diff --git a/.changeset/khaki-dots-change.md b/.changeset/khaki-dots-change.md new file mode 100644 index 00000000..832262a2 --- /dev/null +++ b/.changeset/khaki-dots-change.md @@ -0,0 +1,8 @@ +--- +"@podley/storage": patch +"@podley/test": patch +"@podley/web": patch +--- + +Update put and putBulk methods to return stored entities across all tabular repositories +update package dependencies diff --git a/bun.lock b/bun.lock index 892ad0ba..512ebdfb 100644 --- a/bun.lock +++ b/bun.lock @@ -4,7 +4,7 @@ "": { "name": "podley", "dependencies": { - "caniuse-lite": "^1.0.30001750", + "caniuse-lite": "^1.0.30001751", }, "devDependencies": { "@changesets/cli": "^2.29.7", @@ -12,7 +12,7 @@ "@typescript-eslint/eslint-plugin": "^8.46.1", "@typescript-eslint/parser": "^8.46.1", "concurrently": "^9.2.1", - "eslint": "^9.37.0", + "eslint": "^9.38.0", "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.0.0", @@ -43,7 +43,7 @@ "commander": "^14.0.1", "is-unicode-supported": "^2.1.0", "react": "=18.3.1", - "react-devtools-core": "=4.19.1", + "react-devtools-core": "=4.28.4", "react-reconciler": "=0.31.0", "retuink": "=1.1.5", }, @@ -78,13 +78,13 @@ "tailwind-merge": "2.6.0", }, "devDependencies": { - "@types/react": "^19.2.1", - "@types/react-dom": "^19.2.0", + "@types/react": "^19.2.2", + "@types/react-dom": "^19.2.2", "@vitejs/plugin-react": "^5.0.4", "autoprefixer": "10.4.21", "postcss": "8.5.6", "tailwindcss": "3.4.17", - "vite": "^7.1.9", + "vite": "^7.1.10", }, }, "packages/ai": { @@ -165,9 +165,9 @@ "@podley/sqlite": "workspace:*", "@podley/util": "workspace:*", "@sinclair/typebox": "catalog:", - "@supabase/supabase-js": "^2.74.0", + "@supabase/supabase-js": "^2.75.1", "@types/pg": "^8.15.5", - "fake-indexeddb": "^6.2.2", + "fake-indexeddb": "=6.2.2", "pg": "^8.16.3", }, "peerDependencies": { @@ -216,7 +216,7 @@ "name": "@podley/test", "version": "0.0.19", "devDependencies": { - "@electric-sql/pglite": "^0.3.10", + "@electric-sql/pglite": "^0.3.11", "@podley/ai": "workspace:*", "@podley/ai-provider": "workspace:*", "@podley/job-queue": "workspace:*", @@ -226,13 +226,13 @@ "@podley/tasks": "workspace:*", "@podley/util": "workspace:*", "@sinclair/typebox": "catalog:", - "@supabase/supabase-js": "^2.74.0", + "@supabase/supabase-js": "^2.75.1", "@types/pg": "^8.15.5", - "fake-indexeddb": "^6.2.2", + "fake-indexeddb": "=6.2.2", "pg": "^8.16.3", }, "peerDependencies": { - "@electric-sql/pglite": "^0.3.7", + "@electric-sql/pglite": "^0.3.11", "@podley/ai": "workspace:*", "@podley/ai-provider": "workspace:*", "@podley/job-queue": "workspace:*", @@ -365,7 +365,7 @@ "@codemirror/view": ["@codemirror/view@6.38.4", "", { "dependencies": { "@codemirror/state": "^6.5.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-hduz0suCcUSC/kM8Fq3A9iLwInJDl8fD1xLpTIk+5xkNm8z/FT7UsIa9sOXrkpChh+XXc18RzswE8QqELsVl+g=="], - "@electric-sql/pglite": ["@electric-sql/pglite@0.3.10", "", {}, "sha512-1XtXXprd848aR4hvjNqBc3Gc86zNGmd60x+MgOUShbHYxt+J76N8A81DqTEl275T8xBD0vdTgqR/dJ4yJyz0NQ=="], + "@electric-sql/pglite": ["@electric-sql/pglite@0.3.11", "", {}, "sha512-FJtjnEyez8XgmgyE5Ewmx89TGVN+75ZjykFoExApRIbJBMT4dsbsuZkF/YWLuymGDfGFHDACjvENPMEqg4FoWg=="], "@emnapi/runtime": ["@emnapi/runtime@1.5.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ=="], @@ -425,17 +425,17 @@ "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.1", "", {}, "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ=="], - "@eslint/config-array": ["@eslint/config-array@0.21.0", "", { "dependencies": { "@eslint/object-schema": "^2.1.6", "debug": "^4.3.1", "minimatch": "^3.1.2" } }, "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ=="], + "@eslint/config-array": ["@eslint/config-array@0.21.1", "", { "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.2" } }, "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA=="], - "@eslint/config-helpers": ["@eslint/config-helpers@0.4.0", "", { "dependencies": { "@eslint/core": "^0.16.0" } }, "sha512-WUFvV4WoIwW8Bv0KeKCIIEgdSiFOsulyN0xrMu+7z43q/hkOLXjvb5u7UC9jDxvRzcrbEmuZBX5yJZz1741jog=="], + "@eslint/config-helpers": ["@eslint/config-helpers@0.4.1", "", { "dependencies": { "@eslint/core": "^0.16.0" } }, "sha512-csZAzkNhsgwb0I/UAV6/RGFTbiakPCf0ZrGmrIxQpYvGZ00PhTkSnyKNolphgIvmnJeGw6rcGVEXfTzUnFuEvw=="], "@eslint/core": ["@eslint/core@0.16.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-nmC8/totwobIiFcGkDza3GIKfAw1+hLiYVrh3I1nIomQ8PEr5cxg34jnkmGawul/ep52wGRAcyeDCNtWKSOj4Q=="], "@eslint/eslintrc": ["@eslint/eslintrc@3.3.1", "", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ=="], - "@eslint/js": ["@eslint/js@9.37.0", "", {}, "sha512-jaS+NJ+hximswBG6pjNX0uEJZkrT0zwpVi3BA3vX22aFGjJjmgSTSmPpZCRKmoBL5VY/M6p0xsSJx7rk7sy5gg=="], + "@eslint/js": ["@eslint/js@9.38.0", "", {}, "sha512-UZ1VpFvXf9J06YG9xQBdnzU+kthors6KjhMAl6f4gH4usHyh31rUf2DLGInT8RFYIReYXNSydgPY0V2LuWgl7A=="], - "@eslint/object-schema": ["@eslint/object-schema@2.1.6", "", {}, "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA=="], + "@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="], "@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.0", "", { "dependencies": { "@eslint/core": "^0.16.0", "levn": "^0.4.1" } }, "sha512-sB5uyeq+dwCWyPi31B2gQlVlo+j5brPlWx4yZBrEaRo/nhdDE8Xke1gsGgtiBdaBTxuTkceLVuVt/pclrasb0A=="], @@ -629,19 +629,19 @@ "@sroussey/transformers": ["@sroussey/transformers@3.7.4", "", { "dependencies": { "@huggingface/jinja": "^0.5.1", "onnxruntime-node": "1.22.0", "onnxruntime-web": "1.22.0", "sharp": "^0.34.2" } }, "sha512-/c56INTj27JIQ7qsBq0gN0RcsJW5agmiPs5yVSiqwWNTx0H3Hf/KfrY87AaYXDgRqHrIFmkSsTsIsQTMjY9xjw=="], - "@supabase/auth-js": ["@supabase/auth-js@2.74.0", "", { "dependencies": { "@supabase/node-fetch": "2.6.15" } }, "sha512-EJYDxYhBCOS40VJvfQ5zSjo8Ku7JbTICLTcmXt4xHMQZt4IumpRfHg11exXI9uZ6G7fhsQlNgbzDhFN4Ni9NnA=="], + "@supabase/auth-js": ["@supabase/auth-js@2.75.1", "", { "dependencies": { "@supabase/node-fetch": "2.6.15" } }, "sha512-zktlxtXstQuVys/egDpVsargD9hQtG20CMdtn+mMn7d2Ulkzy2tgUT5FUtpppvCJtd9CkhPHO/73rvi5W6Am5A=="], - "@supabase/functions-js": ["@supabase/functions-js@2.74.0", "", { "dependencies": { "@supabase/node-fetch": "2.6.15" } }, "sha512-VqWYa981t7xtIFVf7LRb9meklHckbH/tqwaML5P3LgvlaZHpoSPjMCNLcquuLYiJLxnh2rio7IxLh+VlvRvSWw=="], + "@supabase/functions-js": ["@supabase/functions-js@2.75.1", "", { "dependencies": { "@supabase/node-fetch": "2.6.15" } }, "sha512-xO+01SUcwVmmo67J7Htxq8FmhkYLFdWkxfR/taxBOI36wACEUNQZmroXGPl4PkpYxBO7TaDsRHYGxUpv9zTKkg=="], "@supabase/node-fetch": ["@supabase/node-fetch@2.6.15", "", { "dependencies": { "whatwg-url": "^5.0.0" } }, "sha512-1ibVeYUacxWYi9i0cf5efil6adJ9WRyZBLivgjs+AUpewx1F3xPi7gLgaASI2SmIQxPoCEjAsLAzKPgMJVgOUQ=="], - "@supabase/postgrest-js": ["@supabase/postgrest-js@2.74.0", "", { "dependencies": { "@supabase/node-fetch": "2.6.15" } }, "sha512-9Ypa2eS0Ib/YQClE+BhDSjx7OKjYEF6LAGjTB8X4HucdboGEwR0LZKctNfw6V0PPIAVjjzZxIlNBXGv0ypIkHw=="], + "@supabase/postgrest-js": ["@supabase/postgrest-js@2.75.1", "", { "dependencies": { "@supabase/node-fetch": "2.6.15" } }, "sha512-FiYBD0MaKqGW8eo4Xqu7/100Xm3ddgh+3qHtqS18yQRoglJTFRQCJzY1xkrGS0JFHE2YnbjL6XCiOBXiG8DK4Q=="], - "@supabase/realtime-js": ["@supabase/realtime-js@2.74.0", "", { "dependencies": { "@supabase/node-fetch": "2.6.15", "@types/phoenix": "^1.6.6", "@types/ws": "^8.18.1", "ws": "^8.18.2" } }, "sha512-K5VqpA4/7RO1u1nyD5ICFKzWKu58bIDcPxHY0aFA7MyWkFd0pzi/XYXeoSsAifnD9p72gPIpgxVXCQZKJg1ktQ=="], + "@supabase/realtime-js": ["@supabase/realtime-js@2.75.1", "", { "dependencies": { "@supabase/node-fetch": "2.6.15", "@types/phoenix": "^1.6.6", "@types/ws": "^8.18.1", "ws": "^8.18.2" } }, "sha512-lBIJ855bUsBFScHA/AY+lxIFkubduUvmwbagbP1hq0wDBNAsYdg3ql80w8YmtXCDjkCwlE96SZqcFn7BGKKJKQ=="], - "@supabase/storage-js": ["@supabase/storage-js@2.74.0", "", { "dependencies": { "@supabase/node-fetch": "2.6.15" } }, "sha512-o0cTQdMqHh4ERDLtjUp1/KGPbQoNwKRxUh6f8+KQyjC5DSmiw/r+jgFe/WHh067aW+WU8nA9Ytw9ag7OhzxEkQ=="], + "@supabase/storage-js": ["@supabase/storage-js@2.75.1", "", { "dependencies": { "@supabase/node-fetch": "2.6.15" } }, "sha512-WdGEhroflt5O398Yg3dpf1uKZZ6N3CGloY9iGsdT873uWbkQKoP0wG8mtx98dh0fhj6dAlzBqOAvnlV12cJfzA=="], - "@supabase/supabase-js": ["@supabase/supabase-js@2.74.0", "", { "dependencies": { "@supabase/auth-js": "2.74.0", "@supabase/functions-js": "2.74.0", "@supabase/node-fetch": "2.6.15", "@supabase/postgrest-js": "2.74.0", "@supabase/realtime-js": "2.74.0", "@supabase/storage-js": "2.74.0" } }, "sha512-IEMM/V6gKdP+N/X31KDIczVzghDpiPWFGLNjS8Rus71KvV6y6ueLrrE/JGCHDrU+9pq5copF3iCa0YQh+9Lq9Q=="], + "@supabase/supabase-js": ["@supabase/supabase-js@2.75.1", "", { "dependencies": { "@supabase/auth-js": "2.75.1", "@supabase/functions-js": "2.75.1", "@supabase/node-fetch": "2.6.15", "@supabase/postgrest-js": "2.75.1", "@supabase/realtime-js": "2.75.1", "@supabase/storage-js": "2.75.1" } }, "sha512-GEPVBvjQimcMd9z5K1eTKTixTRb6oVbudoLQ9JKqTUJnR6GQdBU4OifFZean1AnHfsQwtri1fop2OWwsMv019w=="], "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], @@ -681,7 +681,7 @@ "@types/react": ["@types/react@18.3.12", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" } }, "sha512-D2wOSq/d6Agt28q7rSI3jhU7G6aiuzljDGZ2hTZHIkrTLUI+AF3WMeKkEZ9nN2fkBAlcktT6vcZjDFiIhMYEQw=="], - "@types/react-dom": ["@types/react-dom@19.2.0", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-brtBs0MnE9SMx7px208g39lRmC5uHZs96caOJfTjFcYSLHNamvaSMfJNagChVNkup2SdtOxKX1FDBkRSJe1ZAg=="], + "@types/react-dom": ["@types/react-dom@19.2.2", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw=="], "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], @@ -813,7 +813,7 @@ "camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="], - "caniuse-lite": ["caniuse-lite@1.0.30001750", "", {}, "sha512-cuom0g5sdX6rw00qOoLNSFCJ9/mYIsuSOA+yzpDw8eopiFqcVwQvZHqov0vmEighRxX++cfC0Vg1G+1Iy/mSpQ=="], + "caniuse-lite": ["caniuse-lite@1.0.30001751", "", {}, "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw=="], "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], @@ -957,7 +957,7 @@ "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], - "eslint": ["eslint@9.37.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.0", "@eslint/config-helpers": "^0.4.0", "@eslint/core": "^0.16.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.37.0", "@eslint/plugin-kit": "^0.4.0", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig=="], + "eslint": ["eslint@9.38.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.1", "@eslint/config-helpers": "^0.4.1", "@eslint/core": "^0.16.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.38.0", "@eslint/plugin-kit": "^0.4.0", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw=="], "eslint-plugin-jsx-a11y": ["eslint-plugin-jsx-a11y@6.10.2", "", { "dependencies": { "aria-query": "^5.3.2", "array-includes": "^3.1.8", "array.prototype.flatmap": "^1.3.2", "ast-types-flow": "^0.0.8", "axe-core": "^4.10.0", "axobject-query": "^4.1.0", "damerau-levenshtein": "^1.0.8", "emoji-regex": "^9.2.2", "hasown": "^2.0.2", "jsx-ast-utils": "^3.3.5", "language-tags": "^1.0.9", "minimatch": "^3.1.2", "object.fromentries": "^2.0.8", "safe-regex-test": "^1.0.3", "string.prototype.includes": "^2.0.1" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" } }, "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q=="], @@ -1399,7 +1399,7 @@ "react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="], - "react-devtools-core": ["react-devtools-core@4.19.1", "", { "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" } }, "sha512-2wJiGffPWK0KggBjVwnTaAk+Z3MSxKInHmdzPTrBh1mAarexsa93Kw+WMX88+XjN+TtYgAiLe9xeTqcO5FfJTw=="], + "react-devtools-core": ["react-devtools-core@4.28.4", "", { "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" } }, "sha512-IUZKLv3CimeM07G3vX4H4loxVpByrzq3HvfTX7v9migalwvLs9ZY5D3S3pKR33U+GguYfBBdMMZyToFhsSE/iQ=="], "react-dom": ["react-dom@19.2.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ=="], @@ -1621,7 +1621,7 @@ "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], - "vite": ["vite@7.1.9", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4nVGliEpxmhCL8DslSAUdxlB6+SMrhB0a1v5ijlh1xB1nEPuy1mxaHxysVucLHuWryAxLWg6a5ei+U4TLn/rFg=="], + "vite": ["vite@7.1.10", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-CmuvUBzVJ/e3HGxhg6cYk88NGgTnBoOo7ogtfJJ0fefUWAxN/WDSUa50o+oVBxuIhO8FoEZW0j2eW7sfjs5EtA=="], "w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="], @@ -1703,13 +1703,13 @@ "@manypkg/get-packages/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], - "@podley/web/@types/react": ["@types/react@19.2.1", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-1U5NQWh/GylZQ50ZMnnPjkYHEaGhg6t5i/KI0LDDh3t4E3h3T3vzm+GLY2BRzMfIjSBwzm6tginoZl5z0O/qsA=="], + "@podley/web/@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="], "@podley/web/react": ["react@19.2.0", "", {}, "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ=="], "@supabase/realtime-js/ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], - "@types/react-dom/@types/react": ["@types/react@19.2.0", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-1LOH8xovvsKsCBq1wnT4ntDUdCJKmnEakhsuoUSy6ExlHCkGP2hqnatagYTgFk6oeL0VU31u7SNjunPN+GchtA=="], + "@types/react-dom/@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="], "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], diff --git a/examples/web/package.json b/examples/web/package.json index 52b32f2e..45764424 100644 --- a/examples/web/package.json +++ b/examples/web/package.json @@ -36,13 +36,13 @@ "tailwind-merge": "2.6.0" }, "devDependencies": { - "@types/react": "^19.2.1", - "@types/react-dom": "^19.2.0", + "@types/react": "^19.2.2", + "@types/react-dom": "^19.2.2", "@vitejs/plugin-react": "^5.0.4", "autoprefixer": "10.4.21", "postcss": "8.5.6", "tailwindcss": "3.4.17", - "vite": "^7.1.9" + "vite": "^7.1.10" }, "publishConfig": { "access": "public" diff --git a/package.json b/package.json index 7c159d1f..b0e36d03 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "publish-manual": "bun ./scripts/publish-workspaces.ts" }, "dependencies": { - "caniuse-lite": "^1.0.30001750" + "caniuse-lite": "^1.0.30001751" }, "catalog": { "@sinclair/typebox": "^0.34.41", @@ -38,7 +38,7 @@ "@typescript-eslint/eslint-plugin": "^8.46.1", "@typescript-eslint/parser": "^8.46.1", "concurrently": "^9.2.1", - "eslint": "^9.37.0", + "eslint": "^9.38.0", "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.0.0", diff --git a/packages/storage/package.json b/packages/storage/package.json index 907ee2bf..153d9bec 100644 --- a/packages/storage/package.json +++ b/packages/storage/package.json @@ -43,8 +43,8 @@ "@sinclair/typebox": "catalog:", "pg": "^8.16.3", "@types/pg": "^8.15.5", - "@supabase/supabase-js": "^2.74.0", - "fake-indexeddb": "^6.2.2" + "@supabase/supabase-js": "^2.75.1", + "fake-indexeddb": "=6.2.2" }, "exports": { ".": { diff --git a/packages/storage/src/kv/KvViaTabularRepository.ts b/packages/storage/src/kv/KvViaTabularRepository.ts index 030b0017..f6117992 100644 --- a/packages/storage/src/kv/KvViaTabularRepository.ts +++ b/packages/storage/src/kv/KvViaTabularRepository.ts @@ -44,7 +44,7 @@ export abstract class KvViaTabularRepository< if (shouldStringify) { value = JSON.stringify(value) as Value; } - return await this.tabularRepository.put({ key, value }); + await this.tabularRepository.put({ key, value }); } /** @@ -64,7 +64,7 @@ export abstract class KvViaTabularRepository< return { key, value }; }); - return await this.tabularRepository.putBulk(entities); + await this.tabularRepository.putBulk(entities); } /** diff --git a/packages/storage/src/tabular/FsFolderTabularRepository.ts b/packages/storage/src/tabular/FsFolderTabularRepository.ts index e5e7fa2d..a71daa17 100644 --- a/packages/storage/src/tabular/FsFolderTabularRepository.ts +++ b/packages/storage/src/tabular/FsFolderTabularRepository.ts @@ -70,11 +70,11 @@ export class FsFolderTabularRepository< /** * Stores a row in the repository - * @param key - The primary key object - * @param value - The value object to store + * @param entity - The entity to store + * @returns The stored entity * @emits 'put' event when successful */ - async put(entity: Entity): Promise { + async put(entity: Entity): Promise { await this.setupDirectory(); const filePath = await this.getFilePath(entity); try { @@ -89,16 +89,18 @@ export class FsFolderTabularRepository< } } this.events.emit("put", entity); + return entity; } /** * Stores multiple rows in the repository in a bulk operation * @param entities - Array of entities to store + * @returns Array of stored entities * @emits 'put' event for each entity stored */ - async putBulk(entities: Entity[]): Promise { + async putBulk(entities: Entity[]): Promise { await this.setupDirectory(); - await Promise.all(entities.map(async (entity) => this.put(entity))); + return await Promise.all(entities.map(async (entity) => this.put(entity))); } /** diff --git a/packages/storage/src/tabular/ITabularRepository.ts b/packages/storage/src/tabular/ITabularRepository.ts index 41a181a1..801dc773 100644 --- a/packages/storage/src/tabular/ITabularRepository.ts +++ b/packages/storage/src/tabular/ITabularRepository.ts @@ -72,8 +72,8 @@ export interface ITabularRepository< Entity = Static, > { // Core methods - put(value: Entity): Promise; - putBulk(values: Entity[]): Promise; + put(value: Entity): Promise; + putBulk(values: Entity[]): Promise; get(key: PrimaryKey): Promise; delete(key: PrimaryKey | Entity): Promise; getAll(): Promise; diff --git a/packages/storage/src/tabular/InMemoryTabularRepository.ts b/packages/storage/src/tabular/InMemoryTabularRepository.ts index 7e856573..295e69e0 100644 --- a/packages/storage/src/tabular/InMemoryTabularRepository.ts +++ b/packages/storage/src/tabular/InMemoryTabularRepository.ts @@ -57,22 +57,25 @@ export class InMemoryTabularRepository< /** * Stores a key-value pair in the repository * @param value - The combined object to store - * @emits 'put' event with the fingerprint ID when successful + * @returns The stored entity + * @emits 'put' event with the stored entity when successful */ - async put(value: Entity): Promise { + async put(value: Entity): Promise { const { key } = this.separateKeyValueFromCombined(value); const id = await makeFingerprint(key); this.values.set(id, value); this.events.emit("put", value); + return value; } /** * Stores multiple key-value pairs in the repository in a bulk operation * @param values - Array of combined objects to store + * @returns Array of stored entities * @emits 'put' event for each value stored */ - async putBulk(values: Entity[]): Promise { - await Promise.all(values.map(async (value) => this.put(value))); + async putBulk(values: Entity[]): Promise { + return await Promise.all(values.map(async (value) => this.put(value))); } /** diff --git a/packages/storage/src/tabular/IndexedDbTabularRepository.ts b/packages/storage/src/tabular/IndexedDbTabularRepository.ts index 75b4a6c1..b2973df2 100644 --- a/packages/storage/src/tabular/IndexedDbTabularRepository.ts +++ b/packages/storage/src/tabular/IndexedDbTabularRepository.ts @@ -88,11 +88,11 @@ export class IndexedDbTabularRepository< /** * Stores a row in the repository. - * @param key - The key object. - * @param value - The value object to store. + * @param record - The entity to store. + * @returns The stored entity * @emits put - Emitted when the value is successfully stored */ - async put(record: Entity): Promise { + async put(record: Entity): Promise { const db = await this.setupDatabase(); const { key } = this.separateKeyValueFromCombined(record); // Merge key and value, ensuring all fields are at the root level for indexing @@ -105,7 +105,7 @@ export class IndexedDbTabularRepository< }; request.onsuccess = () => { this.events.emit("put", record); - resolve(); + resolve(record); }; }); } @@ -113,9 +113,10 @@ export class IndexedDbTabularRepository< /** * Stores multiple rows in the repository in a bulk operation. * @param records - Array of entities to store. + * @returns Array of stored entities * @emits put - Emitted for each record successfully stored */ - async putBulk(records: Entity[]): Promise { + async putBulk(records: Entity[]): Promise { const db = await this.setupDatabase(); return new Promise((resolve, reject) => { const transaction = db.transaction(this.table, "readwrite"); @@ -133,7 +134,7 @@ export class IndexedDbTabularRepository< transaction.oncomplete = () => { if (!hasError) { - resolve(); + resolve(records); } }; diff --git a/packages/storage/src/tabular/PostgresTabularRepository.ts b/packages/storage/src/tabular/PostgresTabularRepository.ts index a176ff4d..60a6ceba 100644 --- a/packages/storage/src/tabular/PostgresTabularRepository.ts +++ b/packages/storage/src/tabular/PostgresTabularRepository.ts @@ -9,7 +9,12 @@ import { createServiceToken } from "@podley/util"; import { Static, TObject, TSchema } from "@sinclair/typebox"; import type { Pool } from "pg"; import { BaseSqlTabularRepository } from "./BaseSqlTabularRepository"; -import { ExtractPrimaryKey, ExtractValue, ITabularRepository } from "./ITabularRepository"; +import { + ExtractPrimaryKey, + ExtractValue, + ITabularRepository, + ValueOptionType, +} from "./ITabularRepository"; export const POSTGRES_TABULAR_REPOSITORY = createServiceToken>( "storage.tabularRepository.postgres" @@ -66,7 +71,7 @@ export class PostgresTabularRepository< } const sql = ` CREATE TABLE IF NOT EXISTS "${this.table}" ( - ${this.constructPrimaryKeyColumns()} ${this.constructValueColumns()}, + ${this.constructPrimaryKeyColumns('"')} ${this.constructValueColumns('"')}, PRIMARY KEY (${this.primaryKeyColumnList()}) ) `; @@ -273,6 +278,32 @@ export class PostgresTabularRepository< } } + /** + * Convert PostgreSQL values to JS values. Ensures numeric strings become numbers where schema says number. + */ + protected sqlToJsValue(column: string, value: ValueOptionType): Entity[keyof Entity] { + const typeDef = this.schema.properties[column as keyof typeof this.schema.properties] as + | TSchema + | undefined; + if (typeDef) { + if (value === null && this.isNullable(typeDef)) { + return null as any; + } + const actualType = this.getNonNullType(typeDef); + + // Handle numeric types - PostgreSQL can return them as strings + if (actualType.type === "number" || actualType.type === "integer") { + const v: any = value; + if (typeof v === "number") return v as any; + if (typeof v === "string") { + const parsed = Number(v); + if (!isNaN(parsed)) return parsed as any; + } + } + } + return super.sqlToJsValue(column, value); + } + /** * Determines if a field should be treated as unsigned based on schema properties * @param typeDef - The schema type definition @@ -298,16 +329,16 @@ export class PostgresTabularRepository< * Stores or updates a row in the database. * Uses UPSERT (INSERT ... ON CONFLICT DO UPDATE) for atomic operations. * - * @param key - The primary key object - * @param value - The value object to store - * @emits "put" event with the key when successful + * @param entity - The entity to store + * @returns The entity with any server-generated fields updated + * @emits "put" event with the updated entity when successful */ - async put(entity: Entity): Promise { + async put(entity: Entity): Promise { const db = await this.setupDatabase(); const { key, value } = this.separateKeyValueFromCombined(entity); const sql = ` INSERT INTO "${this.table}" ( - ${this.primaryKeyColumnList()} ${this.valueColumnList() ? ", " + this.valueColumnList() : ""} + ${this.primaryKeyColumnList('"')} ${this.valueColumnList() ? ", " + this.valueColumnList('"') : ""} ) VALUES ( ${[...this.primaryKeyColumns(), ...this.valueColumns()] @@ -318,20 +349,30 @@ export class PostgresTabularRepository< !this.valueColumnList() ? "" : ` - ON CONFLICT (${this.primaryKeyColumnList()}) DO UPDATE + ON CONFLICT (${this.primaryKeyColumnList('"')}) DO UPDATE SET ${(this.valueColumns() as string[]) - .map((col, i) => `${col} = $${i + this.primaryKeyColumns().length + 1}`) + .map((col, i) => `"${col}" = $${i + this.primaryKeyColumns().length + 1}`) .join(", ")} ` } + RETURNING * `; const primaryKeyParams = this.getPrimaryKeyAsOrderedArray(key); const valueParams = this.getValueAsOrderedArray(value); const params = [...primaryKeyParams, ...valueParams]; - await db.query(sql, params); - this.events.emit("put", entity); + const result = await db.query(sql, params); + + const updatedEntity = result.rows[0] as Entity; + // Convert blob fields from SQL to JS values + for (const key in this.schema.properties) { + // @ts-ignore + updatedEntity[key] = this.sqlToJsValue(key, updatedEntity[key]); + } + + this.events.emit("put", updatedEntity); + return updatedEntity; } /** @@ -339,10 +380,11 @@ export class PostgresTabularRepository< * Uses batch INSERT with ON CONFLICT for better performance. * * @param entities - Array of entities to store + * @returns Array of entities with any server-generated fields updated * @emits "put" event for each entity stored */ - async putBulk(entities: Entity[]): Promise { - if (entities.length === 0) return; + async putBulk(entities: Entity[]): Promise { + if (entities.length === 0) return []; const db = await this.setupDatabase(); @@ -373,14 +415,14 @@ export class PostgresTabularRepository< const sql = ` INSERT INTO "${this.table}" ( - ${this.primaryKeyColumnList()} ${this.valueColumnList() ? ", " + this.valueColumnList() : ""} + ${this.primaryKeyColumnList('"')} ${this.valueColumnList() ? ", " + this.valueColumnList('"') : ""} ) VALUES ${valuesClauses} ${ !this.valueColumnList() ? "" : ` - ON CONFLICT (${this.primaryKeyColumnList()}) DO UPDATE + ON CONFLICT (${this.primaryKeyColumnList('"')}) DO UPDATE SET ${(this.valueColumns() as string[]) .map((col) => { @@ -390,14 +432,27 @@ export class PostgresTabularRepository< .join(", ")} ` } + RETURNING * `; - await db.query(sql, allParams); + const result = await db.query(sql, allParams); + + const updatedEntities = result.rows.map((row) => { + const entity = row as Entity; + // Convert blob fields from SQL to JS values + for (const key in this.schema.properties) { + // @ts-ignore + entity[key] = this.sqlToJsValue(key, entity[key]); + } + return entity; + }); // Emit events for each entity - for (const entity of entities) { + for (const entity of updatedEntities) { this.events.emit("put", entity); } + + return updatedEntities; } /** @@ -410,18 +465,18 @@ export class PostgresTabularRepository< async get(key: PrimaryKey): Promise { const db = await this.setupDatabase(); const whereClauses = (this.primaryKeyColumns() as string[]) - .map((discriminatorKey, i) => `${discriminatorKey} = $${i + 1}`) + .map((discriminatorKey, i) => `"${discriminatorKey}" = $${i + 1}`) .join(" AND "); - const sql = `SELECT ${this.valueColumnList()} FROM "${this.table}" WHERE ${whereClauses}`; + const sql = `SELECT * FROM "${this.table}" WHERE ${whereClauses}`; const params = this.getPrimaryKeyAsOrderedArray(key); const result = await db.query(sql, params); let val: Entity | undefined; if (result.rows.length > 0) { val = result.rows[0] as Entity; - // iterate through the schema and check if value is a blob base on the schema - for (const key in this.valueSchema.properties) { + // Convert all columns according to schema + for (const key in this.schema.properties) { // @ts-ignore val[key] = this.sqlToJsValue(key, val[key]); } @@ -469,13 +524,23 @@ export class PostgresTabularRepository< const whereClauses = Object.keys(key) .map((key, i) => `"${key}" = $${i + 1}`) .join(" AND "); - const whereClauseValues = Object.values(key); + const whereClauseValues = Object.entries(key).map(([k, v]) => + // @ts-ignore + this.jsToSqlValue(k, v as any) + ); const sql = `SELECT * FROM "${this.table}" WHERE ${whereClauses}`; // @ts-ignore const result = await db.query(sql, whereClauseValues); if (result.rows.length > 0) { + // Convert all columns according to schema + for (const row of result.rows) { + for (const k in this.schema.properties) { + // @ts-ignore + row[k] = this.sqlToJsValue(k, row[k]); + } + } this.events.emit("search", key, result.rows); return result.rows; } else { @@ -510,7 +575,18 @@ export class PostgresTabularRepository< const db = await this.setupDatabase(); const sql = `SELECT * FROM "${this.table}"`; const result = await db.query(sql); - return result.rows.length ? result.rows : undefined; + + if (result.rows.length > 0) { + // Convert all columns according to schema + for (const row of result.rows) { + for (const key in this.schema.properties) { + // @ts-ignore + row[key] = this.sqlToJsValue(key, row[key]); + } + } + return result.rows; + } + return undefined; } /** @@ -541,7 +617,7 @@ export class PostgresTabularRepository< if (!(column in this.schema.properties)) { throw new Error(`Schema must have a ${String(column)} field to use deleteSearch`); } - return `${String(column)} ${operator} $1`; + return `"${String(column)}" ${operator} $1`; } /** diff --git a/packages/storage/src/tabular/SqliteTabularRepository.ts b/packages/storage/src/tabular/SqliteTabularRepository.ts index bdb4a9a8..357f2685 100644 --- a/packages/storage/src/tabular/SqliteTabularRepository.ts +++ b/packages/storage/src/tabular/SqliteTabularRepository.ts @@ -224,11 +224,11 @@ export class SqliteTabularRepository< /** * Stores a key-value pair in the database - * @param key - The primary key object - * @param value - The value object to store + * @param entity - The entity to store + * @returns The entity with any server-generated fields updated * @emits 'put' event when successful */ - async put(entity: Entity): Promise { + async put(entity: Entity): Promise { const db = await this.setupDatabase(); const { key, value } = this.separateKeyValueFromCombined(entity); const sql = ` @@ -239,6 +239,7 @@ export class SqliteTabularRepository< ${this.primaryKeyColumns().map((i) => "?")} ${this.valueColumns().length > 0 ? ", " + this.valueColumns().map((i) => "?") : ""} ) + RETURNING * `; const stmt = db.prepare(sql); @@ -247,52 +248,81 @@ export class SqliteTabularRepository< const params = [...primaryKeyParams, ...valueParams]; // @ts-ignore - const result = stmt.run(...params); + const updatedEntity = stmt.get(...params) as Entity; - this.events.emit("put", entity); + // Convert all columns according to schema + for (const k in this.schema.properties) { + // @ts-ignore + updatedEntity[k] = this.sqlToJsValue(k, updatedEntity[k]); + } + + this.events.emit("put", updatedEntity); + return updatedEntity; } /** * Stores multiple key-value pairs in the database in a bulk operation * @param entities - Array of entities to store + * @returns Array of entities with any server-generated fields updated * @emits 'put' event for each entity stored */ - async putBulk(entities: Entity[]): Promise { - if (entities.length === 0) return; + async putBulk(entities: Entity[]): Promise { + if (entities.length === 0) return []; const db = await this.setupDatabase(); - const allParams: any[] = []; - const valuesPerRow = this.primaryKeyColumns().length + this.valueColumns().length; - // Build the VALUES clauses - one for each entity - const valuesClauses = entities - .map((entity, index) => { + // For SQLite bulk inserts with RETURNING, we need to do them individually + // or use a transaction with multiple INSERT statements + const updatedEntities: Entity[] = []; + + // Use a transaction for better performance + const transaction = db.transaction((entitiesToInsert: Entity[]) => { + for (const entity of entitiesToInsert) { const { key, value } = this.separateKeyValueFromCombined(entity); + const sql = ` + INSERT OR REPLACE INTO \`${ + this.table + }\` (${this.primaryKeyColumnList()} ${this.valueColumnList() ? ", " + this.valueColumnList() : ""}) + VALUES ( + ${this.primaryKeyColumns() + .map(() => "?") + .join(", ")} + ${ + this.valueColumns().length > 0 + ? ", " + + this.valueColumns() + .map(() => "?") + .join(", ") + : "" + } + ) + RETURNING * + `; + const stmt = db.prepare(sql); const primaryKeyParams = this.getPrimaryKeyAsOrderedArray(key); const valueParams = this.getValueAsOrderedArray(value); - const entityParams = [...primaryKeyParams, ...valueParams]; - allParams.push(...entityParams); - const placeholders = Array(valuesPerRow).fill("?").join(", "); - return `(${placeholders})`; - }) - .join(", "); + const params = [...primaryKeyParams, ...valueParams]; - const sql = ` - INSERT OR REPLACE INTO \`${ - this.table - }\` (${this.primaryKeyColumnList()} ${this.valueColumnList() ? ", " + this.valueColumnList() : ""}) - VALUES ${valuesClauses} - `; + // @ts-ignore + const updatedEntity = stmt.get(...params) as Entity; - const stmt = db.prepare(sql); + // Convert all columns according to schema + for (const k in this.schema.properties) { + // @ts-ignore + updatedEntity[k] = this.sqlToJsValue(k, updatedEntity[k]); + } - // Execute the single statement with all parameters - // @ts-ignore - stmt.run(...allParams); + updatedEntities.push(updatedEntity); + } + }); - for (const entity of entities) { + transaction(entities); + + for (const entity of updatedEntities) { this.events.emit("put", entity); } + + return updatedEntities; } /** diff --git a/packages/storage/src/tabular/SupabaseTabularRepository.ts b/packages/storage/src/tabular/SupabaseTabularRepository.ts index c91cfc11..71d55df4 100644 --- a/packages/storage/src/tabular/SupabaseTabularRepository.ts +++ b/packages/storage/src/tabular/SupabaseTabularRepository.ts @@ -9,7 +9,12 @@ import { createServiceToken } from "@podley/util"; import { Static, TObject, TSchema } from "@sinclair/typebox"; import type { SupabaseClient } from "@supabase/supabase-js"; import { BaseSqlTabularRepository } from "./BaseSqlTabularRepository"; -import { ExtractPrimaryKey, ExtractValue, ITabularRepository } from "./ITabularRepository"; +import { + ExtractPrimaryKey, + ExtractValue, + ITabularRepository, + ValueOptionType, +} from "./ITabularRepository"; export const SUPABASE_TABULAR_REPOSITORY = createServiceToken>( "storage.tabularRepository.supabase" @@ -66,7 +71,7 @@ export class SupabaseTabularRepository< } const sql = ` CREATE TABLE IF NOT EXISTS "${this.table}" ( - ${this.constructPrimaryKeyColumns()}${this.constructValueColumns()}, + ${this.constructPrimaryKeyColumns('"')} ${this.constructValueColumns('"')}, PRIMARY KEY (${this.primaryKeyColumnList()}) ) `; @@ -280,6 +285,32 @@ export class SupabaseTabularRepository< } } + /** + * Convert Supabase values to JS values. Ensures numeric strings become numbers where schema says number. + */ + protected sqlToJsValue(column: string, value: ValueOptionType): Entity[keyof Entity] { + const typeDef = this.schema.properties[column as keyof typeof this.schema.properties] as + | TSchema + | undefined; + if (typeDef) { + if (value === null && this.isNullable(typeDef)) { + return null as any; + } + const actualType = this.getNonNullType(typeDef); + + // Handle numeric types - Supabase can return them as strings + if (actualType.type === "number" || actualType.type === "integer") { + const v: any = value; + if (typeof v === "number") return v as any; + if (typeof v === "string") { + const parsed = Number(v); + if (!isNaN(parsed)) return parsed as any; + } + } + } + return super.sqlToJsValue(column, value); + } + /** * Determines if a field should be treated as unsigned based on schema properties * @param typeDef - The schema type definition @@ -306,16 +337,28 @@ export class SupabaseTabularRepository< * Uses UPSERT (INSERT ... ON CONFLICT DO UPDATE) for atomic operations. * * @param entity - The entity to store - * @emits "put" event with the entity when successful + * @returns The entity with any server-generated fields updated + * @emits "put" event with the updated entity when successful */ - async put(entity: Entity): Promise { + async put(entity: Entity): Promise { await this.setupDatabase(); - const { error } = await this.client + const { data, error } = await this.client .from(this.table) - .upsert(entity as any, { onConflict: this.primaryKeyColumnList() }); + .upsert(entity as any, { onConflict: this.primaryKeyColumnList() }) + .select() + .single(); if (error) throw error; - this.events.emit("put", entity); + const updatedEntity = data as Entity; + + // Convert all columns from SQL to JS values + for (const key in this.schema.properties) { + // @ts-ignore + updatedEntity[key] = this.sqlToJsValue(key, updatedEntity[key]); + } + + this.events.emit("put", updatedEntity); + return updatedEntity; } /** @@ -323,22 +366,31 @@ export class SupabaseTabularRepository< * Uses batch INSERT with ON CONFLICT for better performance. * * @param entities - Array of entities to store + * @returns Array of entities with any server-generated fields updated * @emits "put" event for each entity stored */ - async putBulk(entities: Entity[]): Promise { - if (entities.length === 0) return; + async putBulk(entities: Entity[]): Promise { + if (entities.length === 0) return []; await this.setupDatabase(); - const { error } = await this.client + const { data, error } = await this.client .from(this.table) - .upsert(entities as any[], { onConflict: this.primaryKeyColumnList() }); + .upsert(entities as any[], { onConflict: this.primaryKeyColumnList() }) + .select(); if (error) throw error; + const updatedEntities = data as Entity[]; - // Emit events for each entity - for (const entity of entities) { + // Convert all columns from SQL to JS values and emit events + for (const entity of updatedEntities) { + for (const key in this.schema.properties) { + // @ts-ignore + entity[key] = this.sqlToJsValue(key, entity[key]); + } this.events.emit("put", entity); } + + return updatedEntities; } /** @@ -370,6 +422,13 @@ export class SupabaseTabularRepository< } const val = data as Entity | undefined; + if (val) { + // Convert all columns from SQL to JS values + for (const key in this.schema.properties) { + // @ts-ignore + val[key] = this.sqlToJsValue(key, val[key]); + } + } this.events.emit("get", key, val); return val; } @@ -419,6 +478,13 @@ export class SupabaseTabularRepository< if (error) throw error; if (data && data.length > 0) { + // Convert all columns from SQL to JS values + for (const row of data) { + for (const key in this.schema.properties) { + // @ts-ignore + row[key] = this.sqlToJsValue(key, row[key]); + } + } this.events.emit("search", searchCriteria, data as Entity[]); return data as Entity[]; } else { @@ -459,7 +525,18 @@ export class SupabaseTabularRepository< const { data, error } = await this.client.from(this.table).select("*"); if (error) throw error; - return data && data.length ? (data as Entity[]) : undefined; + + if (data && data.length) { + // Convert all columns from SQL to JS values + for (const row of data) { + for (const key in this.schema.properties) { + // @ts-ignore + row[key] = this.sqlToJsValue(key, row[key]); + } + } + return data as Entity[]; + } + return undefined; } /** diff --git a/packages/storage/src/tabular/TabularRepository.ts b/packages/storage/src/tabular/TabularRepository.ts index 61fd8d1a..ab73dd3c 100644 --- a/packages/storage/src/tabular/TabularRepository.ts +++ b/packages/storage/src/tabular/TabularRepository.ts @@ -215,8 +215,8 @@ export abstract class TabularRepository< /** * Core abstract methods that must be implemented by concrete repositories */ - abstract put(value: Entity): Promise; - abstract putBulk(values: Entity[]): Promise; + abstract put(value: Entity): Promise; + abstract putBulk(values: Entity[]): Promise; abstract get(key: PrimaryKey): Promise; abstract delete(key: PrimaryKey | Entity): Promise; abstract getAll(): Promise; diff --git a/packages/test/package.json b/packages/test/package.json index 48f649ab..559f5a00 100644 --- a/packages/test/package.json +++ b/packages/test/package.json @@ -41,7 +41,7 @@ "@podley/tasks": "workspace:*", "@podley/util": "workspace:*", "@podley/sqlite": "workspace:*", - "@electric-sql/pglite": "^0.3.7", + "@electric-sql/pglite": "^0.3.11", "@sinclair/typebox": "catalog:", "pg": "^8.16.3" }, @@ -83,12 +83,12 @@ "@podley/tasks": "workspace:*", "@podley/util": "workspace:*", "@podley/sqlite": "workspace:*", - "@electric-sql/pglite": "^0.3.10", + "@electric-sql/pglite": "^0.3.11", "@sinclair/typebox": "catalog:", - "fake-indexeddb": "^6.2.2", + "fake-indexeddb": "=6.2.2", "pg": "^8.16.3", "@types/pg": "^8.15.5", - "@supabase/supabase-js": "^2.74.0" + "@supabase/supabase-js": "^2.75.1" }, "publishConfig": { "access": "public" diff --git a/packages/test/src/test/helpers/SupabaseMockClient.ts b/packages/test/src/test/helpers/SupabaseMockClient.ts index 25f782a0..88b6ddaf 100644 --- a/packages/test/src/test/helpers/SupabaseMockClient.ts +++ b/packages/test/src/test/helpers/SupabaseMockClient.ts @@ -151,7 +151,25 @@ export function createSupabaseMockClient(): SupabaseClient { }; return { - select: executeUpsert, + select: () => { + return { + single: async () => { + const result = await executeUpsert(); + if (result.error) return result; + // Return single record or first record from array + const singleData = Array.isArray(result.data) ? result.data[0] : result.data; + return { data: singleData, error: null }; + }, + then: async (resolve: any, reject: any) => { + try { + const result = await executeUpsert(); + resolve(result); + } catch (error) { + reject?.(error); + } + }, + }; + }, then: async (resolve: any, reject: any) => { try { const result = await executeUpsert(); diff --git a/packages/test/src/test/storage-tabular/genericTabularRepositoryTests.ts b/packages/test/src/test/storage-tabular/genericTabularRepositoryTests.ts index 2f710285..4d629797 100644 --- a/packages/test/src/test/storage-tabular/genericTabularRepositoryTests.ts +++ b/packages/test/src/test/storage-tabular/genericTabularRepositoryTests.ts @@ -84,6 +84,70 @@ export function runGenericTabularRepositoryTests( await repository.putBulk([]); // Should not throw an error }); + + it("should return the entity from put()", async () => { + const key = { name: "key1", type: "string1" }; + const entity = { ...key, option: "value1", success: true }; + + const returned = await repository.put(entity); + + // Verify returned entity matches what was stored + expect(returned).toBeDefined(); + expect(returned.name).toEqual(entity.name); + expect(returned.type).toEqual(entity.type); + expect(returned.option).toEqual(entity.option); + expect(!!returned.success).toEqual(entity.success); + }); + + it("should return updated entity from put() when upserting", async () => { + const key = { name: "key1", type: "string1" }; + const entity1 = { ...key, option: "value1", success: true }; + const entity2 = { ...key, option: "value2", success: false }; + + // First insert + const returned1 = await repository.put(entity1); + expect(returned1.option).toEqual("value1"); + expect(!!returned1.success).toEqual(true); + + // Update via upsert + const returned2 = await repository.put(entity2); + expect(returned2.option).toEqual("value2"); + expect(!!returned2.success).toEqual(false); + + // Verify database was updated + const stored = await repository.get(key); + expect(stored?.option).toEqual("value2"); + expect(!!stored?.success).toEqual(false); + }); + + it("should return array of entities from putBulk()", async () => { + const entities = [ + { name: "key1", type: "string1", option: "value1", success: true }, + { name: "key2", type: "string2", option: "value2", success: false }, + { name: "key3", type: "string3", option: "value3", success: true }, + ]; + + const returned = await repository.putBulk(entities); + + // Verify returned array matches input + expect(returned).toBeDefined(); + expect(returned.length).toEqual(3); + + for (let i = 0; i < entities.length; i++) { + expect(returned[i].name).toEqual(entities[i].name); + expect(returned[i].type).toEqual(entities[i].type); + expect(returned[i].option).toEqual(entities[i].option); + expect(!!returned[i].success).toEqual(entities[i].success); + } + }); + + it("should return empty array from putBulk() with empty input", async () => { + const returned = await repository.putBulk([]); + + expect(returned).toBeDefined(); + expect(Array.isArray(returned)).toBe(true); + expect(returned.length).toEqual(0); + }); }); // Only run compound index tests if createCompoundRepository is provided @@ -378,5 +442,181 @@ export function runGenericTabularRepositoryTests( expect(remaining?.length).toBe(2); }); }); + + describe("return value tests with timestamps", () => { + let repository: ITabularRepository; + + beforeEach(async () => { + repository = await createSearchableRepository(); + }); + + afterEach(async () => { + await repository.deleteAll(); + }); + + it("should return entity with timestamps from put()", async () => { + const now = new Date().toISOString(); + const entity = { + id: "1", + category: "electronics", + subcategory: "phones", + value: 100, + createdAt: now, + updatedAt: now, + }; + + const returned = await repository.put(entity); + + // Verify all fields are returned + expect(returned).toBeDefined(); + expect(returned.id).toEqual("1"); + expect(returned.category).toEqual("electronics"); + expect(returned.subcategory).toEqual("phones"); + expect(returned.value).toEqual(100); + expect(returned.createdAt).toBeDefined(); + expect(returned.updatedAt).toBeDefined(); + }); + + it("should return entities with timestamps from putBulk()", async () => { + const now = new Date().toISOString(); + const entities = [ + { + id: "1", + category: "electronics", + subcategory: "phones", + value: 100, + createdAt: now, + updatedAt: now, + }, + { + id: "2", + category: "books", + subcategory: "fiction", + value: 200, + createdAt: now, + updatedAt: now, + }, + ]; + + const returned = await repository.putBulk(entities); + + // Verify all entities are returned with all fields + expect(returned).toBeDefined(); + expect(returned.length).toEqual(2); + + for (let i = 0; i < entities.length; i++) { + expect(returned[i].id).toEqual(entities[i].id); + expect(returned[i].category).toEqual(entities[i].category); + expect(returned[i].subcategory).toEqual(entities[i].subcategory); + expect(returned[i].value).toEqual(entities[i].value); + expect(returned[i].createdAt).toBeDefined(); + expect(returned[i].updatedAt).toBeDefined(); + } + }); + + it("should return updated timestamps when upserting", async () => { + const now = new Date().toISOString(); + const entity1 = { + id: "1", + category: "electronics", + subcategory: "phones", + value: 100, + createdAt: now, + updatedAt: now, + }; + + // First insert + const returned1 = await repository.put(entity1); + expect(returned1.value).toEqual(100); + + // Wait a moment to ensure timestamps would differ + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Update with new data + const later = new Date().toISOString(); + const entity2 = { + id: "1", + category: "electronics", + subcategory: "phones", + value: 150, + createdAt: now, // Keep original created time + updatedAt: later, // New update time + }; + + const returned2 = await repository.put(entity2); + expect(returned2.value).toEqual(150); + expect(returned2.updatedAt).toBeDefined(); + + // Verify the update persisted + const stored = await repository.get({ id: "1" }); + expect(stored?.value).toEqual(150); + }); + + it("should return consistent data between put() result and get()", async () => { + const now = new Date().toISOString(); + const entity = { + id: "1", + category: "electronics", + subcategory: "phones", + value: 100, + createdAt: now, + updatedAt: now, + }; + + const returned = await repository.put(entity); + const retrieved = await repository.get({ id: "1" }); + + // Verify returned and retrieved match + expect(retrieved).toBeDefined(); + expect(returned.id).toEqual(retrieved!.id); + expect(returned.category).toEqual(retrieved!.category); + expect(returned.subcategory).toEqual(retrieved!.subcategory); + expect(returned.value).toEqual(retrieved!.value); + expect(returned.createdAt).toEqual(retrieved!.createdAt); + expect(returned.updatedAt).toEqual(retrieved!.updatedAt); + }); + + it("should return consistent data between putBulk() results and getAll()", async () => { + const now = new Date().toISOString(); + const entities = [ + { + id: "1", + category: "electronics", + subcategory: "phones", + value: 100, + createdAt: now, + updatedAt: now, + }, + { + id: "2", + category: "books", + subcategory: "fiction", + value: 200, + createdAt: now, + updatedAt: now, + }, + ]; + + const returned = await repository.putBulk(entities); + const retrieved = await repository.getAll(); + + // Verify returned and retrieved match + expect(retrieved).toBeDefined(); + expect(returned.length).toEqual(retrieved!.length); + + // Sort both arrays by id for comparison + const sortedReturned = returned.sort((a, b) => a.id.localeCompare(b.id)); + const sortedRetrieved = retrieved!.sort((a, b) => a.id.localeCompare(b.id)); + + for (let i = 0; i < sortedReturned.length; i++) { + expect(sortedReturned[i].id).toEqual(sortedRetrieved[i].id); + expect(sortedReturned[i].category).toEqual(sortedRetrieved[i].category); + expect(sortedReturned[i].subcategory).toEqual(sortedRetrieved[i].subcategory); + expect(sortedReturned[i].value).toEqual(sortedRetrieved[i].value); + expect(sortedReturned[i].createdAt).toEqual(sortedRetrieved[i].createdAt); + expect(sortedReturned[i].updatedAt).toEqual(sortedRetrieved[i].updatedAt); + } + }); + }); } }