From 576fda3adc2a3f0f470dd00a63d51cc3adc1f2f3 Mon Sep 17 00:00:00 2001 From: Mikkel Georgsen Date: Wed, 1 Apr 2026 01:00:25 +0200 Subject: [PATCH] feat(09-01): install @libsql/client, schema, DB init, path helpers - Install @libsql/client@^0.17.2 to server package - Create skill-registry-schema.ts with 4 sqliteTable definitions (skills, skillVersions, skillFiles, communityRatings) - Create skill-registry-db.ts with lazy singleton getSkillRegistryDb() and resetSkillRegistryDb() - Add resolveSkillRegistryDbPath() and resolveSkillCacheDir() to home-paths.ts - Add skill-registry-schema.test.ts with 8 passing tests (TDD green) --- pnpm-lock.yaml | 264 +++++++++++++++++- server/package.json | 1 + .../__tests__/skill-registry-schema.test.ts | 136 +++++++++ server/src/home-paths.ts | 9 + server/src/services/skill-registry-db.ts | 73 +++++ server/src/services/skill-registry-schema.ts | 38 +++ 6 files changed, 514 insertions(+), 7 deletions(-) create mode 100644 server/src/__tests__/skill-registry-schema.test.ts create mode 100644 server/src/services/skill-registry-db.ts create mode 100644 server/src/services/skill-registry-schema.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cc53db71..486eac08 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -78,7 +78,7 @@ importers: version: 17.3.1 drizzle-orm: specifier: 0.38.4 - version: 0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4) + version: 0.38.4(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.2)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4) embedded-postgres: specifier: ^18.1.0-beta.16 version: 18.1.0-beta.16(patch_hash=55uhvnotpqyiy37rn3pqpukhei) @@ -236,7 +236,7 @@ importers: version: link:../shared drizzle-orm: specifier: ^0.38.4 - version: 0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4) + version: 0.38.4(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.2)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4) embedded-postgres: specifier: ^18.1.0-beta.16 version: 18.1.0-beta.16(patch_hash=55uhvnotpqyiy37rn3pqpukhei) @@ -449,6 +449,9 @@ importers: '@aws-sdk/client-s3': specifier: ^3.888.0 version: 3.994.0 + '@libsql/client': + specifier: ^0.17.2 + version: 0.17.2 '@paperclipai/adapter-claude-local': specifier: workspace:* version: link:../packages/adapters/claude-local @@ -490,7 +493,7 @@ importers: version: 3.0.1(ajv@8.18.0) better-auth: specifier: 1.4.18 - version: 1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4))(pg@8.18.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.21.0)) + version: 1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.38.4(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.2)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4))(pg@8.18.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.21.0)) chokidar: specifier: ^4.0.3 version: 4.0.3 @@ -505,7 +508,7 @@ importers: version: 17.3.1 drizzle-orm: specifier: ^0.38.4 - version: 0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4) + version: 0.38.4(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.2)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4) embedded-postgres: specifier: ^18.1.0-beta.16 version: 18.1.0-beta.16(patch_hash=55uhvnotpqyiy37rn3pqpukhei) @@ -2020,6 +2023,63 @@ packages: '@lezer/yaml@1.0.4': resolution: {integrity: sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==} + '@libsql/client@0.17.2': + resolution: {integrity: sha512-0aw0S3iQMHvOxfRt5j1atoCCPMT3gjsB2PS8/uxSM1DcDn39xqz6RlgSMxtP8I3JsxIXAFuw7S41baLEw0Zi+Q==} + + '@libsql/core@0.17.2': + resolution: {integrity: sha512-L8qv12HZ/jRBcETVR3rscP0uHNxh+K3EABSde6scCw7zfOdiLqO3MAkJaeE1WovPsjXzsN/JBoZED4+7EZVT3g==} + + '@libsql/darwin-arm64@0.5.29': + resolution: {integrity: sha512-K+2RIB1OGFPYQbfay48GakLhqf3ArcbHqPFu7EZiaUcRgFcdw8RoltsMyvbj5ix2fY0HV3Q3Ioa/ByvQdaSM0A==} + cpu: [arm64] + os: [darwin] + + '@libsql/darwin-x64@0.5.29': + resolution: {integrity: sha512-OtT+KFHsKFy1R5FVadr8FJ2Bb1mghtXTyJkxv0trocq7NuHntSki1eUbxpO5ezJesDvBlqFjnWaYYY516QNLhQ==} + cpu: [x64] + os: [darwin] + + '@libsql/hrana-client@0.9.0': + resolution: {integrity: sha512-pxQ1986AuWfPX4oXzBvLwBnfgKDE5OMhAdR/5cZmRaB4Ygz5MecQybvwZupnRz341r2CtFmbk/BhSu7k2Lm+Jw==} + + '@libsql/isomorphic-ws@0.1.5': + resolution: {integrity: sha512-DtLWIH29onUYR00i0GlQ3UdcTRC6EP4u9w/h9LxpUZJWRMARk6dQwZ6Jkd+QdwVpuAOrdxt18v0K2uIYR3fwFg==} + + '@libsql/linux-arm-gnueabihf@0.5.29': + resolution: {integrity: sha512-CD4n4zj7SJTHso4nf5cuMoWoMSS7asn5hHygsDuhRl8jjjCTT3yE+xdUvI4J7zsyb53VO5ISh4cwwOtf6k2UhQ==} + cpu: [arm] + os: [linux] + + '@libsql/linux-arm-musleabihf@0.5.29': + resolution: {integrity: sha512-2Z9qBVpEJV7OeflzIR3+l5yAd4uTOLxklScYTwpZnkm2vDSGlC1PRlueLaufc4EFITkLKXK2MWBpexuNJfMVcg==} + cpu: [arm] + os: [linux] + + '@libsql/linux-arm64-gnu@0.5.29': + resolution: {integrity: sha512-gURBqaiXIGGwFNEaUj8Ldk7Hps4STtG+31aEidCk5evMMdtsdfL3HPCpvys+ZF/tkOs2MWlRWoSq7SOuCE9k3w==} + cpu: [arm64] + os: [linux] + + '@libsql/linux-arm64-musl@0.5.29': + resolution: {integrity: sha512-fwgYZ0H8mUkyVqXZHF3mT/92iIh1N94Owi/f66cPVNsk9BdGKq5gVpoKO+7UxaNzuEH1roJp2QEwsCZMvBLpqg==} + cpu: [arm64] + os: [linux] + + '@libsql/linux-x64-gnu@0.5.29': + resolution: {integrity: sha512-y14V0vY0nmMC6G0pHeJcEarcnGU2H6cm21ZceRkacWHvQAEhAG0latQkCtoS2njFOXiYIg+JYPfAoWKbi82rkg==} + cpu: [x64] + os: [linux] + + '@libsql/linux-x64-musl@0.5.29': + resolution: {integrity: sha512-gquqwA/39tH4pFl+J9n3SOMSymjX+6kZ3kWgY3b94nXFTwac9bnFNMffIomgvlFaC4ArVqMnOZD3nuJ3H3VO1w==} + cpu: [x64] + os: [linux] + + '@libsql/win32-x64-msvc@0.5.29': + resolution: {integrity: sha512-4/0CvEdhi6+KjMxMaVbFM2n2Z44escBRoEYpR+gZg64DdetzGnYm8mcNLcoySaDJZNaBd6wz5DNdgRmcI4hXcg==} + cpu: [x64] + os: [win32] + '@marijn/find-cluster-break@1.0.2': resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==} @@ -2040,6 +2100,9 @@ packages: '@mermaid-js/parser@1.0.0': resolution: {integrity: sha512-vvK0Hi/VWndxoh03Mmz6wa1KDriSPjS2XMZL/1l19HFwygiObEEoEwSDxOqyLzzAI6J2PU3261JjTMTO7x+BPw==} + '@neon-rs/load@0.0.4': + resolution: {integrity: sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw==} + '@noble/ciphers@2.1.1': resolution: {integrity: sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==} engines: {node: '>= 20.19.0'} @@ -3856,6 +3919,9 @@ packages: engines: {node: '>=20'} hasBin: true + cross-fetch@4.1.0: + resolution: {integrity: sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -4036,6 +4102,10 @@ packages: dagre-d3-es@7.0.13: resolution: {integrity: sha512-efEhnxpSuwpYOKRm/L5KbqoZmNNukHa/Flty4Wp62JRvgH2ojwVgPgdYyr4twpieZnyRDdIH7PY2mopX26+j2Q==} + data-uri-to-buffer@4.0.1: + resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} + engines: {node: '>= 12'} + data-urls@7.0.0: resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -4099,6 +4169,10 @@ packages: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} + detect-libc@2.0.2: + resolution: {integrity: sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==} + engines: {node: '>=8'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -4392,6 +4466,10 @@ packages: picomatch: optional: true + fetch-blob@3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} + finalhandler@2.1.1: resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} engines: {node: '>= 18.0.0'} @@ -4404,6 +4482,10 @@ packages: resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} engines: {node: '>=0.4.x'} + formdata-polyfill@4.0.10: + resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} + engines: {node: '>=12.20.0'} + formidable@3.5.4: resolution: {integrity: sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==} engines: {node: '>=14.0.0'} @@ -4602,6 +4684,9 @@ packages: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} + js-base64@3.7.8: + resolution: {integrity: sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -4667,6 +4752,11 @@ packages: engines: {node: '>=16'} hasBin: true + libsql@0.5.29: + resolution: {integrity: sha512-8lMP8iMgiBzzoNbAPQ59qdVcj6UaE/Vnm+fiwX4doX4Narook0a4GPKWBEv+CR8a1OwbfkgL18uBfBjWdF0Fzg==} + cpu: [x64, arm64, wasm32, arm] + os: [darwin, linux, win32] + lightningcss-android-arm64@1.30.2: resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==} engines: {node: '>= 12.0.0'} @@ -5032,6 +5122,24 @@ packages: next-tick@1.1.0: resolution: {integrity: sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==} + node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead + + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + + node-fetch@3.3.2: + resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} @@ -5216,6 +5324,9 @@ packages: process-warning@5.0.0: resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + promise-limit@2.7.0: + resolution: {integrity: sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==} + prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} @@ -5631,6 +5742,9 @@ packages: resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} engines: {node: '>=16'} + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + tr46@6.0.0: resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} engines: {node: '>=20'} @@ -5909,6 +6023,13 @@ packages: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} + web-streams-polyfill@3.3.3: + resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} + engines: {node: '>= 8'} + + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + webidl-conversions@8.0.1: resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} engines: {node: '>=20'} @@ -5921,6 +6042,9 @@ packages: resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -7668,6 +7792,68 @@ snapshots: '@lezer/highlight': 1.2.3 '@lezer/lr': 1.4.8 + '@libsql/client@0.17.2': + dependencies: + '@libsql/core': 0.17.2 + '@libsql/hrana-client': 0.9.0 + js-base64: 3.7.8 + libsql: 0.5.29 + promise-limit: 2.7.0 + transitivePeerDependencies: + - bufferutil + - encoding + - utf-8-validate + + '@libsql/core@0.17.2': + dependencies: + js-base64: 3.7.8 + + '@libsql/darwin-arm64@0.5.29': + optional: true + + '@libsql/darwin-x64@0.5.29': + optional: true + + '@libsql/hrana-client@0.9.0': + dependencies: + '@libsql/isomorphic-ws': 0.1.5 + cross-fetch: 4.1.0 + js-base64: 3.7.8 + node-fetch: 3.3.2 + transitivePeerDependencies: + - bufferutil + - encoding + - utf-8-validate + + '@libsql/isomorphic-ws@0.1.5': + dependencies: + '@types/ws': 8.18.1 + ws: 8.19.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@libsql/linux-arm-gnueabihf@0.5.29': + optional: true + + '@libsql/linux-arm-musleabihf@0.5.29': + optional: true + + '@libsql/linux-arm64-gnu@0.5.29': + optional: true + + '@libsql/linux-arm64-musl@0.5.29': + optional: true + + '@libsql/linux-x64-gnu@0.5.29': + optional: true + + '@libsql/linux-x64-musl@0.5.29': + optional: true + + '@libsql/win32-x64-msvc@0.5.29': + optional: true + '@marijn/find-cluster-break@1.0.2': {} '@mdxeditor/editor@3.52.4(@codemirror/language@6.12.1)(@lezer/highlight@1.2.3)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(yjs@13.6.29)': @@ -7747,6 +7933,8 @@ snapshots: dependencies: langium: 4.2.1 + '@neon-rs/load@0.0.4': {} + '@noble/ciphers@2.1.1': {} '@noble/hashes@1.8.0': {} @@ -9449,7 +9637,7 @@ snapshots: baseline-browser-mapping@2.9.19: {} - better-auth@1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4))(pg@8.18.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.21.0)): + better-auth@1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.38.4(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.2)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4))(pg@8.18.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.21.0)): dependencies: '@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@3.25.76))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0) '@better-auth/telemetry': 1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@3.25.76))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)) @@ -9465,7 +9653,7 @@ snapshots: zod: 4.3.6 optionalDependencies: drizzle-kit: 0.31.9 - drizzle-orm: 0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4) + drizzle-orm: 0.38.4(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.2)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4) pg: 8.18.0 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) @@ -9672,6 +9860,12 @@ snapshots: '@epic-web/invariant': 1.0.0 cross-spawn: 7.0.6 + cross-fetch@4.1.0: + dependencies: + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -9883,6 +10077,8 @@ snapshots: d3: 7.9.0 lodash-es: 4.17.23 + data-uri-to-buffer@4.0.1: {} + data-urls@7.0.0(@noble/hashes@2.0.1): dependencies: whatwg-mimetype: 5.0.0 @@ -9929,6 +10125,8 @@ snapshots: dequal@2.0.3: {} + detect-libc@2.0.2: {} + detect-libc@2.1.2: {} detect-node-es@1.1.0: {} @@ -9974,9 +10172,10 @@ snapshots: transitivePeerDependencies: - supports-color - drizzle-orm@0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4): + drizzle-orm@0.38.4(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.2)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4): optionalDependencies: '@electric-sql/pglite': 0.3.15 + '@libsql/client': 0.17.2 '@types/react': 19.2.14 kysely: 0.28.11 pg: 8.18.0 @@ -10243,6 +10442,11 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + fetch-blob@3.2.0: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 + finalhandler@2.1.1: dependencies: debug: 4.4.3 @@ -10264,6 +10468,10 @@ snapshots: format@0.2.2: {} + formdata-polyfill@4.0.10: + dependencies: + fetch-blob: 3.2.0 + formidable@3.5.4: dependencies: '@paralleldrive/cuid2': 2.3.1 @@ -10454,6 +10662,8 @@ snapshots: joycon@3.1.1: {} + js-base64@3.7.8: {} + js-tokens@4.0.0: {} js-tokens@9.0.1: {} @@ -10523,6 +10733,21 @@ snapshots: dependencies: isomorphic.js: 0.2.5 + libsql@0.5.29: + dependencies: + '@neon-rs/load': 0.0.4 + detect-libc: 2.0.2 + optionalDependencies: + '@libsql/darwin-arm64': 0.5.29 + '@libsql/darwin-x64': 0.5.29 + '@libsql/linux-arm-gnueabihf': 0.5.29 + '@libsql/linux-arm-musleabihf': 0.5.29 + '@libsql/linux-arm64-gnu': 0.5.29 + '@libsql/linux-arm64-musl': 0.5.29 + '@libsql/linux-x64-gnu': 0.5.29 + '@libsql/linux-x64-musl': 0.5.29 + '@libsql/win32-x64-msvc': 0.5.29 + lightningcss-android-arm64@1.30.2: optional: true @@ -11168,6 +11393,18 @@ snapshots: next-tick@1.1.0: {} + node-domexception@1.0.0: {} + + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + + node-fetch@3.3.2: + dependencies: + data-uri-to-buffer: 4.0.1 + fetch-blob: 3.2.0 + formdata-polyfill: 4.0.10 + node-releases@2.0.27: {} object-assign@4.1.1: {} @@ -11365,6 +11602,8 @@ snapshots: process-warning@5.0.0: {} + promise-limit@2.7.0: {} + prop-types@15.8.1: dependencies: loose-envify: 1.4.0 @@ -11908,6 +12147,8 @@ snapshots: dependencies: tldts: 7.0.26 + tr46@0.0.3: {} + tr46@6.0.0: dependencies: punycode: 2.3.1 @@ -12256,6 +12497,10 @@ snapshots: dependencies: xml-name-validator: 5.0.0 + web-streams-polyfill@3.3.3: {} + + webidl-conversions@3.0.1: {} + webidl-conversions@8.0.1: {} whatwg-mimetype@5.0.0: {} @@ -12268,6 +12513,11 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + which@2.0.2: dependencies: isexe: 2.0.0 diff --git a/server/package.json b/server/package.json index b2d17ad3..d0c1adba 100644 --- a/server/package.json +++ b/server/package.json @@ -45,6 +45,7 @@ }, "dependencies": { "@aws-sdk/client-s3": "^3.888.0", + "@libsql/client": "^0.17.2", "@paperclipai/adapter-claude-local": "workspace:*", "@paperclipai/adapter-codex-local": "workspace:*", "@paperclipai/adapter-cursor-local": "workspace:*", diff --git a/server/src/__tests__/skill-registry-schema.test.ts b/server/src/__tests__/skill-registry-schema.test.ts new file mode 100644 index 00000000..e3b5d231 --- /dev/null +++ b/server/src/__tests__/skill-registry-schema.test.ts @@ -0,0 +1,136 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, it, expect, beforeEach, afterEach } from "vitest"; + +// We reset the singleton between tests by calling resetSkillRegistryDb +// and redirecting PAPERCLIP_HOME to an isolated temp dir + +describe("skill-registry-schema", () => { + let tmpDir: string; + let originalPaperclipHome: string | undefined; + + beforeEach(async () => { + tmpDir = await mkdtemp(path.join(os.tmpdir(), "skill-registry-test-")); + originalPaperclipHome = process.env.PAPERCLIP_HOME; + process.env.PAPERCLIP_HOME = tmpDir; + }); + + afterEach(async () => { + // Reset the DB singleton so next test gets a fresh instance + const { resetSkillRegistryDb } = await import("../services/skill-registry-db.js"); + resetSkillRegistryDb(); + + if (originalPaperclipHome === undefined) { + delete process.env.PAPERCLIP_HOME; + } else { + process.env.PAPERCLIP_HOME = originalPaperclipHome; + } + await rm(tmpDir, { recursive: true, force: true }); + }); + + it("Test 1: getSkillRegistryDb() creates registry.db at the resolved path and returns a drizzle instance", async () => { + const { getSkillRegistryDb } = await import("../services/skill-registry-db.js"); + const db = await getSkillRegistryDb(); + expect(db).toBeDefined(); + expect(typeof db.select).toBe("function"); + expect(typeof db.insert).toBe("function"); + }); + + it("Test 2: skills table has correct columns", async () => { + const { skills } = await import("../services/skill-registry-schema.js"); + const cols = Object.keys(skills); + // Check the table has the expected name + expect((skills as any)[Symbol.for("drizzle:Name") as any] ?? skills._.name).toBeDefined(); + const colNames = Object.keys(skills); + // Drizzle table object has column accessors + expect(skills.id).toBeDefined(); + expect(skills.sourceId).toBeDefined(); + expect(skills.name).toBeDefined(); + expect(skills.description).toBeDefined(); + expect(skills.sourceUrl).toBeDefined(); + expect(skills.activeVersionId).toBeDefined(); + expect(skills.removedAt).toBeDefined(); + expect(skills.createdAt).toBeDefined(); + expect(skills.updatedAt).toBeDefined(); + }); + + it("Test 3: skill_versions table has correct columns", async () => { + const { skillVersions } = await import("../services/skill-registry-schema.js"); + expect(skillVersions.id).toBeDefined(); + expect(skillVersions.skillId).toBeDefined(); + expect(skillVersions.version).toBeDefined(); + expect(skillVersions.fetchedAt).toBeDefined(); + expect(skillVersions.cacheDir).toBeDefined(); + }); + + it("Test 4: skill_files table has correct columns", async () => { + const { skillFiles } = await import("../services/skill-registry-schema.js"); + expect(skillFiles.id).toBeDefined(); + expect(skillFiles.versionId).toBeDefined(); + expect(skillFiles.path).toBeDefined(); + expect(skillFiles.kind).toBeDefined(); + expect(skillFiles.sizeBytes).toBeDefined(); + }); + + it("Test 5: community_ratings table has correct columns", async () => { + const { communityRatings } = await import("../services/skill-registry-schema.js"); + expect(communityRatings.id).toBeDefined(); + expect(communityRatings.skillId).toBeDefined(); + expect(communityRatings.fetchedAt).toBeDefined(); + expect(communityRatings.averageRating).toBeDefined(); + expect(communityRatings.ratingCount).toBeDefined(); + expect(communityRatings.source).toBeDefined(); + }); + + it("Test 6: soft-delete — inserting a skill and setting removed_at keeps the row queryable with a WHERE filter", async () => { + const { getSkillRegistryDb } = await import("../services/skill-registry-db.js"); + const { skills } = await import("../services/skill-registry-schema.js"); + const { eq, isNull, isNotNull } = await import("drizzle-orm"); + + const db = await getSkillRegistryDb(); + const now = Date.now(); + + await db.insert(skills).values({ + id: "test-skill-1", + sourceId: "src-1", + name: "Test Skill", + description: "A test skill", + sourceUrl: null, + activeVersionId: null, + removedAt: null, + createdAt: now, + updatedAt: now, + }); + + // Before soft-delete: row is visible + const before = await db.select().from(skills).where(isNull(skills.removedAt)); + expect(before.length).toBe(1); + + // Apply soft-delete + await db.update(skills).set({ removedAt: now + 1000 }).where(eq(skills.id, "test-skill-1")); + + // After soft-delete: not visible via active filter + const activeAfter = await db.select().from(skills).where(isNull(skills.removedAt)); + expect(activeAfter.length).toBe(0); + + // But still visible via removed filter + const removedAfter = await db.select().from(skills).where(isNotNull(skills.removedAt)); + expect(removedAfter.length).toBe(1); + expect(removedAfter[0]!.id).toBe("test-skill-1"); + }); + + it("Test 7: resolveSkillRegistryDbPath() returns path ending in skills/registry.db under instance root", async () => { + const { resolveSkillRegistryDbPath } = await import("../home-paths.js"); + const dbPath = resolveSkillRegistryDbPath(); + expect(dbPath).toMatch(/skills[/\\]registry\.db$/); + expect(dbPath.startsWith(tmpDir)).toBe(true); + }); + + it("Test 8: resolveSkillCacheDir returns path ending in skills/cache//", async () => { + const { resolveSkillCacheDir } = await import("../home-paths.js"); + const cacheDir = resolveSkillCacheDir("my-skill", "abc123"); + expect(cacheDir).toMatch(/skills[/\\]cache[/\\]my-skill[/\\]abc123$/); + expect(cacheDir.startsWith(tmpDir)).toBe(true); + }); +}); diff --git a/server/src/home-paths.ts b/server/src/home-paths.ts index fbba15bb..08fdc96f 100644 --- a/server/src/home-paths.ts +++ b/server/src/home-paths.ts @@ -113,3 +113,12 @@ export function resolveManagedProjectWorkspaceDir(input: { export function resolveHomeAwarePath(value: string): string { return path.resolve(expandHomePrefix(value)); } + +// [nexus] Skill registry paths +export function resolveSkillRegistryDbPath(): string { + return path.resolve(resolvePaperclipInstanceRoot(), "skills", "registry.db"); +} + +export function resolveSkillCacheDir(skillId: string, versionId: string): string { + return path.resolve(resolvePaperclipInstanceRoot(), "skills", "cache", skillId, versionId); +} diff --git a/server/src/services/skill-registry-db.ts b/server/src/services/skill-registry-db.ts new file mode 100644 index 00000000..9b26656a --- /dev/null +++ b/server/src/services/skill-registry-db.ts @@ -0,0 +1,73 @@ +import { mkdir } from "node:fs/promises"; +import { dirname } from "node:path"; +import { drizzle } from "drizzle-orm/libsql"; +import { createClient } from "@libsql/client"; +import * as schema from "./skill-registry-schema.js"; +import { resolveSkillRegistryDbPath } from "../home-paths.js"; + +export type SkillRegistryDb = ReturnType>; + +let _db: SkillRegistryDb | null = null; + +const CREATE_SKILLS_TABLE = ` +CREATE TABLE IF NOT EXISTS skills ( + id TEXT PRIMARY KEY, + source_id TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT, + source_url TEXT, + active_version_id TEXT, + removed_at INTEGER, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +)`; + +const CREATE_SKILL_VERSIONS_TABLE = ` +CREATE TABLE IF NOT EXISTS skill_versions ( + id TEXT PRIMARY KEY, + skill_id TEXT NOT NULL, + version TEXT NOT NULL, + fetched_at INTEGER NOT NULL, + cache_dir TEXT +)`; + +const CREATE_SKILL_FILES_TABLE = ` +CREATE TABLE IF NOT EXISTS skill_files ( + id TEXT PRIMARY KEY, + version_id TEXT NOT NULL, + path TEXT NOT NULL, + kind TEXT NOT NULL, + size_bytes INTEGER +)`; + +const CREATE_COMMUNITY_RATINGS_TABLE = ` +CREATE TABLE IF NOT EXISTS community_ratings ( + id TEXT PRIMARY KEY, + skill_id TEXT NOT NULL, + fetched_at INTEGER NOT NULL, + average_rating REAL, + rating_count INTEGER, + source TEXT +)`; + +export async function getSkillRegistryDb(): Promise { + if (_db !== null) return _db; + + const dbPath = resolveSkillRegistryDbPath(); + await mkdir(dirname(dbPath), { recursive: true }); + + const client = createClient({ url: `file:${dbPath}` }); + _db = drizzle({ client, schema }); + + await client.execute(CREATE_SKILLS_TABLE); + await client.execute(CREATE_SKILL_VERSIONS_TABLE); + await client.execute(CREATE_SKILL_FILES_TABLE); + await client.execute(CREATE_COMMUNITY_RATINGS_TABLE); + + return _db; +} + +/** Reset the singleton — used for test cleanup */ +export function resetSkillRegistryDb(): void { + _db = null; +} diff --git a/server/src/services/skill-registry-schema.ts b/server/src/services/skill-registry-schema.ts new file mode 100644 index 00000000..98e72e0b --- /dev/null +++ b/server/src/services/skill-registry-schema.ts @@ -0,0 +1,38 @@ +import { sqliteTable, text, integer, real } from "drizzle-orm/sqlite-core"; + +export const skills = sqliteTable("skills", { + id: text("id").primaryKey(), + sourceId: text("source_id").notNull(), + name: text("name").notNull(), + description: text("description"), + sourceUrl: text("source_url"), + activeVersionId: text("active_version_id"), + removedAt: integer("removed_at"), // unix ms, nullable — soft-delete + createdAt: integer("created_at").notNull(), + updatedAt: integer("updated_at").notNull(), +}); + +export const skillVersions = sqliteTable("skill_versions", { + id: text("id").primaryKey(), + skillId: text("skill_id").notNull(), + version: text("version").notNull(), + fetchedAt: integer("fetched_at").notNull(), + cacheDir: text("cache_dir"), +}); + +export const skillFiles = sqliteTable("skill_files", { + id: text("id").primaryKey(), + versionId: text("version_id").notNull(), + path: text("path").notNull(), + kind: text("kind").notNull(), // "skill" | "reference" | "script" | "asset" + sizeBytes: integer("size_bytes"), +}); + +export const communityRatings = sqliteTable("community_ratings", { + id: text("id").primaryKey(), + skillId: text("skill_id").notNull(), + fetchedAt: integer("fetched_at").notNull(), + averageRating: real("average_rating"), + ratingCount: integer("rating_count"), + source: text("source"), +});