feat: add Turso (libSQL) storage support for EdgeOne deployment

- Add TursoAdapter implementing DatabaseAdapter interface using @libsql/client
- Add 'turso' storage type to db.ts storage selector (reuses D1Storage)
- Add init-turso.js script for database migration and admin initialization
- Add @libsql/client dependency to package.json
- Update next.config.js to exclude @libsql/client from client-side bundle
- Update edgeone-deploy.yml workflow with TURSO_URL and TURSO_TOKEN secrets
- Update edgeone-build.mjs runtime env keys to include Turso variables
- Update .env.edgeone.example with Turso deployment instructions
- Update README.md with Turso as recommended storage for EdgeOne

Turso provides free SQLite-compatible cloud database (libSQL) with:
- 500 databases, 9GB storage, 1B row reads/month on free tier
- HTTP API compatible with edge/serverless environments
- Full SQLite syntax compatibility, reuses existing migrations
This commit is contained in:
mtvpls-turso
2026-07-11 17:04:27 +00:00
parent f55a1f50de
commit 8e464c463b
10 changed files with 625 additions and 27 deletions
+12 -1
View File
@@ -6,7 +6,18 @@ BUILD_TARGET=edgeone
EDGEONE_PAGES=1
# 默认不设置 NEXT_PUBLIC_STORAGE_TYPE,项目会使用默认 localstorage。
# 如需云端持久化,再自行改为 upstash / redis / postgres 等项目支持的存储。
# 如需云端持久化,可选用以下存储方案:
#
# 方案一:Turso(推荐,免费 SQLite 云数据库)
# 1. 注册 https://turso.tech 获取免费数据库
# 2. 设置 NEXT_PUBLIC_STORAGE_TYPE=turso
# 3. 运行 pnpm init:turso 初始化数据库表结构
# 4. 配置 TURSO_URL 和 TURSO_TOKEN
#
# 方案二:Upstash RedisKV 存储)
# 1. 注册 https://upstash.com 获取免费 Redis
# 2. 设置 NEXT_PUBLIC_STORAGE_TYPE=upstash
# 3. 配置 UPSTASH_URL 和 UPSTASH_TOKEN
# 管理员账号(请替换)
USERNAME=admin
+4 -2
View File
@@ -82,6 +82,8 @@ jobs:
PASSWORD: ${{ secrets.PASSWORD }}
UPSTASH_URL: ${{ secrets.UPSTASH_URL }}
UPSTASH_TOKEN: ${{ secrets.UPSTASH_TOKEN }}
TURSO_URL: ${{ secrets.TURSO_URL }}
TURSO_TOKEN: ${{ secrets.TURSO_TOKEN }}
TMDB_API_KEY: ${{ secrets.TMDB_API_KEY }}
TMDB_IMAGE_DOMAIN: ${{ secrets.TMDB_IMAGE_DOMAIN }}
TMDB_PROXY: ${{ secrets.TMDB_PROXY }}
@@ -135,7 +137,7 @@ jobs:
NEXT_PUBLIC_STORAGE_PREFIX NEXT_PUBLIC_ENABLE_STREAM_PROXY NEXT_PUBLIC_STREAM_PROXY_URL
NEXT_PUBLIC_ENABLE_TURNSTILE NEXT_PUBLIC_TURNSTILE_SITE_KEY
NEXT_PUBLIC_THEME NEXT_PUBLIC_THEME_COLOR NEXT_PUBLIC_APP_URL NEXT_PUBLIC_BASE_PATH
USERNAME PASSWORD UPSTASH_URL UPSTASH_TOKEN
USERNAME PASSWORD UPSTASH_URL UPSTASH_TOKEN TURSO_URL TURSO_TOKEN
TMDB_API_KEY TMDB_IMAGE_DOMAIN TMDB_PROXY TMDB_REVERSE_PROXY
DANMAKU_API_BASE DANMAKU_API_TOKEN ANNOUNCEMENT CRON_PASSWORD SITE_BASE
REDIS_URL KVROCKS_URL INIT_CONFIG CONFIG_SUBSCRIPTION_URL ENABLE_REGISTER
@@ -195,7 +197,7 @@ jobs:
NEXT_PUBLIC_STORAGE_PREFIX NEXT_PUBLIC_ENABLE_STREAM_PROXY NEXT_PUBLIC_STREAM_PROXY_URL
NEXT_PUBLIC_ENABLE_TURNSTILE NEXT_PUBLIC_TURNSTILE_SITE_KEY
NEXT_PUBLIC_THEME NEXT_PUBLIC_THEME_COLOR NEXT_PUBLIC_APP_URL NEXT_PUBLIC_BASE_PATH
USERNAME PASSWORD UPSTASH_URL UPSTASH_TOKEN
USERNAME PASSWORD UPSTASH_URL UPSTASH_TOKEN TURSO_URL TURSO_TOKEN
TMDB_API_KEY TMDB_IMAGE_DOMAIN TMDB_PROXY TMDB_REVERSE_PROXY
DANMAKU_API_BASE DANMAKU_API_TOKEN ANNOUNCEMENT CRON_PASSWORD SITE_BASE
REDIS_URL KVROCKS_URL INIT_CONFIG CONFIG_SUBSCRIPTION_URL ENABLE_REGISTER
+19 -3
View File
@@ -204,7 +204,7 @@ EdgeOne Pages/Makers 支持通过 API Token 在 GitHub Actions 中自动构建
1. 一个腾讯云 EdgeOne 账号
2. Fork 本项目到你的 GitHub 账号
3. 准备一个 Upstash Redis 实例(推荐)
3. 准备一个 Turso 数据库实例(推荐,免费 SQLite 云数据库)或 Upstash Redis 实例
4. 准备 EdgeOne API Token
#### 配置步骤
@@ -224,7 +224,21 @@ EdgeOne Pages/Makers 支持通过 API Token 在 GitHub Actions 中自动构建
| `EDGEONE_API_TOKEN` | EdgeOne API Token | `your_edgeone_token` |
| `USERNAME` | 站长账号 | `admin` |
| `PASSWORD` | 站长密码 | `your_secure_password` |
| `NEXT_PUBLIC_STORAGE_TYPE` | 存储类型 | `upstash` |
| `NEXT_PUBLIC_STORAGE_TYPE` | 存储类型 | `turso``upstash` |
**使用 Turso(推荐):**
在 [Turso](https://turso.tech/) 注册账号并创建数据库,然后运行 `pnpm init:turso` 初始化表结构(需设置 TURSO_URL 和 TURSO_TOKEN 环境变量)。
| Secret 名称 | 说明 | 示例值 |
| -------------------------- | ------------------- | --------------------------------- |
| `TURSO_URL` | Turso 数据库 URL | `libsql://your-db.turso.io` |
| `TURSO_TOKEN` | Turso 访问令牌 | `your_turso_token` |
**使用 Upstash Redis**
| Secret 名称 | 说明 | 示例值 |
| -------------------------- | ------------------- | ------------------------ |
| `UPSTASH_URL` | Upstash Redis URL | `https://xxx.upstash.io` |
| `UPSTASH_TOKEN` | Upstash Redis Token | `your_upstash_token` |
@@ -458,11 +472,13 @@ dockge/komodo 等 docker compose UI 也有自动更新功能
| NEXT_PUBLIC_SITE_NAME | 站点名称 | 任意字符串 | MoonTV |
| ANNOUNCEMENT | 站点公告 | 任意字符串 | 本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。 |
| ANNOUNCEMENT_DISPLAY_MODE | 公告显示模式 | once、every | once |
| NEXT_PUBLIC_STORAGE_TYPE | 播放记录/收藏的存储方式 | redis、kvrocks、upstash、d1 | 无默认,必填字段 |
| NEXT_PUBLIC_STORAGE_TYPE | 播放记录/收藏的存储方式 | redis、kvrocks、upstash、d1、turso、postgres | 无默认,必填字段 |
| KVROCKS_URL | kvrocks 连接 url | 连接 url | 空 |
| REDIS_URL | redis 连接 url | 连接 url | 空 |
| UPSTASH_URL | upstash redis 连接 url | 连接 url | 空 |
| UPSTASH_TOKEN | upstash redis 连接 token | 连接 token | 空 |
| TURSO_URL | Turso (libSQL) 数据库连接 url | libsql://xxx.turso.io | 空 |
| TURSO_TOKEN | Turso (libSQL) 数据库访问令牌 | 访问令牌 | 空 |
| NEXT_PUBLIC_SEARCH_MAX_PAGE | 搜索接口可拉取的最大页数 | 1-50 | 5 |
| NEXT_PUBLIC_DOUBAN_PROXY_TYPE | 豆瓣数据源请求方式 | 见下方 | direct |
| NEXT_PUBLIC_DOUBAN_PROXY | 自定义豆瓣数据代理 URL | url prefix | (空) |
+3 -1
View File
@@ -136,13 +136,14 @@ const createNextConfig = (phase) => {
});
}
// Exclude better-sqlite3, D1, and Postgres modules from client-side bundle
// Exclude better-sqlite3, D1, Postgres, and Turso modules from client-side bundle
if (!isServer) {
config.externals = config.externals || [];
config.externals.push({
'better-sqlite3': 'commonjs better-sqlite3',
'@vercel/postgres': 'commonjs @vercel/postgres',
'pg': 'commonjs pg',
'@libsql/client': 'commonjs @libsql/client',
});
config.resolve.alias = {
@@ -152,6 +153,7 @@ const createNextConfig = (phase) => {
'@/lib/d1-adapter': false,
'@/lib/postgres.db': false,
'@/lib/postgres-adapter': false,
'@/lib/turso-adapter': false,
};
}
+2
View File
@@ -22,6 +22,7 @@
"watch-room:server": "node server/watch-room-standalone-server.js --port 3001 --auth YOUR_SECRET_KEY",
"init:sqlite": "node scripts/init-sqlite.js",
"init:postgres": "node scripts/init-postgres.js",
"init:turso": "node scripts/init-turso.js",
"db:reset": "rm -f .data/moontv.db .data/moontv.db-shm .data/moontv.db-wal && node scripts/init-sqlite.js",
"build:edgeone": "pnpm edgeone:build",
"preview:edgeone": "BUILD_TARGET=edgeone EDGEONE_PAGES=1 node scripts/edgeone-local-preview.mjs",
@@ -61,6 +62,7 @@
"he": "^1.2.0",
"hls.js": "^1.6.10",
"https-proxy-agent": "^7.0.6",
"@libsql/client": "^0.15.0",
"lucide-react": "^0.438.0",
"media-icons": "^1.1.5",
"mux.js": "^6.3.0",
+185 -19
View File
@@ -26,6 +26,9 @@ importers:
'@heroicons/react':
specifier: ^2.2.0
version: 2.2.0([email protected])
'@libsql/client':
specifier: ^0.15.0
version: 0.15.15([email protected])
'@types/crypto-js':
specifier: ^4.2.2
version: 4.2.2
@@ -2566,6 +2569,67 @@ packages:
'@jspm/[email protected]':
resolution: {integrity: sha512-3sRl+pkyFY/kLmHl0cgHiFp2xEqErA8N3ECjMs7serSUBmoJ70lBa0PG5t0IM6WJgdZNyyI0R8YFfi5wM8+mzg==}
'@libsql/[email protected]':
resolution: {integrity: sha512-twC0hQxPNHPKfeOv3sNT6u2pturQjLcI+CnpTM0SjRpocEGgfiZ7DWKXLNnsothjyJmDqEsBQJ5ztq9Wlu470w==}
'@libsql/[email protected]':
resolution: {integrity: sha512-C88Z6UKl+OyuKKPwz224riz02ih/zHYI3Ho/LAcVOgjsunIRZoBw7fjRfaH9oPMmSNeQfhGklSG2il1URoOIsA==}
'@libsql/[email protected]':
resolution: {integrity: sha512-K+2RIB1OGFPYQbfay48GakLhqf3ArcbHqPFu7EZiaUcRgFcdw8RoltsMyvbj5ix2fY0HV3Q3Ioa/ByvQdaSM0A==}
cpu: [arm64]
os: [darwin]
'@libsql/[email protected]':
resolution: {integrity: sha512-OtT+KFHsKFy1R5FVadr8FJ2Bb1mghtXTyJkxv0trocq7NuHntSki1eUbxpO5ezJesDvBlqFjnWaYYY516QNLhQ==}
cpu: [x64]
os: [darwin]
'@libsql/[email protected]':
resolution: {integrity: sha512-OF8fFQSkbL7vJY9rfuegK1R7sPgQ6kFMkDamiEccNUvieQ+3urzfDFI616oPl8V7T9zRmnTkSjMOImYCAVRVuw==}
'@libsql/[email protected]':
resolution: {integrity: sha512-6kK3SUK5Uu56zPq/Las620n5aS9xJq+jMBcNSOmjhNf/MUvdyji4vrMTqD7ptY7/4/CAVEAYDeotUz60LNQHtw==}
engines: {node: '>=18.0.0'}
'@libsql/[email protected]':
resolution: {integrity: sha512-DtLWIH29onUYR00i0GlQ3UdcTRC6EP4u9w/h9LxpUZJWRMARk6dQwZ6Jkd+QdwVpuAOrdxt18v0K2uIYR3fwFg==}
'@libsql/[email protected]':
resolution: {integrity: sha512-CD4n4zj7SJTHso4nf5cuMoWoMSS7asn5hHygsDuhRl8jjjCTT3yE+xdUvI4J7zsyb53VO5ISh4cwwOtf6k2UhQ==}
cpu: [arm]
os: [linux]
'@libsql/[email protected]':
resolution: {integrity: sha512-2Z9qBVpEJV7OeflzIR3+l5yAd4uTOLxklScYTwpZnkm2vDSGlC1PRlueLaufc4EFITkLKXK2MWBpexuNJfMVcg==}
cpu: [arm]
os: [linux]
'@libsql/[email protected]':
resolution: {integrity: sha512-gURBqaiXIGGwFNEaUj8Ldk7Hps4STtG+31aEidCk5evMMdtsdfL3HPCpvys+ZF/tkOs2MWlRWoSq7SOuCE9k3w==}
cpu: [arm64]
os: [linux]
'@libsql/[email protected]':
resolution: {integrity: sha512-fwgYZ0H8mUkyVqXZHF3mT/92iIh1N94Owi/f66cPVNsk9BdGKq5gVpoKO+7UxaNzuEH1roJp2QEwsCZMvBLpqg==}
cpu: [arm64]
os: [linux]
'@libsql/[email protected]':
resolution: {integrity: sha512-y14V0vY0nmMC6G0pHeJcEarcnGU2H6cm21ZceRkacWHvQAEhAG0latQkCtoS2njFOXiYIg+JYPfAoWKbi82rkg==}
cpu: [x64]
os: [linux]
'@libsql/[email protected]':
resolution: {integrity: sha512-gquqwA/39tH4pFl+J9n3SOMSymjX+6kZ3kWgY3b94nXFTwac9bnFNMffIomgvlFaC4ArVqMnOZD3nuJ3H3VO1w==}
cpu: [x64]
os: [linux]
'@libsql/[email protected]':
resolution: {integrity: sha512-4/0CvEdhi6+KjMxMaVbFM2n2Z44escBRoEYpR+gZg64DdetzGnYm8mcNLcoySaDJZNaBd6wz5DNdgRmcI4hXcg==}
cpu: [x64]
os: [win32]
'@mapbox/[email protected]':
resolution: {integrity: sha512-uwPAhccfFJlsfCxMYTwOdVfOz3xqyj8xYL3zJj8f0pb30tLohnnFPhLuqp4/qoEz8sNxe4SESZedcBojRefIzg==}
engines: {node: '>=18'}
@@ -2580,6 +2644,9 @@ packages:
'@napi-rs/[email protected]':
resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==}
'@neon-rs/[email protected]':
resolution: {integrity: sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw==}
'@neondatabase/[email protected]':
resolution: {integrity: sha512-siFas6gItqv6wD/pZnvdu34wEqgG3nSE6zWZdq5j2DEsa+VvX8i/5HXJOo06qrw5axPXn+lGCxeR+NLaSPIXug==}
@@ -3817,6 +3884,9 @@ packages:
'@types/[email protected]':
resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==}
'@types/[email protected]':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
'@types/[email protected]':
resolution: {integrity: sha512-4YnrRemBShWRO2QjvUin8ESA41rH+9nQGLUGZV/1IDhi3SL9OhdpNC/MrulTWuptXKwhx/aDxE7toV0f/ypIXQ==}
@@ -5485,6 +5555,10 @@ packages:
[email protected]:
resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==}
[email protected]:
resolution: {integrity: sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==}
engines: {node: '>=8'}
[email protected]:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
@@ -7180,6 +7254,9 @@ packages:
[email protected]:
resolution: {integrity: sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ==}
[email protected]:
resolution: {integrity: sha512-5xVjhUZlHHeuO2W7w2rDFj/Kl1xLX+HjZxdOQwCsUOifl6UaoH1o1wsbsTMz+r0aeC7gCijvru02j6TfKZWzKg==}
[email protected]:
resolution: {integrity: sha512-Smw4xcfIQ5LVjAOuJCvN/zIodzA/BBSsluuoSykP+lUvScIi4U6RJLfwHet5cxFnCswUjISV8oAXaqaJDY3chg==}
engines: {node: '>= 0.8'}
@@ -7333,6 +7410,11 @@ packages:
[email protected]:
resolution: {integrity: sha512-r9kw4OA6oDO4dPXkOrXTkArQAafIKAU71hChInV4FxZ69dxCfbwQGDPzqR5/vea94wU705/3AZroEbSoeVWrQw==}
[email protected]:
resolution: {integrity: sha512-8lMP8iMgiBzzoNbAPQ59qdVcj6UaE/Vnm+fiwX4doX4Narook0a4GPKWBEv+CR8a1OwbfkgL18uBfBjWdF0Fzg==}
cpu: [x64, arm64, wasm32, arm]
os: [darwin, linux, win32]
[email protected]:
resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==}
engines: {node: '>=14'}
@@ -8606,6 +8688,9 @@ packages:
resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
engines: {node: '>= 0.6.0'}
[email protected]:
resolution: {integrity: sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==}
[email protected]:
resolution: {integrity: sha512-V6TbZDJ/ZswevgkDNpGt/YqNCiZP9ASfgU+p83uJE6NrGtvSGoOcHLiDCqkMs2+yg7F5qHdLV8d0aS8O26G/KA==}
@@ -9691,10 +9776,6 @@ packages:
resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==}
engines: {node: '>=16'}
[email protected]:
resolution: {integrity: sha512-VCn+LMHbd4t6sF3wfU/+HKT63C9OoyrSIf4b+vtWHpt2U7/4InZG467YDNMFMR70DdHjAdpPWmw2lzRdg0Xqqg==}
engines: {node: '>=20'}
[email protected]:
resolution: {integrity: sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==}
engines: {node: '>=20'}
@@ -13716,6 +13797,68 @@ snapshots:
'@jspm/[email protected]': {}
'@libsql/[email protected]([email protected])':
dependencies:
'@libsql/core': 0.15.15
'@libsql/hrana-client': 0.7.0([email protected])
js-base64: 3.8.1
libsql: 0.5.29
promise-limit: 2.7.0
transitivePeerDependencies:
- bufferutil
- utf-8-validate
'@libsql/[email protected]':
dependencies:
js-base64: 3.8.1
'@libsql/[email protected]':
optional: true
'@libsql/[email protected]':
optional: true
'@libsql/[email protected]([email protected])':
dependencies:
'@libsql/isomorphic-fetch': 0.3.1
'@libsql/isomorphic-ws': 0.1.5([email protected])
js-base64: 3.8.1
node-fetch: 3.3.2
transitivePeerDependencies:
- bufferutil
- utf-8-validate
'@libsql/[email protected]': {}
'@libsql/[email protected]([email protected])':
dependencies:
'@types/ws': 8.18.1
ws: 8.21.0([email protected])
transitivePeerDependencies:
- bufferutil
- utf-8-validate
'@libsql/[email protected]':
optional: true
'@libsql/[email protected]':
optional: true
'@libsql/[email protected]':
optional: true
'@libsql/[email protected]':
optional: true
'@libsql/[email protected]':
optional: true
'@libsql/[email protected]':
optional: true
'@libsql/[email protected]':
optional: true
'@mapbox/[email protected]':
dependencies:
consola: 3.4.2
@@ -13745,6 +13888,8 @@ snapshots:
'@tybys/wasm-util': 0.10.1
optional: true
'@neon-rs/[email protected]': {}
'@neondatabase/[email protected]':
dependencies:
'@types/pg': 8.11.6
@@ -15139,11 +15284,11 @@ snapshots:
'@types/[email protected]':
dependencies:
'@types/eslint': 9.6.1
'@types/estree': 1.0.8
'@types/estree': 1.0.9
'@types/[email protected]':
dependencies:
'@types/estree': 1.0.8
'@types/estree': 1.0.9
'@types/json-schema': 7.0.15
'@types/[email protected]':
@@ -15285,6 +15430,10 @@ snapshots:
'@types/[email protected]': {}
'@types/[email protected]':
dependencies:
'@types/node': 24.0.3
'@types/[email protected]':
dependencies:
'@types/node': 24.0.3
@@ -16053,9 +16202,9 @@ snapshots:
dependencies:
acorn: 8.15.0
[email protected]([email protected]5.0):
[email protected]([email protected]7.0):
dependencies:
acorn: 8.15.0
acorn: 8.17.0
[email protected]([email protected]):
dependencies:
@@ -16958,7 +17107,7 @@ snapshots:
dot-prop: 10.1.0
env-paths: 3.0.0
json-schema-typed: 8.0.2
semver: 7.7.3
semver: 7.8.4
uint8array-extras: 1.5.0
[email protected]:
@@ -17226,6 +17375,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
@@ -17354,7 +17505,7 @@ snapshots:
[email protected]:
dependencies:
type-fest: 5.3.1
type-fest: 5.7.0
[email protected]:
dependencies:
@@ -18979,7 +19130,7 @@ snapshots:
[email protected]:
dependencies:
get-east-asian-width: 1.4.0
get-east-asian-width: 1.6.0
[email protected]: {}
@@ -19620,6 +19771,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
@@ -19774,6 +19927,21 @@ snapshots:
[email protected]: {}
[email protected]:
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
[email protected]: {}
[email protected]: {}
@@ -20996,6 +21164,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
@@ -22258,7 +22428,7 @@ snapshots:
'@tsconfig/node14': 1.0.3
'@tsconfig/node16': 1.0.4
'@types/node': 24.0.3
acorn: 8.15.0
acorn: 8.17.0
acorn-walk: 8.3.4
arg: 4.1.3
create-require: 1.1.1
@@ -22322,10 +22492,6 @@ snapshots:
[email protected]: {}
[email protected]:
dependencies:
tagged-tag: 1.0.0
[email protected]:
dependencies:
tagged-tag: 1.0.0
@@ -22805,13 +22971,13 @@ snapshots:
[email protected]([email protected]):
dependencies:
'@types/eslint-scope': 3.7.7
'@types/estree': 1.0.8
'@types/estree': 1.0.9
'@types/json-schema': 7.0.15
'@webassemblyjs/ast': 1.14.1
'@webassemblyjs/wasm-edit': 1.14.1
'@webassemblyjs/wasm-parser': 1.14.1
acorn: 8.15.0
acorn-import-phases: 1.0.4([email protected]5.0)
acorn: 8.17.0
acorn-import-phases: 1.0.4([email protected]7.0)
browserslist: 4.28.1
chrome-trace-event: 1.0.4
enhanced-resolve: 5.18.4
+2
View File
@@ -42,6 +42,8 @@ const runtimeEnvKeys = [
'NEXT_PUBLIC_STORAGE_TYPE',
'UPSTASH_URL',
'UPSTASH_TOKEN',
'TURSO_URL',
'TURSO_TOKEN',
'TMDB_API_KEY',
'TMDB_IMAGE_DOMAIN',
'NEXT_PUBLIC_SITE_NAME',
+205
View File
@@ -0,0 +1,205 @@
/**
* Turso (libSQL) 数据库初始化脚本
*
* 创建数据库表结构并初始化默认管理员用户
* 复用 SQLite 迁移文件(Turso 基于 libSQL,完全兼容 SQLite 语法)
*
* 使用方式:
* 1. 安装依赖:pnpm install
* 2. 设置环境变量:
* export TURSO_URL=libsql://your-database.turso.io
* export TURSO_TOKEN=your-auth-token
* export USERNAME=admin
* export PASSWORD=your-password
* 3. 运行:node scripts/init-turso.js
*/
const { createClient } = require('@libsql/client');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const MIGRATIONS_DIR = path.join(__dirname, '../migrations');
const MIGRATION_BASELINE_CUTOFF = '008_web_push_notifications.sql';
function hashPassword(password) {
return crypto.createHash('sha256').update(password).digest('hex');
}
function splitSqlStatements(sql) {
const withoutLineComments = sql
.split('\n')
.filter((line) => !line.trim().startsWith('--'))
.join('\n');
return withoutLineComments
.split(';')
.map((statement) => statement.trim())
.filter((statement) => statement.length > 0);
}
function getMigrationFiles() {
if (!fs.existsSync(MIGRATIONS_DIR)) {
throw new Error(`Migrations directory not found: ${MIGRATIONS_DIR}`);
}
return fs
.readdirSync(MIGRATIONS_DIR)
.filter((file) => file.endsWith('.sql'))
.sort((a, b) => a.localeCompare(b));
}
function isIgnorableMigrationError(error) {
const message =
error instanceof Error ? error.message : String(error || '');
return (
(message.includes('table') && message.includes('already exists')) ||
(message.includes('index') && message.includes('already exists')) ||
message.includes('duplicate column name')
);
}
async function tableExists(client, tableName) {
const result = await client.execute({
sql: "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?",
args: [tableName],
});
return result.rows.length > 0;
}
async function ensureMigrationTable(client) {
await client.execute(`
CREATE TABLE IF NOT EXISTS schema_migrations (
filename TEXT PRIMARY KEY,
applied_at INTEGER NOT NULL
)
`);
}
async function getAppliedMigrations(client) {
const result = await client.execute('SELECT filename FROM schema_migrations');
return new Set(result.rows.map((row) => row.filename));
}
async function markMigrationApplied(client, filename) {
await client.execute({
sql: 'INSERT OR IGNORE INTO schema_migrations (filename, applied_at) VALUES (?, ?)',
args: [filename, Date.now()],
});
}
async function seedExistingMigrationBaseline(
client,
migrationFiles,
hadExistingSchema
) {
const applied = await getAppliedMigrations(client);
if (!hadExistingSchema || applied.size > 0) return;
for (const file of migrationFiles) {
if (file.localeCompare(MIGRATION_BASELINE_CUTOFF) < 0) {
await markMigrationApplied(client, file);
}
}
}
async function runMigrations(client) {
const migrationFiles = getMigrationFiles();
const hadExistingSchema = await tableExists(client, 'users');
await ensureMigrationTable(client);
await seedExistingMigrationBaseline(client, migrationFiles, hadExistingSchema);
for (const file of migrationFiles) {
const applied = await getAppliedMigrations(client);
if (applied.has(file)) {
console.log(`⏭️ Migration already applied: ${file}`);
continue;
}
const migrationPath = path.join(MIGRATIONS_DIR, file);
const sql = fs.readFileSync(migrationPath, 'utf8');
const statements = splitSqlStatements(sql);
console.log(`▶️ Applying migration: ${file}`);
for (const statement of statements) {
try {
await client.execute(statement);
} catch (error) {
if (isIgnorableMigrationError(error)) {
console.log(`⏭️ Statement skipped in ${file}: ${error.message}`);
continue;
}
throw error;
}
}
await markMigrationApplied(client, file);
console.log(`✅ Migration applied: ${file}`);
}
}
async function ensureDefaultAdmin(client) {
const username = process.env.USERNAME || 'admin';
const password = process.env.PASSWORD || '123456789';
const passwordHash = hashPassword(password);
const existingUser = await client.execute({
sql: 'SELECT username FROM users WHERE username = ? LIMIT 1',
args: [username],
});
if (existingUser.rows.length > 0) {
console.log(`️ Admin user already exists: ${username}`);
return;
}
await client.execute({
sql: `INSERT INTO users (
username, password_hash, role, created_at,
playrecord_migrated, favorite_migrated, skip_migrated
) VALUES (?, ?, 'owner', ?, 1, 1, 1)`,
args: [username, passwordHash, Date.now()],
});
console.log(`✅ Default admin user created: ${username}`);
}
async function initTursoDatabase() {
const tursoUrl = process.env.TURSO_URL;
const tursoToken = process.env.TURSO_TOKEN;
if (!tursoUrl || !tursoToken) {
console.error(
'❌ TURSO_URL and TURSO_TOKEN environment variables must be set'
);
console.error('');
console.error('Example:');
console.error(' export TURSO_URL=libsql://your-database.turso.io');
console.error(' export TURSO_TOKEN=your-auth-token');
process.exit(1);
}
const client = createClient({
url: tursoUrl,
authToken: tursoToken,
});
console.log('📦 Initializing Turso (libSQL) database...');
console.log('🔗 Database URL:', tursoUrl);
try {
await runMigrations(client);
await ensureDefaultAdmin(client);
console.log('');
console.log('🎉 Turso database is ready!');
console.log('');
console.log('Next steps:');
console.log('1. Set NEXT_PUBLIC_STORAGE_TYPE=turso in your environment');
console.log('2. Set TURSO_URL and TURSO_TOKEN environment variables');
console.log('3. Deploy your application');
} catch (err) {
console.error('❌ Turso initialization failed:', err);
process.exit(1);
}
}
initTursoDatabase();
+34 -1
View File
@@ -17,7 +17,7 @@ import {
SkipConfig,
} from './types';
// storage type 常量: 'localstorage' | 'redis' | 'upstash' | 'kvrocks' | 'd1' | 'postgres',默认 'localstorage'
// storage type 常量: 'localstorage' | 'redis' | 'upstash' | 'kvrocks' | 'd1' | 'postgres' | 'turso',默认 'localstorage'
const IS_CLOUDFLARE_BUILD =
process.env.CF_PAGES === '1' || process.env.BUILD_TARGET === 'cloudflare';
const STORAGE_TYPE =
@@ -28,6 +28,7 @@ const STORAGE_TYPE =
| 'kvrocks'
| 'd1'
| 'postgres'
| 'turso'
| undefined) || 'localstorage';
// 创建存储实例
@@ -70,6 +71,15 @@ function createStorage(): IStorage {
// 动态导入 PostgresStorage 以避免客户端打包
const { PostgresStorage } = require('./postgres.db');
return new PostgresStorage(postgresAdapter);
case 'turso':
// TursoStorage 只能在服务端使用,客户端会报错
if (typeof window !== 'undefined') {
throw new Error('TursoStorage can only be used on the server side');
}
const tursoAdapter = getTursoAdapter();
// 复用 D1StorageTurso 基于 libSQL/SQLiteSQL 语法完全兼容)
const { D1Storage: TursoD1Storage } = require('./d1.db');
return new TursoD1Storage(tursoAdapter);
case 'localstorage':
default:
return null as unknown as IStorage;
@@ -89,6 +99,29 @@ function getPostgresAdapter(): any {
return new PostgresAdapter();
}
/**
* 获取 Turso 适配器
* 使用 @libsql/client 连接 Turso (libSQL) 远程数据库
* 适用于 EdgeOne Pages 等无内置数据库的边缘平台
*/
function getTursoAdapter(): any {
// 动态导入适配器以避免客户端打包
const { TursoAdapter } = require('./turso-adapter');
const tursoUrl = process.env.TURSO_URL;
const tursoToken = process.env.TURSO_TOKEN;
if (!tursoUrl || !tursoToken) {
throw new Error(
'TURSO_URL and TURSO_TOKEN env variables must be set for Turso storage'
);
}
console.log('Using Turso (libSQL) database');
return new TursoAdapter(tursoUrl, tursoToken);
}
/**
* 获取 D1 适配器
* 开发环境:使用 better-sqlite3
+159
View File
@@ -0,0 +1,159 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/**
* Turso (libSQL) 适配器
*
* 将 @libsql/client API 转换为与 D1 兼容的接口
* Turso 基于 libSQLSQLite 开源分支),SQL 语法与 D1/SQLite 完全兼容
*
* 适用于 EdgeOne Pages 等无内置数据库的边缘平台
*
* 注意:此模块仅在服务端使用,通过 webpack 配置排除客户端打包
*/
import { createClient, type Client } from '@libsql/client';
import { DatabaseAdapter, D1PreparedStatement, D1Result } from './d1-adapter';
/**
* Turso 适配器
*
* 使用 @libsql/client 包装为 D1 兼容接口
*/
export class TursoAdapter implements DatabaseAdapter {
private client: Client;
constructor(url: string, authToken: string) {
this.client = createClient({
url,
authToken,
});
}
prepare(query: string): D1PreparedStatement {
return new TursoPreparedStatement(this.client, query);
}
async batch(statements: D1PreparedStatement[]): Promise<D1Result[]> {
// Turso/libSQL 原生支持 batch
const libsqlStatements = statements.map(
(stmt) => (stmt as TursoPreparedStatement).toLibSQLBatch()
);
const results = await this.client.batch(libsqlStatements, 'write');
return results.map((result) => ({
success: true,
results: result.rows || [],
meta: {
changes: result.rowsAffected,
last_row_id:
result.lastInsertRowid !== undefined
? Number(result.lastInsertRowid)
: null,
},
}));
}
async exec(query: string): Promise<void> {
await this.client.executeMultiple(query);
}
}
/**
* Turso PreparedStatement 包装器
* 将 @libsql/client API 转换为 D1 兼容 API
*/
class TursoPreparedStatement implements D1PreparedStatement {
private params: any[] = [];
constructor(
private client: Client,
private query: string
) {}
bind(...values: any[]): D1PreparedStatement {
this.params = values;
return this;
}
/**
* 执行查询并返回第一行
*/
async first<T = any>(colName?: string): Promise<T | null> {
try {
const result = await this.client.execute({
sql: this.query,
args: this.params,
});
if (!result.rows || result.rows.length === 0) return null;
const row = result.rows[0];
if (colName) return (row as any)[colName] ?? null;
return row as T;
} catch (err) {
console.error('Turso first() error:', err);
return null;
}
}
/**
* 执行查询并返回结果
*/
async run<T = any>(): Promise<D1Result<T>> {
try {
const result = await this.client.execute({
sql: this.query,
args: this.params,
});
return {
success: true,
meta: {
changes: result.rowsAffected,
last_row_id:
result.lastInsertRowid !== undefined
? Number(result.lastInsertRowid)
: null,
},
results: result.rows as T[],
};
} catch (err: any) {
console.error('Turso run() error:', err);
return {
success: false,
error: err.message,
};
}
}
/**
* 执行查询并返回所有行
*/
async all<T = any>(): Promise<D1Result<T>> {
try {
const result = await this.client.execute({
sql: this.query,
args: this.params,
});
return {
success: true,
results: (result.rows || []) as T[],
};
} catch (err: any) {
console.error('Turso all() error:', err);
return {
success: false,
error: err.message,
results: [],
};
}
}
/**
* 转换为 libSQL batch 格式
*/
toLibSQLBatch(): { sql: string; args: any[] } {
return { sql: this.query, args: this.params };
}
}