@supacloud/lite 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/README.md +70 -1
- package/THIRD_PARTY_NOTICES.md +8 -0
- package/dist/cli.js +480 -21
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +392 -4
- package/dist/snapshot.d.ts +41 -0
- package/dist/snapshot.d.ts.map +1 -0
- package/package.json +5 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.3.0](https://github.com/zuohuadong/supacloud/compare/supacloud-lite-v0.2.0...supacloud-lite-v0.3.0) (2026-07-28)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* extend Supabase-compatible platform capabilities ([adef019](https://github.com/zuohuadong/supacloud/commit/adef019261f82f123043ab4c7a047e6ad6956e56))
|
|
9
|
+
* harden Supabase Cloud compatibility ([ab64374](https://github.com/zuohuadong/supacloud/commit/ab643743b058ad08a0d32c124d26bed0863db397))
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
### Bug Fixes
|
|
13
|
+
|
|
14
|
+
* use installed TypeScript in Lite smoke ([8a64050](https://github.com/zuohuadong/supacloud/commit/8a64050833c47778e931067f8f30e541141256bf))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
### Documentation
|
|
18
|
+
|
|
19
|
+
* **lite:** document snapshot archive dependency ([1c0a8cc](https://github.com/zuohuadong/supacloud/commit/1c0a8cccc4e6cbb0804c7badf6573d6d986c2528))
|
|
20
|
+
|
|
3
21
|
## [0.2.0](https://github.com/zuohuadong/supacloud/compare/supacloud-lite-v0.1.0...supacloud-lite-v0.2.0) (2026-07-28)
|
|
4
22
|
|
|
5
23
|
|
package/README.md
CHANGED
|
@@ -59,6 +59,19 @@ supabase/
|
|
|
59
59
|
|
|
60
60
|
`config.toml` 当前支持 Auth、API schema/max rows、Storage bucket/size limit、seed 和 function entrypoint 等常用配置。
|
|
61
61
|
|
|
62
|
+
### Auth 运行方式
|
|
63
|
+
|
|
64
|
+
Lite 不下载、安装或启动独立的 GoTrue 进程。`/auth/v1/*` 由同一个 Bun 进程中的内置 Auth 实现处理,并与该 Lite 项目的 PGlite `auth` schema 共享生命周期;这避免了 sidecar 的配置、端口和会话一致性负担。
|
|
65
|
+
|
|
66
|
+
Auth 默认启用。如项目不需要客户端登录接口,可在 `supabase/config.toml` 中关闭该路由:
|
|
67
|
+
|
|
68
|
+
```toml
|
|
69
|
+
[auth]
|
|
70
|
+
enabled = false
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
关闭后 `/auth/v1/*` 返回 `404`,但不会把 Lite 变成完整 GoTrue 运行时,也不会自动移除已有的 `auth` schema 或 API key。需要完整 GoTrue 行为、多项目鉴权或独立鉴权进程时,应使用完整 SupaCloud 平台。
|
|
74
|
+
|
|
62
75
|
## CLI
|
|
63
76
|
|
|
64
77
|
```text
|
|
@@ -70,6 +83,9 @@ supacloud-lite gen types [-o database.types.ts]
|
|
|
70
83
|
supacloud-lite db reset
|
|
71
84
|
supacloud-lite db diff [-f migration_name]
|
|
72
85
|
supacloud-lite db pull [migration_name]
|
|
86
|
+
supacloud-lite snapshot create [-o backup.tar.gz]
|
|
87
|
+
supacloud-lite snapshot restore <backup.tar.gz> [--force]
|
|
88
|
+
supacloud-lite upgrade [-o pre-upgrade.tar.gz]
|
|
73
89
|
supacloud-lite inspect
|
|
74
90
|
supacloud-lite version
|
|
75
91
|
```
|
|
@@ -107,12 +123,65 @@ S3 模式下 `db reset` 会被拒绝,因为 Lite 不能把数据库元数据
|
|
|
107
123
|
|
|
108
124
|
网络暴露时必须提供足够强的 JWT secret 和独立 vault key。默认生成的密钥适合单机项目;不要把 `.supacloud-lite/secrets.json` 提交到版本库。
|
|
109
125
|
|
|
126
|
+
## 升级、快照与恢复
|
|
127
|
+
|
|
128
|
+
生产或持久化环境升级时,先更新项目锁定的 npm 依赖,再运行 Lite 的受控升级命令:
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
# 明确指定目标版本;不要在生产启动命令中隐式使用 @latest
|
|
132
|
+
bun add @supacloud/lite@0.2.0
|
|
133
|
+
|
|
134
|
+
# 自动创建升级前快照,然后执行尚未应用的 supabase/migrations
|
|
135
|
+
bunx supacloud-lite upgrade
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
`upgrade` 不会自行修改 `package.json` 或联网更新 npm 包。它使用当前项目已经安装并锁定的 Lite 版本,默认把升级前快照写入 `.supacloud-lite/backups/pre-upgrade-<timestamp>.tar.gz`,快照成功后才执行 migrations。升级失败时,快照会保留,并打印恢复命令。
|
|
139
|
+
|
|
140
|
+
也可以单独创建可移植快照:
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
# 使用默认文件名和目录
|
|
144
|
+
bunx supacloud-lite snapshot create
|
|
145
|
+
|
|
146
|
+
# 指定输出位置
|
|
147
|
+
bunx supacloud-lite snapshot create -o ./backups/project-a.tar.gz
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
快照是一个 gzip 压缩的 tar 文件,包含:
|
|
151
|
+
|
|
152
|
+
- PGlite 数据目录;
|
|
153
|
+
- `fs` 模式的对象文件;
|
|
154
|
+
- `secrets.json`,用于保持 JWT、会话和 Vault 解密兼容;
|
|
155
|
+
- 快照格式、Lite 版本和 Storage backend 清单。
|
|
156
|
+
|
|
157
|
+
快照包含敏感密钥,输出文件在 Unix 系统上会设置为 `0600`;仍应按数据库备份级别加密、限制访问并设置保留周期。创建快照前必须停止 Lite。若检测到数据目录锁,命令会拒绝继续;只有确认进程已退出后才能人工删除陈旧锁。
|
|
158
|
+
|
|
159
|
+
恢复到新项目或空状态目录:
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
bunx supacloud-lite snapshot restore ./backups/project-a.tar.gz
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
目标状态目录非空时默认拒绝覆盖。确认要替换现有状态时显式使用:
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
bunx supacloud-lite snapshot restore ./backups/project-a.tar.gz --force
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
`--force` 不会直接删除旧数据,而是把旧状态目录重命名为 `.supacloud-lite.restore-<id>` 形式的回滚副本,并在输出中打印完整路径。验证新状态后再由运维人员清理该目录。
|
|
172
|
+
|
|
173
|
+
使用自定义 `--state-dir`、`--data-dir` 或 `--storage-dir` 时,创建和恢复必须传入相同参数。数据库与 Storage 目录不得重叠,也不能指向文件系统根目录或符号链接。
|
|
174
|
+
|
|
175
|
+
S3 模式的快照只包含数据库中的 Storage 元数据和密钥,不复制远端对象,也不会读取或保存 S3 凭据。恢复时必须传入 `--storage-backend s3`,并重新提供原 bucket/prefix 的环境变量;跨 bucket 迁移仍需使用对象存储自身的复制工具。
|
|
176
|
+
|
|
177
|
+
内存数据库没有可持久化的数据,因此 `snapshot` 和 `upgrade` 会拒绝 `--memory`。
|
|
178
|
+
|
|
110
179
|
## 兼容范围
|
|
111
180
|
|
|
112
181
|
| 能力 | V1 状态 | 说明 |
|
|
113
182
|
| --- | --- | --- |
|
|
114
183
|
| `supabase.from()` | 已验证核心 | 自动测试覆盖 CRUD、过滤、RLS;嵌套关系、RPC 和高级 PostgREST 语法属于实验性兼容 |
|
|
115
|
-
| `supabase.auth` | 已验证核心 |
|
|
184
|
+
| `supabase.auth` | 已验证核心 | 由内置 Auth 实现而非独立 GoTrue 进程提供;自动测试覆盖邮箱密码、会话和 bcrypt;OTP/Magic Link、匿名用户、OAuth、MFA 属于实验性兼容 |
|
|
116
185
|
| `supabase.storage` | 已验证核心 | 覆盖上传下载、列表、删除、TUS/RLS、远端 S3 驱动,以及 Bun.Image 的 `contain`/`fill`、格式和质量变换子集;`cover` 明确不支持 |
|
|
117
186
|
| `supabase.channel()` | 已验证核心 | 自动测试覆盖 `postgres_changes`、DELETE RLS 隔离和事件快照校验;Broadcast、Presence 属于实验性兼容 |
|
|
118
187
|
| `supabase.functions.invoke()` | 已验证核心 | 自动测试覆盖 Bun.build、`Deno.serve()`、公开函数和同进程重启 |
|
package/THIRD_PARTY_NOTICES.md
CHANGED
|
@@ -22,3 +22,11 @@ SupaCloud Lite 使用 ElectricSQL PGlite 作为嵌入式 PostgreSQL 引擎。
|
|
|
22
22
|
## Supabase JavaScript SDK
|
|
23
23
|
|
|
24
24
|
`@supabase/supabase-js` 仅作为开发和兼容性测试依赖使用,没有 vendored 到发布产物中。
|
|
25
|
+
|
|
26
|
+
## node-tar
|
|
27
|
+
|
|
28
|
+
SupaCloud Lite 使用 `tar` 作为跨平台流式快照归档依赖,避免把完整 PGlite 数据目录加载到内存。
|
|
29
|
+
|
|
30
|
+
- License: ISC
|
|
31
|
+
- Upstream: `https://github.com/isaacs/node-tar`
|
|
32
|
+
- Distribution: 作为独立 npm 依赖安装,许可证随依赖包分发
|
package/dist/cli.js
CHANGED
|
@@ -3,12 +3,12 @@
|
|
|
3
3
|
var __require = import.meta.require;
|
|
4
4
|
|
|
5
5
|
// src/cli.ts
|
|
6
|
-
import { mkdir as
|
|
7
|
-
import { dirname as
|
|
6
|
+
import { mkdir as mkdir7, rm as rm4, writeFile as writeFile6 } from "fs/promises";
|
|
7
|
+
import { dirname as dirname5, join as join9, resolve as resolve4 } from "path";
|
|
8
8
|
// package.json
|
|
9
9
|
var package_default = {
|
|
10
10
|
name: "@supacloud/lite",
|
|
11
|
-
version: "0.
|
|
11
|
+
version: "0.3.0",
|
|
12
12
|
description: "Bun-native, single-project Supabase-compatible backend powered by PGlite",
|
|
13
13
|
type: "module",
|
|
14
14
|
license: "Apache-2.0",
|
|
@@ -32,7 +32,7 @@ var package_default = {
|
|
|
32
32
|
],
|
|
33
33
|
scripts: {
|
|
34
34
|
build: "bun run build:js && bun run build:types",
|
|
35
|
-
"build:js": "bun build src/index.ts --target=bun --format=esm --outdir=dist --external @electric-sql/pglite && bun build src/cli.ts --target=bun --format=esm --outdir=dist --external @electric-sql/pglite",
|
|
35
|
+
"build:js": "bun build src/index.ts --target=bun --format=esm --outdir=dist --external @electric-sql/pglite --external tar && bun build src/cli.ts --target=bun --format=esm --outdir=dist --external @electric-sql/pglite --external tar",
|
|
36
36
|
"build:types": "bun x tsc --emitDeclarationOnly -p tsconfig.build.json",
|
|
37
37
|
check: "bun run typecheck && bun run test && bun run build && bun run test:package",
|
|
38
38
|
dev: "bun run src/cli.ts start",
|
|
@@ -43,10 +43,11 @@ var package_default = {
|
|
|
43
43
|
typecheck: "bun x tsc --noEmit -p tsconfig.json"
|
|
44
44
|
},
|
|
45
45
|
dependencies: {
|
|
46
|
-
"@electric-sql/pglite": "0.5.4"
|
|
46
|
+
"@electric-sql/pglite": "0.5.4",
|
|
47
|
+
tar: "^7.5.22"
|
|
47
48
|
},
|
|
48
49
|
devDependencies: {
|
|
49
|
-
"@supabase/supabase-js": "^2.
|
|
50
|
+
"@supabase/supabase-js": "^2.110.9",
|
|
50
51
|
"@types/bun": "^1.3.14",
|
|
51
52
|
typescript: "^5.9.3"
|
|
52
53
|
},
|
|
@@ -8736,6 +8737,392 @@ async function findEphemeralPort(host = "127.0.0.1") {
|
|
|
8736
8737
|
return port;
|
|
8737
8738
|
}
|
|
8738
8739
|
|
|
8740
|
+
// src/snapshot.ts
|
|
8741
|
+
import { chmod as chmod2, copyFile, link as link2, lstat as lstat2, mkdir as mkdir6, mkdtemp, readdir as readdir3, readFile as readFile7, rename as rename2, rm as rm3, writeFile as writeFile5 } from "fs/promises";
|
|
8742
|
+
import { dirname as dirname4, join as join8, parse as parse2, relative as relative2, resolve as resolve3, sep as sep2 } from "path";
|
|
8743
|
+
import { create as createTar, extract as extractTar } from "tar";
|
|
8744
|
+
var SNAPSHOT_FORMAT = "supacloud-lite-snapshot";
|
|
8745
|
+
var SNAPSHOT_VERSION = 1;
|
|
8746
|
+
async function createSnapshot(options) {
|
|
8747
|
+
const paths = normalizePaths(options.paths);
|
|
8748
|
+
await assertSnapshotPaths(paths);
|
|
8749
|
+
await assertNoDataDirectoryLock(paths);
|
|
8750
|
+
const manifest = {
|
|
8751
|
+
format: SNAPSHOT_FORMAT,
|
|
8752
|
+
version: SNAPSHOT_VERSION,
|
|
8753
|
+
createdAt: new Date().toISOString(),
|
|
8754
|
+
packageVersion: options.packageVersion,
|
|
8755
|
+
storageBackend: options.storageBackend,
|
|
8756
|
+
includesDatabase: Boolean(paths.dataDir),
|
|
8757
|
+
includesLocalStorage: options.storageBackend === "fs",
|
|
8758
|
+
includesSecrets: true
|
|
8759
|
+
};
|
|
8760
|
+
const output = resolve3(options.output);
|
|
8761
|
+
if (await existingInfo(output))
|
|
8762
|
+
throw new Error(`snapshot output already exists: ${output}`);
|
|
8763
|
+
await mkdir6(dirname4(output), { recursive: true });
|
|
8764
|
+
const stagingRoot = await mkdtemp(join8(dirname4(output), ".supacloud-lite-snapshot-"));
|
|
8765
|
+
try {
|
|
8766
|
+
await writeFile5(join8(stagingRoot, "manifest.json"), `${JSON.stringify(manifest, null, 2)}
|
|
8767
|
+
`);
|
|
8768
|
+
await stageFile(paths.secretsFile, join8(stagingRoot, "secrets.json"));
|
|
8769
|
+
if (paths.dataDir)
|
|
8770
|
+
await stageDirectory(paths.dataDir, join8(stagingRoot, "database"));
|
|
8771
|
+
if (options.storageBackend === "fs")
|
|
8772
|
+
await stageDirectory(paths.storageDir, join8(stagingRoot, "storage"));
|
|
8773
|
+
const entries = ["manifest.json", "secrets.json"];
|
|
8774
|
+
if (paths.dataDir)
|
|
8775
|
+
entries.push("database");
|
|
8776
|
+
if (options.storageBackend === "fs")
|
|
8777
|
+
entries.push("storage");
|
|
8778
|
+
await createTar({ cwd: stagingRoot, file: output, gzip: true, portable: true }, entries);
|
|
8779
|
+
if (process.platform !== "win32")
|
|
8780
|
+
await chmod2(output, 384);
|
|
8781
|
+
return manifest;
|
|
8782
|
+
} catch (error) {
|
|
8783
|
+
await rm3(output, { force: true });
|
|
8784
|
+
throw error;
|
|
8785
|
+
} finally {
|
|
8786
|
+
await rm3(stagingRoot, { recursive: true, force: true });
|
|
8787
|
+
}
|
|
8788
|
+
}
|
|
8789
|
+
async function restoreSnapshot(options) {
|
|
8790
|
+
const paths = normalizePaths(options.paths);
|
|
8791
|
+
await assertSnapshotPaths(paths, { requireSecrets: false, allowMissingState: true });
|
|
8792
|
+
await assertNoDataDirectoryLock(paths);
|
|
8793
|
+
const stagingRoot = await mkdtemp(join8(dirname4(paths.stateDir), ".supacloud-lite-restore-"));
|
|
8794
|
+
const payloadRoot = join8(stagingRoot, "payload");
|
|
8795
|
+
const rollbackId = crypto.randomUUID();
|
|
8796
|
+
const rollbackPaths = [];
|
|
8797
|
+
try {
|
|
8798
|
+
await mkdir6(payloadRoot, { recursive: true });
|
|
8799
|
+
await extractTar({
|
|
8800
|
+
cwd: payloadRoot,
|
|
8801
|
+
file: resolve3(options.input),
|
|
8802
|
+
preserveOwner: false,
|
|
8803
|
+
preservePaths: false,
|
|
8804
|
+
strict: true,
|
|
8805
|
+
unlink: true,
|
|
8806
|
+
filter: (path, entry) => {
|
|
8807
|
+
const normalized = path.replaceAll("\\", "/");
|
|
8808
|
+
const allowedPath = normalized === "manifest.json" || normalized === "secrets.json" || normalized === "database" || normalized.startsWith("database/") || normalized === "storage" || normalized.startsWith("storage/");
|
|
8809
|
+
if (!allowedPath || normalized.startsWith("/") || normalized.split("/").includes("..")) {
|
|
8810
|
+
throw new Error(`snapshot contains an unsafe path: ${path}`);
|
|
8811
|
+
}
|
|
8812
|
+
const entryType = "type" in entry ? entry.type : undefined;
|
|
8813
|
+
if (entryType !== "File" && entryType !== "Directory") {
|
|
8814
|
+
throw new Error(`snapshot contains an unsupported entry type: ${entryType ?? "unknown"}`);
|
|
8815
|
+
}
|
|
8816
|
+
return true;
|
|
8817
|
+
}
|
|
8818
|
+
});
|
|
8819
|
+
await assertNoSymlinks(payloadRoot);
|
|
8820
|
+
const manifest = await readManifest(payloadRoot);
|
|
8821
|
+
if (manifest.storageBackend !== options.storageBackend) {
|
|
8822
|
+
throw new Error(`snapshot storage backend is ${manifest.storageBackend}, but the target uses ${options.storageBackend}; ` + "restore with the matching --storage-backend value");
|
|
8823
|
+
}
|
|
8824
|
+
if (manifest.includesDatabase !== Boolean(paths.dataDir)) {
|
|
8825
|
+
throw new Error("snapshot database mode does not match the target; do not restore a persistent snapshot into --memory");
|
|
8826
|
+
}
|
|
8827
|
+
if (manifest.includesLocalStorage !== (options.storageBackend === "fs")) {
|
|
8828
|
+
throw new Error("snapshot storage payload does not match the target storage backend");
|
|
8829
|
+
}
|
|
8830
|
+
await assertSnapshotPayload(payloadRoot, manifest);
|
|
8831
|
+
if (manifest.includesDatabase)
|
|
8832
|
+
await mkdir6(join8(payloadRoot, "database"), { recursive: true });
|
|
8833
|
+
if (manifest.includesLocalStorage)
|
|
8834
|
+
await mkdir6(join8(payloadRoot, "storage"), { recursive: true });
|
|
8835
|
+
await assertRestoreTargets(paths, manifest, options.force === true);
|
|
8836
|
+
const stateStage = join8(stagingRoot, "state");
|
|
8837
|
+
await mkdir6(stateStage, { recursive: true });
|
|
8838
|
+
await copyEntry(join8(payloadRoot, "secrets.json"), join8(stateStage, "secrets.json"));
|
|
8839
|
+
if (paths.dataDir && isWithin(paths.stateDir, paths.dataDir)) {
|
|
8840
|
+
await copyEntry(join8(payloadRoot, "database"), join8(stateStage, relative2(paths.stateDir, paths.dataDir)));
|
|
8841
|
+
}
|
|
8842
|
+
if (options.storageBackend === "fs" && isWithin(paths.stateDir, paths.storageDir)) {
|
|
8843
|
+
await copyEntry(join8(payloadRoot, "storage"), join8(stateStage, relative2(paths.stateDir, paths.storageDir)));
|
|
8844
|
+
}
|
|
8845
|
+
const swaps = [];
|
|
8846
|
+
try {
|
|
8847
|
+
await applyDirectorySwap(stateStage, paths.stateDir, options.force === true, rollbackId, swaps);
|
|
8848
|
+
if (paths.dataDir && !isWithin(paths.stateDir, paths.dataDir)) {
|
|
8849
|
+
await applyDirectorySwap(join8(payloadRoot, "database"), paths.dataDir, options.force === true, rollbackId, swaps);
|
|
8850
|
+
}
|
|
8851
|
+
if (options.storageBackend === "fs" && !isWithin(paths.stateDir, paths.storageDir)) {
|
|
8852
|
+
await applyDirectorySwap(join8(payloadRoot, "storage"), paths.storageDir, options.force === true, rollbackId, swaps);
|
|
8853
|
+
}
|
|
8854
|
+
} catch (error) {
|
|
8855
|
+
await rollbackDirectorySwaps(swaps);
|
|
8856
|
+
throw error;
|
|
8857
|
+
}
|
|
8858
|
+
rollbackPaths.push(...swaps.flatMap((swap) => swap.rollbackPath ? [swap.rollbackPath] : []));
|
|
8859
|
+
if (process.platform !== "win32") {
|
|
8860
|
+
await hardenRestoredTree(paths.stateDir);
|
|
8861
|
+
if (paths.dataDir && !isWithin(paths.stateDir, paths.dataDir))
|
|
8862
|
+
await hardenRestoredTree(paths.dataDir);
|
|
8863
|
+
if (options.storageBackend === "fs" && !isWithin(paths.stateDir, paths.storageDir)) {
|
|
8864
|
+
await hardenRestoredTree(paths.storageDir);
|
|
8865
|
+
}
|
|
8866
|
+
}
|
|
8867
|
+
return { manifest, rollbackPaths };
|
|
8868
|
+
} catch (error) {
|
|
8869
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
8870
|
+
} finally {
|
|
8871
|
+
await rm3(stagingRoot, { recursive: true, force: true });
|
|
8872
|
+
}
|
|
8873
|
+
}
|
|
8874
|
+
function normalizePaths(paths) {
|
|
8875
|
+
return {
|
|
8876
|
+
...paths,
|
|
8877
|
+
projectDir: resolve3(paths.projectDir),
|
|
8878
|
+
stateDir: resolve3(paths.stateDir),
|
|
8879
|
+
dataDir: paths.dataDir ? resolve3(paths.dataDir) : undefined,
|
|
8880
|
+
storageDir: resolve3(paths.storageDir),
|
|
8881
|
+
secretsFile: resolve3(paths.secretsFile)
|
|
8882
|
+
};
|
|
8883
|
+
}
|
|
8884
|
+
async function assertSnapshotPaths(paths, options = {}) {
|
|
8885
|
+
try {
|
|
8886
|
+
if (paths.stateDir === parse2(paths.stateDir).root)
|
|
8887
|
+
throw new Error("snapshot state directory must not be the filesystem root");
|
|
8888
|
+
if (paths.secretsFile !== join8(paths.stateDir, "secrets.json"))
|
|
8889
|
+
throw new Error("snapshot secrets path must be inside the state directory");
|
|
8890
|
+
const stateInfo = await lstat2(paths.stateDir);
|
|
8891
|
+
if (!stateInfo.isDirectory() || stateInfo.isSymbolicLink())
|
|
8892
|
+
throw new Error(`state directory must be a real directory: ${paths.stateDir}`);
|
|
8893
|
+
} catch (error) {
|
|
8894
|
+
if (error.code !== "ENOENT" || options.allowMissingState !== true)
|
|
8895
|
+
throw error;
|
|
8896
|
+
}
|
|
8897
|
+
if (paths.dataDir && paths.storageDir && pathsOverlap(paths.dataDir, paths.storageDir)) {
|
|
8898
|
+
throw new Error("database and storage directories must not overlap");
|
|
8899
|
+
}
|
|
8900
|
+
await assertDirectoryOrMissing(paths.dataDir);
|
|
8901
|
+
await assertDirectoryOrMissing(paths.storageDir);
|
|
8902
|
+
if (options.requireSecrets !== false) {
|
|
8903
|
+
const secretInfo = await lstat2(paths.secretsFile);
|
|
8904
|
+
if (!secretInfo.isFile() || secretInfo.isSymbolicLink())
|
|
8905
|
+
throw new Error(`secrets file must be a regular file: ${paths.secretsFile}`);
|
|
8906
|
+
}
|
|
8907
|
+
}
|
|
8908
|
+
async function assertDirectoryOrMissing(path) {
|
|
8909
|
+
if (!path)
|
|
8910
|
+
return;
|
|
8911
|
+
if (resolve3(path) === parse2(resolve3(path)).root)
|
|
8912
|
+
throw new Error(`snapshot path must not be the filesystem root: ${path}`);
|
|
8913
|
+
try {
|
|
8914
|
+
const info = await lstat2(path);
|
|
8915
|
+
if (!info.isDirectory() || info.isSymbolicLink())
|
|
8916
|
+
throw new Error(`snapshot path must be a real directory: ${path}`);
|
|
8917
|
+
} catch (error) {
|
|
8918
|
+
if (error.code !== "ENOENT")
|
|
8919
|
+
throw error;
|
|
8920
|
+
}
|
|
8921
|
+
}
|
|
8922
|
+
async function assertNoDataDirectoryLock(paths) {
|
|
8923
|
+
if (!paths.dataDir)
|
|
8924
|
+
return;
|
|
8925
|
+
const lockPath = `${paths.dataDir}.supacloud-lite.lock`;
|
|
8926
|
+
try {
|
|
8927
|
+
await lstat2(lockPath);
|
|
8928
|
+
throw new Error(`data directory is in use or has a stale lock: ${lockPath}; stop Lite and remove the lock manually if it is stale`);
|
|
8929
|
+
} catch (error) {
|
|
8930
|
+
if (error.code !== "ENOENT")
|
|
8931
|
+
throw error;
|
|
8932
|
+
}
|
|
8933
|
+
}
|
|
8934
|
+
async function stageDirectory(root, destination) {
|
|
8935
|
+
try {
|
|
8936
|
+
const info = await lstat2(root);
|
|
8937
|
+
if (!info.isDirectory() || info.isSymbolicLink())
|
|
8938
|
+
throw new Error(`snapshot path must be a real directory: ${root}`);
|
|
8939
|
+
} catch (error) {
|
|
8940
|
+
if (error.code === "ENOENT") {
|
|
8941
|
+
await mkdir6(destination, { recursive: true });
|
|
8942
|
+
return;
|
|
8943
|
+
}
|
|
8944
|
+
throw error;
|
|
8945
|
+
}
|
|
8946
|
+
await mkdir6(destination, { recursive: true });
|
|
8947
|
+
const walk = async (current, target) => {
|
|
8948
|
+
for (const entry of await readdir3(current, { withFileTypes: true })) {
|
|
8949
|
+
const fullPath = join8(current, entry.name);
|
|
8950
|
+
const targetPath = join8(target, entry.name);
|
|
8951
|
+
if (entry.isSymbolicLink())
|
|
8952
|
+
throw new Error(`snapshot refuses symbolic link: ${fullPath}`);
|
|
8953
|
+
if (entry.isDirectory()) {
|
|
8954
|
+
await mkdir6(targetPath, { recursive: true });
|
|
8955
|
+
await walk(fullPath, targetPath);
|
|
8956
|
+
} else if (entry.isFile()) {
|
|
8957
|
+
await stageFile(fullPath, targetPath);
|
|
8958
|
+
} else {
|
|
8959
|
+
throw new Error(`snapshot refuses unsupported filesystem entry: ${fullPath}`);
|
|
8960
|
+
}
|
|
8961
|
+
}
|
|
8962
|
+
};
|
|
8963
|
+
await walk(root, destination);
|
|
8964
|
+
}
|
|
8965
|
+
async function stageFile(source, target) {
|
|
8966
|
+
await mkdir6(dirname4(target), { recursive: true });
|
|
8967
|
+
try {
|
|
8968
|
+
await link2(source, target);
|
|
8969
|
+
} catch (error) {
|
|
8970
|
+
const code = error.code;
|
|
8971
|
+
if (code !== "EXDEV" && code !== "EPERM" && code !== "EACCES")
|
|
8972
|
+
throw error;
|
|
8973
|
+
await copyFile(source, target);
|
|
8974
|
+
}
|
|
8975
|
+
}
|
|
8976
|
+
async function readManifest(payloadRoot) {
|
|
8977
|
+
let parsed;
|
|
8978
|
+
try {
|
|
8979
|
+
parsed = JSON.parse(await readFile7(join8(payloadRoot, "manifest.json"), "utf8"));
|
|
8980
|
+
} catch (error) {
|
|
8981
|
+
throw new Error(`invalid snapshot manifest: ${error instanceof Error ? error.message : String(error)}`);
|
|
8982
|
+
}
|
|
8983
|
+
if (!isSnapshotManifest(parsed))
|
|
8984
|
+
throw new Error("unsupported or invalid SupaCloud Lite snapshot manifest");
|
|
8985
|
+
return parsed;
|
|
8986
|
+
}
|
|
8987
|
+
function isSnapshotManifest(value) {
|
|
8988
|
+
if (!value || typeof value !== "object")
|
|
8989
|
+
return false;
|
|
8990
|
+
const candidate = value;
|
|
8991
|
+
return candidate.format === SNAPSHOT_FORMAT && candidate.version === SNAPSHOT_VERSION && typeof candidate.createdAt === "string" && typeof candidate.packageVersion === "string" && (candidate.storageBackend === "fs" || candidate.storageBackend === "s3" || candidate.storageBackend === "memory") && typeof candidate.includesDatabase === "boolean" && typeof candidate.includesLocalStorage === "boolean" && candidate.includesSecrets === true;
|
|
8992
|
+
}
|
|
8993
|
+
async function assertSnapshotPayload(payloadRoot, manifest) {
|
|
8994
|
+
const required = ["manifest.json", "secrets.json"];
|
|
8995
|
+
for (const path of required) {
|
|
8996
|
+
try {
|
|
8997
|
+
await lstat2(join8(payloadRoot, path));
|
|
8998
|
+
} catch {
|
|
8999
|
+
throw new Error(`snapshot is missing required payload: ${path}`);
|
|
9000
|
+
}
|
|
9001
|
+
}
|
|
9002
|
+
const allowed = [...required];
|
|
9003
|
+
if (manifest.includesDatabase)
|
|
9004
|
+
allowed.push("database");
|
|
9005
|
+
if (manifest.includesLocalStorage)
|
|
9006
|
+
allowed.push("storage");
|
|
9007
|
+
for (const entry of await readdir3(payloadRoot)) {
|
|
9008
|
+
if (!allowed.includes(entry)) {
|
|
9009
|
+
throw new Error(`snapshot contains an unexpected payload entry: ${entry}`);
|
|
9010
|
+
}
|
|
9011
|
+
}
|
|
9012
|
+
}
|
|
9013
|
+
async function assertRestoreTargets(paths, manifest, force) {
|
|
9014
|
+
const targets = [paths.stateDir];
|
|
9015
|
+
if (paths.dataDir && !isWithin(paths.stateDir, paths.dataDir))
|
|
9016
|
+
targets.push(paths.dataDir);
|
|
9017
|
+
if (manifest.includesLocalStorage && !isWithin(paths.stateDir, paths.storageDir))
|
|
9018
|
+
targets.push(paths.storageDir);
|
|
9019
|
+
if (!force) {
|
|
9020
|
+
for (const target of targets) {
|
|
9021
|
+
if (await directoryHasEntries(target))
|
|
9022
|
+
throw new Error(`restore target is not empty: ${target}; pass --force to replace it`);
|
|
9023
|
+
}
|
|
9024
|
+
}
|
|
9025
|
+
}
|
|
9026
|
+
async function directoryHasEntries(path) {
|
|
9027
|
+
try {
|
|
9028
|
+
return (await readdir3(path)).length > 0;
|
|
9029
|
+
} catch (error) {
|
|
9030
|
+
if (error.code === "ENOENT")
|
|
9031
|
+
return false;
|
|
9032
|
+
throw error;
|
|
9033
|
+
}
|
|
9034
|
+
}
|
|
9035
|
+
async function applyDirectorySwap(source, target, force, rollbackId, swaps) {
|
|
9036
|
+
const targetInfo = await existingInfo(target);
|
|
9037
|
+
if (targetInfo && !targetInfo.isDirectory())
|
|
9038
|
+
throw new Error(`restore target is not a directory: ${target}`);
|
|
9039
|
+
const swap = { target };
|
|
9040
|
+
if (targetInfo) {
|
|
9041
|
+
if (!force) {
|
|
9042
|
+
if (await directoryHasEntries(target))
|
|
9043
|
+
throw new Error(`restore target is not empty: ${target}; pass --force to replace it`);
|
|
9044
|
+
await rm3(target, { recursive: true, force: true });
|
|
9045
|
+
} else {
|
|
9046
|
+
swap.rollbackPath = join8(dirname4(target), `.${target.split(sep2).pop() ?? "state"}.restore-${rollbackId}`);
|
|
9047
|
+
await rename2(target, swap.rollbackPath);
|
|
9048
|
+
}
|
|
9049
|
+
}
|
|
9050
|
+
try {
|
|
9051
|
+
await mkdir6(dirname4(target), { recursive: true });
|
|
9052
|
+
await rename2(source, target);
|
|
9053
|
+
swaps.push(swap);
|
|
9054
|
+
} catch (error) {
|
|
9055
|
+
if (swap.rollbackPath)
|
|
9056
|
+
await rename2(swap.rollbackPath, target).catch(() => {});
|
|
9057
|
+
throw error;
|
|
9058
|
+
}
|
|
9059
|
+
}
|
|
9060
|
+
async function rollbackDirectorySwaps(swaps) {
|
|
9061
|
+
for (const swap of [...swaps].reverse()) {
|
|
9062
|
+
await rm3(swap.target, { recursive: true, force: true });
|
|
9063
|
+
if (swap.rollbackPath)
|
|
9064
|
+
await rename2(swap.rollbackPath, swap.target);
|
|
9065
|
+
}
|
|
9066
|
+
}
|
|
9067
|
+
async function existingInfo(path) {
|
|
9068
|
+
try {
|
|
9069
|
+
return await lstat2(path);
|
|
9070
|
+
} catch (error) {
|
|
9071
|
+
if (error.code === "ENOENT")
|
|
9072
|
+
return null;
|
|
9073
|
+
throw error;
|
|
9074
|
+
}
|
|
9075
|
+
}
|
|
9076
|
+
async function copyEntry(source, target) {
|
|
9077
|
+
const info = await lstat2(source);
|
|
9078
|
+
if (info.isSymbolicLink())
|
|
9079
|
+
throw new Error(`snapshot refuses symbolic link: ${source}`);
|
|
9080
|
+
if (info.isDirectory()) {
|
|
9081
|
+
await mkdir6(target, { recursive: true });
|
|
9082
|
+
for (const entry of await readdir3(source))
|
|
9083
|
+
await copyEntry(join8(source, entry), join8(target, entry));
|
|
9084
|
+
} else if (info.isFile()) {
|
|
9085
|
+
await mkdir6(dirname4(target), { recursive: true });
|
|
9086
|
+
await Bun.write(target, Bun.file(source));
|
|
9087
|
+
} else
|
|
9088
|
+
throw new Error(`snapshot refuses unsupported filesystem entry: ${source}`);
|
|
9089
|
+
}
|
|
9090
|
+
async function hardenRestoredTree(root) {
|
|
9091
|
+
const info = await lstat2(root);
|
|
9092
|
+
if (info.isSymbolicLink())
|
|
9093
|
+
throw new Error(`snapshot refuses symbolic link: ${root}`);
|
|
9094
|
+
if (info.isDirectory()) {
|
|
9095
|
+
await chmod2(root, 448);
|
|
9096
|
+
for (const entry of await readdir3(root))
|
|
9097
|
+
await hardenRestoredTree(join8(root, entry));
|
|
9098
|
+
return;
|
|
9099
|
+
}
|
|
9100
|
+
if (info.isFile()) {
|
|
9101
|
+
await chmod2(root, 384);
|
|
9102
|
+
return;
|
|
9103
|
+
}
|
|
9104
|
+
throw new Error(`snapshot refuses unsupported filesystem entry: ${root}`);
|
|
9105
|
+
}
|
|
9106
|
+
async function assertNoSymlinks(root) {
|
|
9107
|
+
for (const entry of await readdir3(root, { withFileTypes: true })) {
|
|
9108
|
+
const fullPath = join8(root, entry.name);
|
|
9109
|
+
if (entry.isSymbolicLink())
|
|
9110
|
+
throw new Error(`snapshot refuses symbolic link in archive: ${fullPath}`);
|
|
9111
|
+
if (entry.isDirectory())
|
|
9112
|
+
await assertNoSymlinks(fullPath);
|
|
9113
|
+
}
|
|
9114
|
+
}
|
|
9115
|
+
function isWithin(parent, child) {
|
|
9116
|
+
const normalizedParent = resolve3(parent);
|
|
9117
|
+
const normalizedChild = resolve3(child);
|
|
9118
|
+
return normalizedChild !== normalizedParent && normalizedChild.startsWith(`${normalizedParent}${sep2}`);
|
|
9119
|
+
}
|
|
9120
|
+
function pathsOverlap(left, right) {
|
|
9121
|
+
const normalizedLeft = resolve3(left);
|
|
9122
|
+
const normalizedRight = resolve3(right);
|
|
9123
|
+
return normalizedLeft === normalizedRight || isWithin(normalizedLeft, normalizedRight) || isWithin(normalizedRight, normalizedLeft);
|
|
9124
|
+
}
|
|
9125
|
+
|
|
8739
9126
|
// src/cli.ts
|
|
8740
9127
|
function parseArgs(argv) {
|
|
8741
9128
|
const args = [...argv];
|
|
@@ -8746,7 +9133,8 @@ function parseArgs(argv) {
|
|
|
8746
9133
|
projectDir: process.cwd(),
|
|
8747
9134
|
host: process.env.SUPACLOUD_LITE_HOST ?? "127.0.0.1",
|
|
8748
9135
|
port: process.env.SUPACLOUD_LITE_PORT || process.env.PORT ? Number.parseInt(process.env.SUPACLOUD_LITE_PORT ?? process.env.PORT, 10) : 54321,
|
|
8749
|
-
serviceRole: false
|
|
9136
|
+
serviceRole: false,
|
|
9137
|
+
force: false
|
|
8750
9138
|
};
|
|
8751
9139
|
for (let index = 0;index < args.length; index++) {
|
|
8752
9140
|
const argument = args[index];
|
|
@@ -8765,13 +9153,13 @@ function parseArgs(argv) {
|
|
|
8765
9153
|
else if (argument === "--site-url")
|
|
8766
9154
|
options.siteUrl = next();
|
|
8767
9155
|
else if (argument === "--project-dir" || argument === "--dir")
|
|
8768
|
-
options.projectDir =
|
|
9156
|
+
options.projectDir = resolve4(next());
|
|
8769
9157
|
else if (argument === "--state-dir")
|
|
8770
|
-
options.stateDir =
|
|
9158
|
+
options.stateDir = resolve4(next());
|
|
8771
9159
|
else if (argument === "--data-dir")
|
|
8772
|
-
options.dataDir =
|
|
9160
|
+
options.dataDir = resolve4(next());
|
|
8773
9161
|
else if (argument === "--storage-dir")
|
|
8774
|
-
options.storageDir =
|
|
9162
|
+
options.storageDir = resolve4(next());
|
|
8775
9163
|
else if (argument === "--storage-backend")
|
|
8776
9164
|
options.storageBackend = next();
|
|
8777
9165
|
else if (argument === "--s3-prefix")
|
|
@@ -8779,11 +9167,13 @@ function parseArgs(argv) {
|
|
|
8779
9167
|
else if (argument === "--memory")
|
|
8780
9168
|
options.memory = true;
|
|
8781
9169
|
else if (argument === "--output" || argument === "-o")
|
|
8782
|
-
options.output =
|
|
9170
|
+
options.output = resolve4(next());
|
|
8783
9171
|
else if (argument === "--file" || argument === "-f")
|
|
8784
9172
|
options.diffFile = next();
|
|
8785
9173
|
else if (argument === "--service-role")
|
|
8786
9174
|
options.serviceRole = true;
|
|
9175
|
+
else if (argument === "--force")
|
|
9176
|
+
options.force = true;
|
|
8787
9177
|
else if (argument === "--version") {
|
|
8788
9178
|
console.log(package_default.version);
|
|
8789
9179
|
process.exit(0);
|
|
@@ -8822,6 +9212,14 @@ Use --service-role to print the privileged key.`);
|
|
|
8822
9212
|
await runDbCommand(options);
|
|
8823
9213
|
return;
|
|
8824
9214
|
}
|
|
9215
|
+
if (options.command === "snapshot") {
|
|
9216
|
+
await runSnapshotCommand(options);
|
|
9217
|
+
return;
|
|
9218
|
+
}
|
|
9219
|
+
if (options.command === "upgrade") {
|
|
9220
|
+
await runUpgradeCommand(options);
|
|
9221
|
+
return;
|
|
9222
|
+
}
|
|
8825
9223
|
if (options.command === "gen") {
|
|
8826
9224
|
if (options.positionals[0] && options.positionals[0] !== "types" && options.positionals[0] !== "typescript") {
|
|
8827
9225
|
throw new Error(`unknown gen subcommand: ${options.positionals[0]}`);
|
|
@@ -8830,8 +9228,8 @@ Use --service-role to print the privileged key.`);
|
|
|
8830
9228
|
try {
|
|
8831
9229
|
const source = await generateTypes(project2.backend.db, "public");
|
|
8832
9230
|
if (options.output) {
|
|
8833
|
-
await
|
|
8834
|
-
await
|
|
9231
|
+
await mkdir7(dirname5(options.output), { recursive: true });
|
|
9232
|
+
await writeFile6(options.output, source);
|
|
8835
9233
|
console.log(`Wrote ${options.output}`);
|
|
8836
9234
|
} else
|
|
8837
9235
|
process.stdout.write(source);
|
|
@@ -8901,8 +9299,8 @@ async function runDbCommand(options) {
|
|
|
8901
9299
|
}
|
|
8902
9300
|
await assertResetPathsSafe(paths);
|
|
8903
9301
|
if (paths.dataDir)
|
|
8904
|
-
await
|
|
8905
|
-
await
|
|
9302
|
+
await rm4(paths.dataDir, { recursive: true, force: true });
|
|
9303
|
+
await rm4(paths.storageDir, { recursive: true, force: true });
|
|
8906
9304
|
const project2 = await createProjectBackend({ ...options, includeFunctions: false, includeWebhooks: false, log: quietLog });
|
|
8907
9305
|
try {
|
|
8908
9306
|
const applied = await project2.backend.db.listAppliedMigrations();
|
|
@@ -8912,7 +9310,7 @@ async function runDbCommand(options) {
|
|
|
8912
9310
|
}
|
|
8913
9311
|
return;
|
|
8914
9312
|
}
|
|
8915
|
-
const project = await loadSupabaseProject(
|
|
9313
|
+
const project = await loadSupabaseProject(resolve4(options.projectDir ?? process.cwd()));
|
|
8916
9314
|
if (subcommand === "diff") {
|
|
8917
9315
|
const ddl = await computeDbDiff({ liveDataDir: paths.dataDir, migrations: project.migrations });
|
|
8918
9316
|
if (ddl.length === 0) {
|
|
@@ -8925,9 +9323,9 @@ async function runDbCommand(options) {
|
|
|
8925
9323
|
`;
|
|
8926
9324
|
if (options.diffFile) {
|
|
8927
9325
|
const stamp = timestamp();
|
|
8928
|
-
const output =
|
|
8929
|
-
await
|
|
8930
|
-
await
|
|
9326
|
+
const output = join9(paths.projectDir, "supabase", "migrations", `${stamp}_${options.diffFile}.sql`);
|
|
9327
|
+
await mkdir7(join9(paths.projectDir, "supabase", "migrations"), { recursive: true });
|
|
9328
|
+
await writeFile6(output, source);
|
|
8931
9329
|
console.log(`Wrote ${output}`);
|
|
8932
9330
|
} else
|
|
8933
9331
|
process.stdout.write(source);
|
|
@@ -8937,7 +9335,7 @@ async function runDbCommand(options) {
|
|
|
8937
9335
|
const result = await pullSchema({
|
|
8938
9336
|
liveDataDir: paths.dataDir,
|
|
8939
9337
|
migrations: project.migrations,
|
|
8940
|
-
migrationsDir:
|
|
9338
|
+
migrationsDir: join9(paths.projectDir, "supabase", "migrations"),
|
|
8941
9339
|
name: options.positionals[1] ?? "remote_schema"
|
|
8942
9340
|
});
|
|
8943
9341
|
if (!result.path)
|
|
@@ -8948,6 +9346,63 @@ async function runDbCommand(options) {
|
|
|
8948
9346
|
}
|
|
8949
9347
|
throw new Error(`unknown db subcommand: ${subcommand ?? "(none)"}`);
|
|
8950
9348
|
}
|
|
9349
|
+
async function runSnapshotCommand(options) {
|
|
9350
|
+
const subcommand = options.positionals[0];
|
|
9351
|
+
const paths = resolveProjectPaths(options);
|
|
9352
|
+
const storageBackend = resolveStorageBackend(options.storageBackend);
|
|
9353
|
+
if (options.memory)
|
|
9354
|
+
throw new Error("snapshot does not support --memory because the database is not durable");
|
|
9355
|
+
if (subcommand === "create") {
|
|
9356
|
+
await ensureProjectSecrets(paths);
|
|
9357
|
+
const output = options.output ?? join9(paths.stateDir, "backups", `snapshot-${timestamp()}.tar.gz`);
|
|
9358
|
+
const manifest = await createSnapshot({ paths, packageVersion: package_default.version, storageBackend, output });
|
|
9359
|
+
console.log(`Snapshot created: ${output}`);
|
|
9360
|
+
if (manifest.storageBackend === "s3")
|
|
9361
|
+
console.log("S3 objects were not copied; the snapshot contains database metadata and secrets only.");
|
|
9362
|
+
return;
|
|
9363
|
+
}
|
|
9364
|
+
if (subcommand === "restore") {
|
|
9365
|
+
const input = options.positionals[1];
|
|
9366
|
+
if (!input)
|
|
9367
|
+
throw new Error("snapshot restore requires a snapshot file");
|
|
9368
|
+
const result = await restoreSnapshot({ paths, storageBackend, input, force: options.force });
|
|
9369
|
+
console.log(`Snapshot restored from ${resolve4(input)}`);
|
|
9370
|
+
for (const rollbackPath of result.rollbackPaths)
|
|
9371
|
+
console.log(`Previous state retained at ${rollbackPath}`);
|
|
9372
|
+
if (result.manifest.storageBackend === "s3")
|
|
9373
|
+
console.log("Reconnect the original S3 bucket/prefix before starting Lite.");
|
|
9374
|
+
return;
|
|
9375
|
+
}
|
|
9376
|
+
throw new Error(`unknown snapshot subcommand: ${subcommand ?? "(none)"}`);
|
|
9377
|
+
}
|
|
9378
|
+
async function runUpgradeCommand(options) {
|
|
9379
|
+
if (options.memory)
|
|
9380
|
+
throw new Error("upgrade does not support --memory because there is no durable database to back up");
|
|
9381
|
+
const paths = resolveProjectPaths(options);
|
|
9382
|
+
const storageBackend = resolveStorageBackend(options.storageBackend);
|
|
9383
|
+
await ensureProjectSecrets(paths);
|
|
9384
|
+
const output = options.output ?? join9(paths.stateDir, "backups", `pre-upgrade-${timestamp()}.tar.gz`);
|
|
9385
|
+
await createSnapshot({ paths, packageVersion: package_default.version, storageBackend, output });
|
|
9386
|
+
console.log(`Pre-upgrade snapshot: ${output}`);
|
|
9387
|
+
try {
|
|
9388
|
+
const project = await createProjectBackend({
|
|
9389
|
+
...options,
|
|
9390
|
+
applyMigrations: true,
|
|
9391
|
+
includeFunctions: false,
|
|
9392
|
+
includeWebhooks: false,
|
|
9393
|
+
includeSeed: true,
|
|
9394
|
+
log: quietLog
|
|
9395
|
+
});
|
|
9396
|
+
try {
|
|
9397
|
+
const applied = await project.backend.db.listAppliedMigrations();
|
|
9398
|
+
console.log(`Upgrade complete on @supacloud/lite ${package_default.version}: ${applied.length} migration(s) recorded.`);
|
|
9399
|
+
} finally {
|
|
9400
|
+
await project.backend.close();
|
|
9401
|
+
}
|
|
9402
|
+
} catch (error) {
|
|
9403
|
+
throw new Error(`upgrade failed; snapshot retained at ${output}. Restore it with ` + `"supacloud-lite snapshot restore ${output} --force". ${error instanceof Error ? error.message : String(error)}`);
|
|
9404
|
+
}
|
|
9405
|
+
}
|
|
8951
9406
|
function printInspection(rows) {
|
|
8952
9407
|
if (rows.length === 0) {
|
|
8953
9408
|
console.log('No tables in schema "public".');
|
|
@@ -8984,6 +9439,9 @@ Commands:
|
|
|
8984
9439
|
db reset wipe database and storage, then re-run migrations
|
|
8985
9440
|
db diff print schema changes outside migrations
|
|
8986
9441
|
db pull [name] write live schema changes as an applied migration
|
|
9442
|
+
snapshot create create a compressed database/storage/secrets snapshot
|
|
9443
|
+
snapshot restore <f> restore a snapshot into an empty target
|
|
9444
|
+
upgrade snapshot first, then apply pending migrations
|
|
8987
9445
|
inspect show table rows and sizes
|
|
8988
9446
|
version print the package version
|
|
8989
9447
|
|
|
@@ -9001,6 +9459,7 @@ Options:
|
|
|
9001
9459
|
--memory use an in-memory PGlite database
|
|
9002
9460
|
-o, --output <p> output file for gen types
|
|
9003
9461
|
-f, --file <name> migration suffix for db diff
|
|
9462
|
+
--force replace non-empty restore targets and retain rollback copies
|
|
9004
9463
|
`);
|
|
9005
9464
|
}
|
|
9006
9465
|
function formatStorage(backend, storageDir) {
|
package/dist/index.d.ts
CHANGED
|
@@ -9,5 +9,6 @@ export { FsStorageDriver } from './vendor/tinbase/node/fs-driver.js';
|
|
|
9
9
|
export { serveBun } from './vendor/tinbase/node/bun-server.js';
|
|
10
10
|
export type { RunningServer, ServerHandle, ServeOptions } from './vendor/tinbase/node/bun-server.js';
|
|
11
11
|
export { createProjectBackend, ensureProjectSecrets, mintProjectKeys, resolveStorageBackend, resolveProjectPaths, startProjectServer, } from './project-runtime.js';
|
|
12
|
+
export { createSnapshot, restoreSnapshot, type CreateSnapshotOptions, type RestoreSnapshotOptions, type RestoreSnapshotResult, type SnapshotManifest, } from './snapshot.js';
|
|
12
13
|
export type { ConfiguredStorageBackend, ProjectBackend, ProjectPaths, ProjectRuntimeOptions, ProjectSecrets, RunningProjectServer, StorageBackend, } from './project-runtime.js';
|
|
13
14
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,aAAa,IAAI,iBAAiB,EAClC,kBAAkB,EAClB,SAAS,EACT,aAAa,EACb,SAAS,EACT,OAAO,EACP,SAAS,GACV,MAAM,2BAA2B,CAAA;AAClC,YAAY,EACV,aAAa,IAAI,mBAAmB,EACpC,cAAc,IAAI,oBAAoB,GACvC,MAAM,2BAA2B,CAAA;AAClC,YAAY,EACV,UAAU,EACV,MAAM,EACN,WAAW,EACX,aAAa,EACb,cAAc,EACd,aAAa,GACd,MAAM,2BAA2B,CAAA;AAClC,OAAO,EAAE,mBAAmB,EAAE,MAAM,oCAAoC,CAAA;AACxE,OAAO,EACL,eAAe,EACf,KAAK,mBAAmB,EACxB,KAAK,sBAAsB,EAC3B,KAAK,iBAAiB,GACvB,MAAM,uCAAuC,CAAA;AAC9C,YAAY,EAAE,YAAY,EAAE,eAAe,EAAE,MAAM,uCAAuC,CAAA;AAC1F,OAAO,EAAE,sBAAsB,EAAE,MAAM,cAAc,CAAA;AACrD,OAAO,EAAE,eAAe,EAAE,MAAM,oCAAoC,CAAA;AACpE,OAAO,EAAE,QAAQ,EAAE,MAAM,qCAAqC,CAAA;AAC9D,YAAY,EAAE,aAAa,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,qCAAqC,CAAA;AACpG,OAAO,EACL,oBAAoB,EACpB,oBAAoB,EACpB,eAAe,EACf,qBAAqB,EACrB,mBAAmB,EACnB,kBAAkB,GACnB,MAAM,sBAAsB,CAAA;AAC7B,YAAY,EACV,wBAAwB,EACxB,cAAc,EACd,YAAY,EACZ,qBAAqB,EACrB,cAAc,EACd,oBAAoB,EACpB,cAAc,GACf,MAAM,sBAAsB,CAAA"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,aAAa,IAAI,iBAAiB,EAClC,kBAAkB,EAClB,SAAS,EACT,aAAa,EACb,SAAS,EACT,OAAO,EACP,SAAS,GACV,MAAM,2BAA2B,CAAA;AAClC,YAAY,EACV,aAAa,IAAI,mBAAmB,EACpC,cAAc,IAAI,oBAAoB,GACvC,MAAM,2BAA2B,CAAA;AAClC,YAAY,EACV,UAAU,EACV,MAAM,EACN,WAAW,EACX,aAAa,EACb,cAAc,EACd,aAAa,GACd,MAAM,2BAA2B,CAAA;AAClC,OAAO,EAAE,mBAAmB,EAAE,MAAM,oCAAoC,CAAA;AACxE,OAAO,EACL,eAAe,EACf,KAAK,mBAAmB,EACxB,KAAK,sBAAsB,EAC3B,KAAK,iBAAiB,GACvB,MAAM,uCAAuC,CAAA;AAC9C,YAAY,EAAE,YAAY,EAAE,eAAe,EAAE,MAAM,uCAAuC,CAAA;AAC1F,OAAO,EAAE,sBAAsB,EAAE,MAAM,cAAc,CAAA;AACrD,OAAO,EAAE,eAAe,EAAE,MAAM,oCAAoC,CAAA;AACpE,OAAO,EAAE,QAAQ,EAAE,MAAM,qCAAqC,CAAA;AAC9D,YAAY,EAAE,aAAa,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,qCAAqC,CAAA;AACpG,OAAO,EACL,oBAAoB,EACpB,oBAAoB,EACpB,eAAe,EACf,qBAAqB,EACrB,mBAAmB,EACnB,kBAAkB,GACnB,MAAM,sBAAsB,CAAA;AAC7B,OAAO,EACL,cAAc,EACd,eAAe,EACf,KAAK,qBAAqB,EAC1B,KAAK,sBAAsB,EAC3B,KAAK,qBAAqB,EAC1B,KAAK,gBAAgB,GACtB,MAAM,eAAe,CAAA;AACtB,YAAY,EACV,wBAAwB,EACxB,cAAc,EACd,YAAY,EACZ,qBAAqB,EACrB,cAAc,EACd,oBAAoB,EACpB,cAAc,GACf,MAAM,sBAAsB,CAAA"}
|
package/dist/index.js
CHANGED
|
@@ -71,7 +71,7 @@ function randomToken(bytes = 32) {
|
|
|
71
71
|
// package.json
|
|
72
72
|
var package_default = {
|
|
73
73
|
name: "@supacloud/lite",
|
|
74
|
-
version: "0.
|
|
74
|
+
version: "0.3.0",
|
|
75
75
|
description: "Bun-native, single-project Supabase-compatible backend powered by PGlite",
|
|
76
76
|
type: "module",
|
|
77
77
|
license: "Apache-2.0",
|
|
@@ -95,7 +95,7 @@ var package_default = {
|
|
|
95
95
|
],
|
|
96
96
|
scripts: {
|
|
97
97
|
build: "bun run build:js && bun run build:types",
|
|
98
|
-
"build:js": "bun build src/index.ts --target=bun --format=esm --outdir=dist --external @electric-sql/pglite && bun build src/cli.ts --target=bun --format=esm --outdir=dist --external @electric-sql/pglite",
|
|
98
|
+
"build:js": "bun build src/index.ts --target=bun --format=esm --outdir=dist --external @electric-sql/pglite --external tar && bun build src/cli.ts --target=bun --format=esm --outdir=dist --external @electric-sql/pglite --external tar",
|
|
99
99
|
"build:types": "bun x tsc --emitDeclarationOnly -p tsconfig.build.json",
|
|
100
100
|
check: "bun run typecheck && bun run test && bun run build && bun run test:package",
|
|
101
101
|
dev: "bun run src/cli.ts start",
|
|
@@ -106,10 +106,11 @@ var package_default = {
|
|
|
106
106
|
typecheck: "bun x tsc --noEmit -p tsconfig.json"
|
|
107
107
|
},
|
|
108
108
|
dependencies: {
|
|
109
|
-
"@electric-sql/pglite": "0.5.4"
|
|
109
|
+
"@electric-sql/pglite": "0.5.4",
|
|
110
|
+
tar: "^7.5.22"
|
|
110
111
|
},
|
|
111
112
|
devDependencies: {
|
|
112
|
-
"@supabase/supabase-js": "^2.
|
|
113
|
+
"@supabase/supabase-js": "^2.110.9",
|
|
113
114
|
"@types/bun": "^1.3.14",
|
|
114
115
|
typescript: "^5.9.3"
|
|
115
116
|
},
|
|
@@ -8447,11 +8448,397 @@ async function findEphemeralPort(host = "127.0.0.1") {
|
|
|
8447
8448
|
throw new Error("Bun did not allocate an ephemeral port");
|
|
8448
8449
|
return port;
|
|
8449
8450
|
}
|
|
8451
|
+
// src/snapshot.ts
|
|
8452
|
+
import { chmod as chmod2, copyFile, link as link2, lstat as lstat2, mkdir as mkdir5, mkdtemp, readdir as readdir3, readFile as readFile7, rename as rename2, rm as rm3, writeFile as writeFile4 } from "fs/promises";
|
|
8453
|
+
import { dirname as dirname4, join as join7, parse as parse2, relative as relative2, resolve as resolve3, sep as sep2 } from "path";
|
|
8454
|
+
import { create as createTar, extract as extractTar } from "tar";
|
|
8455
|
+
var SNAPSHOT_FORMAT = "supacloud-lite-snapshot";
|
|
8456
|
+
var SNAPSHOT_VERSION = 1;
|
|
8457
|
+
async function createSnapshot(options) {
|
|
8458
|
+
const paths = normalizePaths(options.paths);
|
|
8459
|
+
await assertSnapshotPaths(paths);
|
|
8460
|
+
await assertNoDataDirectoryLock(paths);
|
|
8461
|
+
const manifest = {
|
|
8462
|
+
format: SNAPSHOT_FORMAT,
|
|
8463
|
+
version: SNAPSHOT_VERSION,
|
|
8464
|
+
createdAt: new Date().toISOString(),
|
|
8465
|
+
packageVersion: options.packageVersion,
|
|
8466
|
+
storageBackend: options.storageBackend,
|
|
8467
|
+
includesDatabase: Boolean(paths.dataDir),
|
|
8468
|
+
includesLocalStorage: options.storageBackend === "fs",
|
|
8469
|
+
includesSecrets: true
|
|
8470
|
+
};
|
|
8471
|
+
const output = resolve3(options.output);
|
|
8472
|
+
if (await existingInfo(output))
|
|
8473
|
+
throw new Error(`snapshot output already exists: ${output}`);
|
|
8474
|
+
await mkdir5(dirname4(output), { recursive: true });
|
|
8475
|
+
const stagingRoot = await mkdtemp(join7(dirname4(output), ".supacloud-lite-snapshot-"));
|
|
8476
|
+
try {
|
|
8477
|
+
await writeFile4(join7(stagingRoot, "manifest.json"), `${JSON.stringify(manifest, null, 2)}
|
|
8478
|
+
`);
|
|
8479
|
+
await stageFile(paths.secretsFile, join7(stagingRoot, "secrets.json"));
|
|
8480
|
+
if (paths.dataDir)
|
|
8481
|
+
await stageDirectory(paths.dataDir, join7(stagingRoot, "database"));
|
|
8482
|
+
if (options.storageBackend === "fs")
|
|
8483
|
+
await stageDirectory(paths.storageDir, join7(stagingRoot, "storage"));
|
|
8484
|
+
const entries = ["manifest.json", "secrets.json"];
|
|
8485
|
+
if (paths.dataDir)
|
|
8486
|
+
entries.push("database");
|
|
8487
|
+
if (options.storageBackend === "fs")
|
|
8488
|
+
entries.push("storage");
|
|
8489
|
+
await createTar({ cwd: stagingRoot, file: output, gzip: true, portable: true }, entries);
|
|
8490
|
+
if (process.platform !== "win32")
|
|
8491
|
+
await chmod2(output, 384);
|
|
8492
|
+
return manifest;
|
|
8493
|
+
} catch (error) {
|
|
8494
|
+
await rm3(output, { force: true });
|
|
8495
|
+
throw error;
|
|
8496
|
+
} finally {
|
|
8497
|
+
await rm3(stagingRoot, { recursive: true, force: true });
|
|
8498
|
+
}
|
|
8499
|
+
}
|
|
8500
|
+
async function restoreSnapshot(options) {
|
|
8501
|
+
const paths = normalizePaths(options.paths);
|
|
8502
|
+
await assertSnapshotPaths(paths, { requireSecrets: false, allowMissingState: true });
|
|
8503
|
+
await assertNoDataDirectoryLock(paths);
|
|
8504
|
+
const stagingRoot = await mkdtemp(join7(dirname4(paths.stateDir), ".supacloud-lite-restore-"));
|
|
8505
|
+
const payloadRoot = join7(stagingRoot, "payload");
|
|
8506
|
+
const rollbackId = crypto.randomUUID();
|
|
8507
|
+
const rollbackPaths = [];
|
|
8508
|
+
try {
|
|
8509
|
+
await mkdir5(payloadRoot, { recursive: true });
|
|
8510
|
+
await extractTar({
|
|
8511
|
+
cwd: payloadRoot,
|
|
8512
|
+
file: resolve3(options.input),
|
|
8513
|
+
preserveOwner: false,
|
|
8514
|
+
preservePaths: false,
|
|
8515
|
+
strict: true,
|
|
8516
|
+
unlink: true,
|
|
8517
|
+
filter: (path, entry) => {
|
|
8518
|
+
const normalized = path.replaceAll("\\", "/");
|
|
8519
|
+
const allowedPath = normalized === "manifest.json" || normalized === "secrets.json" || normalized === "database" || normalized.startsWith("database/") || normalized === "storage" || normalized.startsWith("storage/");
|
|
8520
|
+
if (!allowedPath || normalized.startsWith("/") || normalized.split("/").includes("..")) {
|
|
8521
|
+
throw new Error(`snapshot contains an unsafe path: ${path}`);
|
|
8522
|
+
}
|
|
8523
|
+
const entryType = "type" in entry ? entry.type : undefined;
|
|
8524
|
+
if (entryType !== "File" && entryType !== "Directory") {
|
|
8525
|
+
throw new Error(`snapshot contains an unsupported entry type: ${entryType ?? "unknown"}`);
|
|
8526
|
+
}
|
|
8527
|
+
return true;
|
|
8528
|
+
}
|
|
8529
|
+
});
|
|
8530
|
+
await assertNoSymlinks(payloadRoot);
|
|
8531
|
+
const manifest = await readManifest(payloadRoot);
|
|
8532
|
+
if (manifest.storageBackend !== options.storageBackend) {
|
|
8533
|
+
throw new Error(`snapshot storage backend is ${manifest.storageBackend}, but the target uses ${options.storageBackend}; ` + "restore with the matching --storage-backend value");
|
|
8534
|
+
}
|
|
8535
|
+
if (manifest.includesDatabase !== Boolean(paths.dataDir)) {
|
|
8536
|
+
throw new Error("snapshot database mode does not match the target; do not restore a persistent snapshot into --memory");
|
|
8537
|
+
}
|
|
8538
|
+
if (manifest.includesLocalStorage !== (options.storageBackend === "fs")) {
|
|
8539
|
+
throw new Error("snapshot storage payload does not match the target storage backend");
|
|
8540
|
+
}
|
|
8541
|
+
await assertSnapshotPayload(payloadRoot, manifest);
|
|
8542
|
+
if (manifest.includesDatabase)
|
|
8543
|
+
await mkdir5(join7(payloadRoot, "database"), { recursive: true });
|
|
8544
|
+
if (manifest.includesLocalStorage)
|
|
8545
|
+
await mkdir5(join7(payloadRoot, "storage"), { recursive: true });
|
|
8546
|
+
await assertRestoreTargets(paths, manifest, options.force === true);
|
|
8547
|
+
const stateStage = join7(stagingRoot, "state");
|
|
8548
|
+
await mkdir5(stateStage, { recursive: true });
|
|
8549
|
+
await copyEntry(join7(payloadRoot, "secrets.json"), join7(stateStage, "secrets.json"));
|
|
8550
|
+
if (paths.dataDir && isWithin(paths.stateDir, paths.dataDir)) {
|
|
8551
|
+
await copyEntry(join7(payloadRoot, "database"), join7(stateStage, relative2(paths.stateDir, paths.dataDir)));
|
|
8552
|
+
}
|
|
8553
|
+
if (options.storageBackend === "fs" && isWithin(paths.stateDir, paths.storageDir)) {
|
|
8554
|
+
await copyEntry(join7(payloadRoot, "storage"), join7(stateStage, relative2(paths.stateDir, paths.storageDir)));
|
|
8555
|
+
}
|
|
8556
|
+
const swaps = [];
|
|
8557
|
+
try {
|
|
8558
|
+
await applyDirectorySwap(stateStage, paths.stateDir, options.force === true, rollbackId, swaps);
|
|
8559
|
+
if (paths.dataDir && !isWithin(paths.stateDir, paths.dataDir)) {
|
|
8560
|
+
await applyDirectorySwap(join7(payloadRoot, "database"), paths.dataDir, options.force === true, rollbackId, swaps);
|
|
8561
|
+
}
|
|
8562
|
+
if (options.storageBackend === "fs" && !isWithin(paths.stateDir, paths.storageDir)) {
|
|
8563
|
+
await applyDirectorySwap(join7(payloadRoot, "storage"), paths.storageDir, options.force === true, rollbackId, swaps);
|
|
8564
|
+
}
|
|
8565
|
+
} catch (error) {
|
|
8566
|
+
await rollbackDirectorySwaps(swaps);
|
|
8567
|
+
throw error;
|
|
8568
|
+
}
|
|
8569
|
+
rollbackPaths.push(...swaps.flatMap((swap) => swap.rollbackPath ? [swap.rollbackPath] : []));
|
|
8570
|
+
if (process.platform !== "win32") {
|
|
8571
|
+
await hardenRestoredTree(paths.stateDir);
|
|
8572
|
+
if (paths.dataDir && !isWithin(paths.stateDir, paths.dataDir))
|
|
8573
|
+
await hardenRestoredTree(paths.dataDir);
|
|
8574
|
+
if (options.storageBackend === "fs" && !isWithin(paths.stateDir, paths.storageDir)) {
|
|
8575
|
+
await hardenRestoredTree(paths.storageDir);
|
|
8576
|
+
}
|
|
8577
|
+
}
|
|
8578
|
+
return { manifest, rollbackPaths };
|
|
8579
|
+
} catch (error) {
|
|
8580
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
8581
|
+
} finally {
|
|
8582
|
+
await rm3(stagingRoot, { recursive: true, force: true });
|
|
8583
|
+
}
|
|
8584
|
+
}
|
|
8585
|
+
function normalizePaths(paths) {
|
|
8586
|
+
return {
|
|
8587
|
+
...paths,
|
|
8588
|
+
projectDir: resolve3(paths.projectDir),
|
|
8589
|
+
stateDir: resolve3(paths.stateDir),
|
|
8590
|
+
dataDir: paths.dataDir ? resolve3(paths.dataDir) : undefined,
|
|
8591
|
+
storageDir: resolve3(paths.storageDir),
|
|
8592
|
+
secretsFile: resolve3(paths.secretsFile)
|
|
8593
|
+
};
|
|
8594
|
+
}
|
|
8595
|
+
async function assertSnapshotPaths(paths, options = {}) {
|
|
8596
|
+
try {
|
|
8597
|
+
if (paths.stateDir === parse2(paths.stateDir).root)
|
|
8598
|
+
throw new Error("snapshot state directory must not be the filesystem root");
|
|
8599
|
+
if (paths.secretsFile !== join7(paths.stateDir, "secrets.json"))
|
|
8600
|
+
throw new Error("snapshot secrets path must be inside the state directory");
|
|
8601
|
+
const stateInfo = await lstat2(paths.stateDir);
|
|
8602
|
+
if (!stateInfo.isDirectory() || stateInfo.isSymbolicLink())
|
|
8603
|
+
throw new Error(`state directory must be a real directory: ${paths.stateDir}`);
|
|
8604
|
+
} catch (error) {
|
|
8605
|
+
if (error.code !== "ENOENT" || options.allowMissingState !== true)
|
|
8606
|
+
throw error;
|
|
8607
|
+
}
|
|
8608
|
+
if (paths.dataDir && paths.storageDir && pathsOverlap(paths.dataDir, paths.storageDir)) {
|
|
8609
|
+
throw new Error("database and storage directories must not overlap");
|
|
8610
|
+
}
|
|
8611
|
+
await assertDirectoryOrMissing(paths.dataDir);
|
|
8612
|
+
await assertDirectoryOrMissing(paths.storageDir);
|
|
8613
|
+
if (options.requireSecrets !== false) {
|
|
8614
|
+
const secretInfo = await lstat2(paths.secretsFile);
|
|
8615
|
+
if (!secretInfo.isFile() || secretInfo.isSymbolicLink())
|
|
8616
|
+
throw new Error(`secrets file must be a regular file: ${paths.secretsFile}`);
|
|
8617
|
+
}
|
|
8618
|
+
}
|
|
8619
|
+
async function assertDirectoryOrMissing(path) {
|
|
8620
|
+
if (!path)
|
|
8621
|
+
return;
|
|
8622
|
+
if (resolve3(path) === parse2(resolve3(path)).root)
|
|
8623
|
+
throw new Error(`snapshot path must not be the filesystem root: ${path}`);
|
|
8624
|
+
try {
|
|
8625
|
+
const info = await lstat2(path);
|
|
8626
|
+
if (!info.isDirectory() || info.isSymbolicLink())
|
|
8627
|
+
throw new Error(`snapshot path must be a real directory: ${path}`);
|
|
8628
|
+
} catch (error) {
|
|
8629
|
+
if (error.code !== "ENOENT")
|
|
8630
|
+
throw error;
|
|
8631
|
+
}
|
|
8632
|
+
}
|
|
8633
|
+
async function assertNoDataDirectoryLock(paths) {
|
|
8634
|
+
if (!paths.dataDir)
|
|
8635
|
+
return;
|
|
8636
|
+
const lockPath = `${paths.dataDir}.supacloud-lite.lock`;
|
|
8637
|
+
try {
|
|
8638
|
+
await lstat2(lockPath);
|
|
8639
|
+
throw new Error(`data directory is in use or has a stale lock: ${lockPath}; stop Lite and remove the lock manually if it is stale`);
|
|
8640
|
+
} catch (error) {
|
|
8641
|
+
if (error.code !== "ENOENT")
|
|
8642
|
+
throw error;
|
|
8643
|
+
}
|
|
8644
|
+
}
|
|
8645
|
+
async function stageDirectory(root, destination) {
|
|
8646
|
+
try {
|
|
8647
|
+
const info = await lstat2(root);
|
|
8648
|
+
if (!info.isDirectory() || info.isSymbolicLink())
|
|
8649
|
+
throw new Error(`snapshot path must be a real directory: ${root}`);
|
|
8650
|
+
} catch (error) {
|
|
8651
|
+
if (error.code === "ENOENT") {
|
|
8652
|
+
await mkdir5(destination, { recursive: true });
|
|
8653
|
+
return;
|
|
8654
|
+
}
|
|
8655
|
+
throw error;
|
|
8656
|
+
}
|
|
8657
|
+
await mkdir5(destination, { recursive: true });
|
|
8658
|
+
const walk = async (current, target) => {
|
|
8659
|
+
for (const entry of await readdir3(current, { withFileTypes: true })) {
|
|
8660
|
+
const fullPath = join7(current, entry.name);
|
|
8661
|
+
const targetPath = join7(target, entry.name);
|
|
8662
|
+
if (entry.isSymbolicLink())
|
|
8663
|
+
throw new Error(`snapshot refuses symbolic link: ${fullPath}`);
|
|
8664
|
+
if (entry.isDirectory()) {
|
|
8665
|
+
await mkdir5(targetPath, { recursive: true });
|
|
8666
|
+
await walk(fullPath, targetPath);
|
|
8667
|
+
} else if (entry.isFile()) {
|
|
8668
|
+
await stageFile(fullPath, targetPath);
|
|
8669
|
+
} else {
|
|
8670
|
+
throw new Error(`snapshot refuses unsupported filesystem entry: ${fullPath}`);
|
|
8671
|
+
}
|
|
8672
|
+
}
|
|
8673
|
+
};
|
|
8674
|
+
await walk(root, destination);
|
|
8675
|
+
}
|
|
8676
|
+
async function stageFile(source, target) {
|
|
8677
|
+
await mkdir5(dirname4(target), { recursive: true });
|
|
8678
|
+
try {
|
|
8679
|
+
await link2(source, target);
|
|
8680
|
+
} catch (error) {
|
|
8681
|
+
const code = error.code;
|
|
8682
|
+
if (code !== "EXDEV" && code !== "EPERM" && code !== "EACCES")
|
|
8683
|
+
throw error;
|
|
8684
|
+
await copyFile(source, target);
|
|
8685
|
+
}
|
|
8686
|
+
}
|
|
8687
|
+
async function readManifest(payloadRoot) {
|
|
8688
|
+
let parsed;
|
|
8689
|
+
try {
|
|
8690
|
+
parsed = JSON.parse(await readFile7(join7(payloadRoot, "manifest.json"), "utf8"));
|
|
8691
|
+
} catch (error) {
|
|
8692
|
+
throw new Error(`invalid snapshot manifest: ${error instanceof Error ? error.message : String(error)}`);
|
|
8693
|
+
}
|
|
8694
|
+
if (!isSnapshotManifest(parsed))
|
|
8695
|
+
throw new Error("unsupported or invalid SupaCloud Lite snapshot manifest");
|
|
8696
|
+
return parsed;
|
|
8697
|
+
}
|
|
8698
|
+
function isSnapshotManifest(value) {
|
|
8699
|
+
if (!value || typeof value !== "object")
|
|
8700
|
+
return false;
|
|
8701
|
+
const candidate = value;
|
|
8702
|
+
return candidate.format === SNAPSHOT_FORMAT && candidate.version === SNAPSHOT_VERSION && typeof candidate.createdAt === "string" && typeof candidate.packageVersion === "string" && (candidate.storageBackend === "fs" || candidate.storageBackend === "s3" || candidate.storageBackend === "memory") && typeof candidate.includesDatabase === "boolean" && typeof candidate.includesLocalStorage === "boolean" && candidate.includesSecrets === true;
|
|
8703
|
+
}
|
|
8704
|
+
async function assertSnapshotPayload(payloadRoot, manifest) {
|
|
8705
|
+
const required = ["manifest.json", "secrets.json"];
|
|
8706
|
+
for (const path of required) {
|
|
8707
|
+
try {
|
|
8708
|
+
await lstat2(join7(payloadRoot, path));
|
|
8709
|
+
} catch {
|
|
8710
|
+
throw new Error(`snapshot is missing required payload: ${path}`);
|
|
8711
|
+
}
|
|
8712
|
+
}
|
|
8713
|
+
const allowed = [...required];
|
|
8714
|
+
if (manifest.includesDatabase)
|
|
8715
|
+
allowed.push("database");
|
|
8716
|
+
if (manifest.includesLocalStorage)
|
|
8717
|
+
allowed.push("storage");
|
|
8718
|
+
for (const entry of await readdir3(payloadRoot)) {
|
|
8719
|
+
if (!allowed.includes(entry)) {
|
|
8720
|
+
throw new Error(`snapshot contains an unexpected payload entry: ${entry}`);
|
|
8721
|
+
}
|
|
8722
|
+
}
|
|
8723
|
+
}
|
|
8724
|
+
async function assertRestoreTargets(paths, manifest, force) {
|
|
8725
|
+
const targets = [paths.stateDir];
|
|
8726
|
+
if (paths.dataDir && !isWithin(paths.stateDir, paths.dataDir))
|
|
8727
|
+
targets.push(paths.dataDir);
|
|
8728
|
+
if (manifest.includesLocalStorage && !isWithin(paths.stateDir, paths.storageDir))
|
|
8729
|
+
targets.push(paths.storageDir);
|
|
8730
|
+
if (!force) {
|
|
8731
|
+
for (const target of targets) {
|
|
8732
|
+
if (await directoryHasEntries(target))
|
|
8733
|
+
throw new Error(`restore target is not empty: ${target}; pass --force to replace it`);
|
|
8734
|
+
}
|
|
8735
|
+
}
|
|
8736
|
+
}
|
|
8737
|
+
async function directoryHasEntries(path) {
|
|
8738
|
+
try {
|
|
8739
|
+
return (await readdir3(path)).length > 0;
|
|
8740
|
+
} catch (error) {
|
|
8741
|
+
if (error.code === "ENOENT")
|
|
8742
|
+
return false;
|
|
8743
|
+
throw error;
|
|
8744
|
+
}
|
|
8745
|
+
}
|
|
8746
|
+
async function applyDirectorySwap(source, target, force, rollbackId, swaps) {
|
|
8747
|
+
const targetInfo = await existingInfo(target);
|
|
8748
|
+
if (targetInfo && !targetInfo.isDirectory())
|
|
8749
|
+
throw new Error(`restore target is not a directory: ${target}`);
|
|
8750
|
+
const swap = { target };
|
|
8751
|
+
if (targetInfo) {
|
|
8752
|
+
if (!force) {
|
|
8753
|
+
if (await directoryHasEntries(target))
|
|
8754
|
+
throw new Error(`restore target is not empty: ${target}; pass --force to replace it`);
|
|
8755
|
+
await rm3(target, { recursive: true, force: true });
|
|
8756
|
+
} else {
|
|
8757
|
+
swap.rollbackPath = join7(dirname4(target), `.${target.split(sep2).pop() ?? "state"}.restore-${rollbackId}`);
|
|
8758
|
+
await rename2(target, swap.rollbackPath);
|
|
8759
|
+
}
|
|
8760
|
+
}
|
|
8761
|
+
try {
|
|
8762
|
+
await mkdir5(dirname4(target), { recursive: true });
|
|
8763
|
+
await rename2(source, target);
|
|
8764
|
+
swaps.push(swap);
|
|
8765
|
+
} catch (error) {
|
|
8766
|
+
if (swap.rollbackPath)
|
|
8767
|
+
await rename2(swap.rollbackPath, target).catch(() => {});
|
|
8768
|
+
throw error;
|
|
8769
|
+
}
|
|
8770
|
+
}
|
|
8771
|
+
async function rollbackDirectorySwaps(swaps) {
|
|
8772
|
+
for (const swap of [...swaps].reverse()) {
|
|
8773
|
+
await rm3(swap.target, { recursive: true, force: true });
|
|
8774
|
+
if (swap.rollbackPath)
|
|
8775
|
+
await rename2(swap.rollbackPath, swap.target);
|
|
8776
|
+
}
|
|
8777
|
+
}
|
|
8778
|
+
async function existingInfo(path) {
|
|
8779
|
+
try {
|
|
8780
|
+
return await lstat2(path);
|
|
8781
|
+
} catch (error) {
|
|
8782
|
+
if (error.code === "ENOENT")
|
|
8783
|
+
return null;
|
|
8784
|
+
throw error;
|
|
8785
|
+
}
|
|
8786
|
+
}
|
|
8787
|
+
async function copyEntry(source, target) {
|
|
8788
|
+
const info = await lstat2(source);
|
|
8789
|
+
if (info.isSymbolicLink())
|
|
8790
|
+
throw new Error(`snapshot refuses symbolic link: ${source}`);
|
|
8791
|
+
if (info.isDirectory()) {
|
|
8792
|
+
await mkdir5(target, { recursive: true });
|
|
8793
|
+
for (const entry of await readdir3(source))
|
|
8794
|
+
await copyEntry(join7(source, entry), join7(target, entry));
|
|
8795
|
+
} else if (info.isFile()) {
|
|
8796
|
+
await mkdir5(dirname4(target), { recursive: true });
|
|
8797
|
+
await Bun.write(target, Bun.file(source));
|
|
8798
|
+
} else
|
|
8799
|
+
throw new Error(`snapshot refuses unsupported filesystem entry: ${source}`);
|
|
8800
|
+
}
|
|
8801
|
+
async function hardenRestoredTree(root) {
|
|
8802
|
+
const info = await lstat2(root);
|
|
8803
|
+
if (info.isSymbolicLink())
|
|
8804
|
+
throw new Error(`snapshot refuses symbolic link: ${root}`);
|
|
8805
|
+
if (info.isDirectory()) {
|
|
8806
|
+
await chmod2(root, 448);
|
|
8807
|
+
for (const entry of await readdir3(root))
|
|
8808
|
+
await hardenRestoredTree(join7(root, entry));
|
|
8809
|
+
return;
|
|
8810
|
+
}
|
|
8811
|
+
if (info.isFile()) {
|
|
8812
|
+
await chmod2(root, 384);
|
|
8813
|
+
return;
|
|
8814
|
+
}
|
|
8815
|
+
throw new Error(`snapshot refuses unsupported filesystem entry: ${root}`);
|
|
8816
|
+
}
|
|
8817
|
+
async function assertNoSymlinks(root) {
|
|
8818
|
+
for (const entry of await readdir3(root, { withFileTypes: true })) {
|
|
8819
|
+
const fullPath = join7(root, entry.name);
|
|
8820
|
+
if (entry.isSymbolicLink())
|
|
8821
|
+
throw new Error(`snapshot refuses symbolic link in archive: ${fullPath}`);
|
|
8822
|
+
if (entry.isDirectory())
|
|
8823
|
+
await assertNoSymlinks(fullPath);
|
|
8824
|
+
}
|
|
8825
|
+
}
|
|
8826
|
+
function isWithin(parent, child) {
|
|
8827
|
+
const normalizedParent = resolve3(parent);
|
|
8828
|
+
const normalizedChild = resolve3(child);
|
|
8829
|
+
return normalizedChild !== normalizedParent && normalizedChild.startsWith(`${normalizedParent}${sep2}`);
|
|
8830
|
+
}
|
|
8831
|
+
function pathsOverlap(left, right) {
|
|
8832
|
+
const normalizedLeft = resolve3(left);
|
|
8833
|
+
const normalizedRight = resolve3(right);
|
|
8834
|
+
return normalizedLeft === normalizedRight || isWithin(normalizedLeft, normalizedRight) || isWithin(normalizedRight, normalizedLeft);
|
|
8835
|
+
}
|
|
8450
8836
|
export {
|
|
8451
8837
|
verifyJwt,
|
|
8452
8838
|
startProjectServer,
|
|
8453
8839
|
signJwt,
|
|
8454
8840
|
serveBun,
|
|
8841
|
+
restoreSnapshot,
|
|
8455
8842
|
resolveStorageBackend,
|
|
8456
8843
|
resolveProjectPaths,
|
|
8457
8844
|
mintProjectKeys,
|
|
@@ -8459,6 +8846,7 @@ export {
|
|
|
8459
8846
|
generateTypes,
|
|
8460
8847
|
ensureProjectSecrets,
|
|
8461
8848
|
decodeJwt,
|
|
8849
|
+
createSnapshot,
|
|
8462
8850
|
createProjectBackend,
|
|
8463
8851
|
createPgliteEngine,
|
|
8464
8852
|
createBackend as createLiteBackend,
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { ConfiguredStorageBackend, ProjectPaths } from './project-runtime.js';
|
|
2
|
+
declare const SNAPSHOT_FORMAT = "supacloud-lite-snapshot";
|
|
3
|
+
declare const SNAPSHOT_VERSION = 1;
|
|
4
|
+
export interface SnapshotManifest {
|
|
5
|
+
format: typeof SNAPSHOT_FORMAT;
|
|
6
|
+
version: typeof SNAPSHOT_VERSION;
|
|
7
|
+
createdAt: string;
|
|
8
|
+
packageVersion: string;
|
|
9
|
+
storageBackend: ConfiguredStorageBackend;
|
|
10
|
+
includesDatabase: boolean;
|
|
11
|
+
includesLocalStorage: boolean;
|
|
12
|
+
includesSecrets: true;
|
|
13
|
+
}
|
|
14
|
+
export interface CreateSnapshotOptions {
|
|
15
|
+
paths: ProjectPaths;
|
|
16
|
+
packageVersion: string;
|
|
17
|
+
storageBackend: ConfiguredStorageBackend;
|
|
18
|
+
output: string;
|
|
19
|
+
}
|
|
20
|
+
export interface RestoreSnapshotOptions {
|
|
21
|
+
paths: ProjectPaths;
|
|
22
|
+
storageBackend: ConfiguredStorageBackend;
|
|
23
|
+
input: string;
|
|
24
|
+
force?: boolean;
|
|
25
|
+
}
|
|
26
|
+
export interface RestoreSnapshotResult {
|
|
27
|
+
manifest: SnapshotManifest;
|
|
28
|
+
rollbackPaths: string[];
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Create a portable, compressed snapshot of the durable Lite state.
|
|
32
|
+
* The caller must stop Lite first; the data-directory lock is checked here.
|
|
33
|
+
*/
|
|
34
|
+
export declare function createSnapshot(options: CreateSnapshotOptions): Promise<SnapshotManifest>;
|
|
35
|
+
/**
|
|
36
|
+
* Restore a snapshot without touching an existing non-empty target unless
|
|
37
|
+
* `force` is explicitly set. Existing targets are renamed aside for rollback.
|
|
38
|
+
*/
|
|
39
|
+
export declare function restoreSnapshot(options: RestoreSnapshotOptions): Promise<RestoreSnapshotResult>;
|
|
40
|
+
export {};
|
|
41
|
+
//# sourceMappingURL=snapshot.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"snapshot.d.ts","sourceRoot":"","sources":["../src/snapshot.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,wBAAwB,EAAE,YAAY,EAAE,MAAM,sBAAsB,CAAA;AAElF,QAAA,MAAM,eAAe,4BAA4B,CAAA;AACjD,QAAA,MAAM,gBAAgB,IAAI,CAAA;AAE1B,MAAM,WAAW,gBAAgB;IAC/B,MAAM,EAAE,OAAO,eAAe,CAAA;IAC9B,OAAO,EAAE,OAAO,gBAAgB,CAAA;IAChC,SAAS,EAAE,MAAM,CAAA;IACjB,cAAc,EAAE,MAAM,CAAA;IACtB,cAAc,EAAE,wBAAwB,CAAA;IACxC,gBAAgB,EAAE,OAAO,CAAA;IACzB,oBAAoB,EAAE,OAAO,CAAA;IAC7B,eAAe,EAAE,IAAI,CAAA;CACtB;AAED,MAAM,WAAW,qBAAqB;IACpC,KAAK,EAAE,YAAY,CAAA;IACnB,cAAc,EAAE,MAAM,CAAA;IACtB,cAAc,EAAE,wBAAwB,CAAA;IACxC,MAAM,EAAE,MAAM,CAAA;CACf;AAED,MAAM,WAAW,sBAAsB;IACrC,KAAK,EAAE,YAAY,CAAA;IACnB,cAAc,EAAE,wBAAwB,CAAA;IACxC,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,CAAC,EAAE,OAAO,CAAA;CAChB;AAED,MAAM,WAAW,qBAAqB;IACpC,QAAQ,EAAE,gBAAgB,CAAA;IAC1B,aAAa,EAAE,MAAM,EAAE,CAAA;CACxB;AAED;;;GAGG;AACH,wBAAsB,cAAc,CAAC,OAAO,EAAE,qBAAqB,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAqC9F;AAED;;;GAGG;AACH,wBAAsB,eAAe,CAAC,OAAO,EAAE,sBAAsB,GAAG,OAAO,CAAC,qBAAqB,CAAC,CA0FrG"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@supacloud/lite",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Bun-native, single-project Supabase-compatible backend powered by PGlite",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
],
|
|
25
25
|
"scripts": {
|
|
26
26
|
"build": "bun run build:js && bun run build:types",
|
|
27
|
-
"build:js": "bun build src/index.ts --target=bun --format=esm --outdir=dist --external @electric-sql/pglite && bun build src/cli.ts --target=bun --format=esm --outdir=dist --external @electric-sql/pglite",
|
|
27
|
+
"build:js": "bun build src/index.ts --target=bun --format=esm --outdir=dist --external @electric-sql/pglite --external tar && bun build src/cli.ts --target=bun --format=esm --outdir=dist --external @electric-sql/pglite --external tar",
|
|
28
28
|
"build:types": "bun x tsc --emitDeclarationOnly -p tsconfig.build.json",
|
|
29
29
|
"check": "bun run typecheck && bun run test && bun run build && bun run test:package",
|
|
30
30
|
"dev": "bun run src/cli.ts start",
|
|
@@ -35,10 +35,11 @@
|
|
|
35
35
|
"typecheck": "bun x tsc --noEmit -p tsconfig.json"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@electric-sql/pglite": "0.5.4"
|
|
38
|
+
"@electric-sql/pglite": "0.5.4",
|
|
39
|
+
"tar": "^7.5.22"
|
|
39
40
|
},
|
|
40
41
|
"devDependencies": {
|
|
41
|
-
"@supabase/supabase-js": "^2.
|
|
42
|
+
"@supabase/supabase-js": "^2.110.9",
|
|
42
43
|
"@types/bun": "^1.3.14",
|
|
43
44
|
"typescript": "^5.9.3"
|
|
44
45
|
},
|