@supacloud/lite 0.2.0 → 0.5.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 +38 -0
- package/LICENSES/BUN-1.3.14-RUNTIME-NOTICES.txt +78 -0
- package/LICENSES/CHOWNR-BLUEOAK-1.0.0.txt +63 -0
- package/LICENSES/FS-MINIPASS-ISC.txt +15 -0
- package/LICENSES/MINIPASS-BLUEOAK-1.0.0.txt +55 -0
- package/LICENSES/MINIZLIB-MIT.txt +26 -0
- package/LICENSES/NODE-TAR-BLUEOAK-1.0.0.txt +55 -0
- package/LICENSES/YALLIST-BLUEOAK-1.0.0.txt +63 -0
- package/README.md +142 -3
- package/RELEASING.md +14 -1
- package/THIRD_PARTY_NOTICES.md +44 -0
- package/dist/cli.js +965 -104
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +867 -80
- package/dist/launcher.cjs +22 -0
- package/dist/snapshot.d.ts +41 -0
- package/dist/snapshot.d.ts.map +1 -0
- package/dist/standalone-assets-protocol.d.ts +13 -0
- package/dist/standalone-assets-protocol.d.ts.map +1 -0
- package/dist/vendor/tinbase/auth/handler.d.ts +4 -0
- package/dist/vendor/tinbase/auth/handler.d.ts.map +1 -1
- package/dist/vendor/tinbase/db/database.d.ts +5 -1
- package/dist/vendor/tinbase/db/database.d.ts.map +1 -1
- package/dist/vendor/tinbase/db/emulated.d.ts +1 -1
- package/dist/vendor/tinbase/db/emulated.d.ts.map +1 -1
- package/dist/vendor/tinbase/db/pglite-engine.d.ts.map +1 -1
- package/dist/vendor/tinbase/functions/handler.d.ts +3 -1
- package/dist/vendor/tinbase/functions/handler.d.ts.map +1 -1
- package/dist/vendor/tinbase/functions/pgredis.d.ts +26 -0
- package/dist/vendor/tinbase/functions/pgredis.d.ts.map +1 -0
- package/dist/vendor/tinbase/index.d.ts +1 -0
- package/dist/vendor/tinbase/index.d.ts.map +1 -1
- package/dist/vendor/tinbase/retention/service.d.ts.map +1 -1
- package/package.json +14 -6
package/dist/index.js
CHANGED
|
@@ -71,12 +71,12 @@ function randomToken(bytes = 32) {
|
|
|
71
71
|
// package.json
|
|
72
72
|
var package_default = {
|
|
73
73
|
name: "@supacloud/lite",
|
|
74
|
-
version: "0.
|
|
74
|
+
version: "0.5.0",
|
|
75
75
|
description: "Bun-native, single-project Supabase-compatible backend powered by PGlite",
|
|
76
76
|
type: "module",
|
|
77
77
|
license: "Apache-2.0",
|
|
78
78
|
bin: {
|
|
79
|
-
"supacloud-lite": "dist/
|
|
79
|
+
"supacloud-lite": "dist/launcher.cjs"
|
|
80
80
|
},
|
|
81
81
|
exports: {
|
|
82
82
|
".": {
|
|
@@ -87,6 +87,7 @@ var package_default = {
|
|
|
87
87
|
},
|
|
88
88
|
files: [
|
|
89
89
|
"dist",
|
|
90
|
+
"!dist/standalone",
|
|
90
91
|
"README.md",
|
|
91
92
|
"CHANGELOG.md",
|
|
92
93
|
"RELEASING.md",
|
|
@@ -95,21 +96,28 @@ var package_default = {
|
|
|
95
96
|
],
|
|
96
97
|
scripts: {
|
|
97
98
|
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",
|
|
99
|
+
"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 && bun run scripts/build-launcher.ts",
|
|
100
|
+
"build:standalone:host": "bun run scripts/build-standalone.ts host",
|
|
101
|
+
"build:standalone:linux": "bun run scripts/build-standalone.ts linux-x64 linux-arm64",
|
|
102
|
+
"build:standalone:macos": "bun run scripts/build-standalone.ts macos-x64 macos-arm64",
|
|
103
|
+
"build:standalone:windows": "bun run scripts/build-standalone.ts windows-x64",
|
|
104
|
+
"build:standalone:release": "bun run scripts/build-standalone.ts linux-x64 linux-arm64 macos-x64 macos-arm64 windows-x64",
|
|
99
105
|
"build:types": "bun x tsc --emitDeclarationOnly -p tsconfig.build.json",
|
|
100
|
-
check: "bun run typecheck && bun run test && bun run build && bun run test:package",
|
|
106
|
+
check: "bun run typecheck && bun run test && bun run build && bun run test:package && bun run build:standalone:host && bun run test:standalone",
|
|
101
107
|
dev: "bun run src/cli.ts start",
|
|
102
108
|
start: "bun run src/cli.ts start",
|
|
103
109
|
test: "bun test --timeout 20000",
|
|
104
110
|
"test:package": "bun run scripts/package-smoke.ts",
|
|
111
|
+
"test:standalone": "bun run scripts/standalone-smoke.ts",
|
|
105
112
|
prepack: "bun run build",
|
|
106
113
|
typecheck: "bun x tsc --noEmit -p tsconfig.json"
|
|
107
114
|
},
|
|
108
115
|
dependencies: {
|
|
109
|
-
"@electric-sql/pglite": "0.5.4"
|
|
116
|
+
"@electric-sql/pglite": "0.5.4",
|
|
117
|
+
tar: "^7.5.22"
|
|
110
118
|
},
|
|
111
119
|
devDependencies: {
|
|
112
|
-
"@supabase/supabase-js": "^2.
|
|
120
|
+
"@supabase/supabase-js": "^2.110.9",
|
|
113
121
|
"@types/bun": "^1.3.14",
|
|
114
122
|
typescript: "^5.9.3"
|
|
115
123
|
},
|
|
@@ -1262,30 +1270,7 @@ class AuthHandler {
|
|
|
1262
1270
|
const token = body.refresh_token;
|
|
1263
1271
|
if (!token)
|
|
1264
1272
|
return authError(400, "validation_failed", "refresh_token required");
|
|
1265
|
-
|
|
1266
|
-
join auth.users u on u.id = rt.user_id
|
|
1267
|
-
where rt.token = $1`, [token]);
|
|
1268
|
-
const row = res.rows[0];
|
|
1269
|
-
if (!row || row.revoked) {
|
|
1270
|
-
return authError(400, "refresh_token_not_found", "Invalid Refresh Token: Refresh Token Not Found");
|
|
1271
|
-
}
|
|
1272
|
-
const now = Date.now();
|
|
1273
|
-
const lastActivity = timestampMs(row.created_at);
|
|
1274
|
-
if (this.config.sessionInactivitySeconds && lastActivity !== null && now - lastActivity >= this.config.sessionInactivitySeconds * 1000) {
|
|
1275
|
-
await this.db.query(`update auth.refresh_tokens set revoked = true, updated_at = now() where token = $1`, [token]);
|
|
1276
|
-
return authError(400, "session_expired", "Session expired due to inactivity");
|
|
1277
|
-
}
|
|
1278
|
-
const sessionStartedAt = row.session_id ? timestampMs((await this.db.query(`select min(created_at) as started_at from auth.refresh_tokens where session_id = $1`, [row.session_id])).rows[0]?.started_at ?? null) : lastActivity;
|
|
1279
|
-
if (this.config.sessionTimeboxSeconds && sessionStartedAt !== null && now - sessionStartedAt >= this.config.sessionTimeboxSeconds * 1000) {
|
|
1280
|
-
await this.db.query(`update auth.refresh_tokens set revoked = true, updated_at = now() where token = $1`, [token]);
|
|
1281
|
-
return authError(400, "session_expired", "Session expired");
|
|
1282
|
-
}
|
|
1283
|
-
await this.db.query(`update auth.refresh_tokens set revoked = true, updated_at = now() where token = $1`, [token]);
|
|
1284
|
-
const ures = await this.db.query(`select * from auth.users where id = $1`, [row.user_id]);
|
|
1285
|
-
return json(200, await this.sessionFor(ures.rows[0], token, {
|
|
1286
|
-
sessionId: row.session_id ?? undefined,
|
|
1287
|
-
sessionStartedAt: sessionStartedAt === null ? undefined : Math.floor(sessionStartedAt / 1000)
|
|
1288
|
-
}));
|
|
1273
|
+
return this.rotateRefreshToken(token);
|
|
1289
1274
|
}
|
|
1290
1275
|
if (grantType === "pkce") {
|
|
1291
1276
|
const authCode = body.auth_code;
|
|
@@ -1709,8 +1694,8 @@ Or sign in with this link: ${link}`
|
|
|
1709
1694
|
return authError(404, "mfa_factor_not_found", "MFA factor not found");
|
|
1710
1695
|
return json(200, { id: factorId });
|
|
1711
1696
|
}
|
|
1712
|
-
async getUserFactors(userId) {
|
|
1713
|
-
const res = await
|
|
1697
|
+
async getUserFactors(userId, query = (sql, params) => this.db.query(sql, params)) {
|
|
1698
|
+
const res = await query(`select id, friendly_name, factor_type, status, created_at, updated_at
|
|
1714
1699
|
from auth.mfa_factors where user_id = $1 order by created_at`, [userId]);
|
|
1715
1700
|
return res.rows.map((f) => ({
|
|
1716
1701
|
id: f.id,
|
|
@@ -1721,8 +1706,8 @@ Or sign in with this link: ${link}`
|
|
|
1721
1706
|
updated_at: iso(f.updated_at)
|
|
1722
1707
|
}));
|
|
1723
1708
|
}
|
|
1724
|
-
async getUserIdentities(userId) {
|
|
1725
|
-
const res = await
|
|
1709
|
+
async getUserIdentities(userId, query = (sql, params) => this.db.query(sql, params)) {
|
|
1710
|
+
const res = await query(`select id, provider_id, user_id, identity_data, provider, created_at, updated_at, last_sign_in_at
|
|
1726
1711
|
from auth.identities where user_id = $1 order by created_at`, [userId]);
|
|
1727
1712
|
return res.rows.map((r) => ({
|
|
1728
1713
|
identity_id: r.id,
|
|
@@ -1735,6 +1720,46 @@ Or sign in with this link: ${link}`
|
|
|
1735
1720
|
updated_at: iso(r.updated_at)
|
|
1736
1721
|
}));
|
|
1737
1722
|
}
|
|
1723
|
+
async rotateRefreshToken(token) {
|
|
1724
|
+
return this.db.transaction(async (query) => {
|
|
1725
|
+
const claimedToken = await this.claimRefreshToken(query, token);
|
|
1726
|
+
if (!claimedToken)
|
|
1727
|
+
return authError(400, "refresh_token_not_found", "Invalid Refresh Token: Refresh Token Not Found");
|
|
1728
|
+
const sessionStartedAt = await this.refreshSessionStartedAt(query, claimedToken);
|
|
1729
|
+
const expiryError = this.refreshExpiryError(claimedToken, sessionStartedAt);
|
|
1730
|
+
if (expiryError)
|
|
1731
|
+
return expiryError;
|
|
1732
|
+
const userResult = await query(`select * from auth.users where id = $1`, [claimedToken.user_id]);
|
|
1733
|
+
const session = await this.sessionFor(userResult.rows[0], token, {
|
|
1734
|
+
sessionId: claimedToken.session_id ?? undefined,
|
|
1735
|
+
sessionStartedAt: sessionStartedAt === null ? undefined : Math.floor(sessionStartedAt / 1000)
|
|
1736
|
+
}, query);
|
|
1737
|
+
return json(200, session);
|
|
1738
|
+
});
|
|
1739
|
+
}
|
|
1740
|
+
async claimRefreshToken(query, token) {
|
|
1741
|
+
const claimed = await query(`update auth.refresh_tokens set revoked = true, updated_at = now()
|
|
1742
|
+
where token = $1 and revoked is not true
|
|
1743
|
+
returning user_id, session_id, created_at`, [token]);
|
|
1744
|
+
return claimed.rows[0];
|
|
1745
|
+
}
|
|
1746
|
+
async refreshSessionStartedAt(query, refreshToken) {
|
|
1747
|
+
if (!refreshToken.session_id)
|
|
1748
|
+
return timestampMs(refreshToken.created_at);
|
|
1749
|
+
const sessionStart = await query(`select min(created_at) as started_at from auth.refresh_tokens where session_id = $1`, [refreshToken.session_id]);
|
|
1750
|
+
return timestampMs(sessionStart.rows[0]?.started_at ?? null);
|
|
1751
|
+
}
|
|
1752
|
+
refreshExpiryError(refreshToken, sessionStartedAt) {
|
|
1753
|
+
const now = Date.now();
|
|
1754
|
+
const lastActivity = timestampMs(refreshToken.created_at);
|
|
1755
|
+
if (this.config.sessionInactivitySeconds && lastActivity !== null && now - lastActivity >= this.config.sessionInactivitySeconds * 1000) {
|
|
1756
|
+
return authError(400, "session_expired", "Session expired due to inactivity");
|
|
1757
|
+
}
|
|
1758
|
+
if (this.config.sessionTimeboxSeconds && sessionStartedAt !== null && now - sessionStartedAt >= this.config.sessionTimeboxSeconds * 1000) {
|
|
1759
|
+
return authError(400, "session_expired", "Session expired");
|
|
1760
|
+
}
|
|
1761
|
+
return null;
|
|
1762
|
+
}
|
|
1738
1763
|
async userFromBearer(req) {
|
|
1739
1764
|
const claims = await this.claimsFromBearer(req);
|
|
1740
1765
|
if (!claims?.sub)
|
|
@@ -1772,7 +1797,7 @@ Or sign in with this link: ${link}`
|
|
|
1772
1797
|
const session = await this.sessionFor(res.rows[0]);
|
|
1773
1798
|
return { access_token: session.access_token, refresh_token: session.refresh_token, expires_in: session.expires_in };
|
|
1774
1799
|
}
|
|
1775
|
-
async sessionFor(user, parentToken, opts) {
|
|
1800
|
+
async sessionFor(user, parentToken, opts, query = (sql, params) => this.db.query(sql, params)) {
|
|
1776
1801
|
const now = Math.floor(Date.now() / 1000);
|
|
1777
1802
|
const timeboxRemaining = this.config.sessionTimeboxSeconds ? opts?.sessionStartedAt !== undefined ? opts.sessionStartedAt + this.config.sessionTimeboxSeconds - now : this.config.sessionTimeboxSeconds : this.config.jwtExpiry;
|
|
1778
1803
|
const lifetime = Math.max(1, Math.min(this.config.jwtExpiry, timeboxRemaining));
|
|
@@ -1796,14 +1821,14 @@ Or sign in with this link: ${link}`
|
|
|
1796
1821
|
};
|
|
1797
1822
|
const accessToken = await signJwt(claims, this.config.jwtSecret);
|
|
1798
1823
|
const refreshToken = randomToken(24);
|
|
1799
|
-
await
|
|
1824
|
+
await query(`insert into auth.refresh_tokens (token, user_id, parent, session_id) values ($1, $2, $3, $4)`, [refreshToken, user.id, parentToken ?? null, sessionId]);
|
|
1800
1825
|
return {
|
|
1801
1826
|
access_token: accessToken,
|
|
1802
1827
|
token_type: "bearer",
|
|
1803
1828
|
expires_in: lifetime,
|
|
1804
1829
|
expires_at: expiresAt,
|
|
1805
1830
|
refresh_token: refreshToken,
|
|
1806
|
-
user: this.userJson(user, await this.getUserFactors(user.id), await this.getUserIdentities(user.id))
|
|
1831
|
+
user: this.userJson(user, await this.getUserFactors(user.id, query), await this.getUserIdentities(user.id, query))
|
|
1807
1832
|
};
|
|
1808
1833
|
}
|
|
1809
1834
|
}
|
|
@@ -1989,13 +2014,182 @@ function resetCapturedHandler() {
|
|
|
1989
2014
|
captured.handler = undefined;
|
|
1990
2015
|
}
|
|
1991
2016
|
|
|
2017
|
+
// src/vendor/tinbase/functions/pgredis.ts
|
|
2018
|
+
import { AsyncLocalStorage as AsyncLocalStorage2 } from "async_hooks";
|
|
2019
|
+
var CACHE_NAMESPACE = "supacloud-edge-runtime";
|
|
2020
|
+
var CACHE_TABLE = "public.supacloud_pgredis_kv";
|
|
2021
|
+
var MAX_KEY_CHARACTERS = 512;
|
|
2022
|
+
var MAX_VALUE_BYTES = 1048576;
|
|
2023
|
+
var MAX_TTL_MS = 31536000000;
|
|
2024
|
+
var CACHE_SCHEMA_SQL = `
|
|
2025
|
+
create table if not exists ${CACHE_TABLE} (
|
|
2026
|
+
namespace text not null,
|
|
2027
|
+
key text not null,
|
|
2028
|
+
value jsonb not null,
|
|
2029
|
+
expires_at timestamptz,
|
|
2030
|
+
updated_at timestamptz not null default now(),
|
|
2031
|
+
primary key (namespace, key)
|
|
2032
|
+
);
|
|
2033
|
+
|
|
2034
|
+
create index if not exists supacloud_pgredis_kv_expires_at_idx
|
|
2035
|
+
on ${CACHE_TABLE} (expires_at) where expires_at is not null;
|
|
2036
|
+
`;
|
|
2037
|
+
var CACHE_PERMISSIONS_SQL = `
|
|
2038
|
+
revoke all on table ${CACHE_TABLE} from public, anon, authenticated, service_role;
|
|
2039
|
+
`;
|
|
2040
|
+
|
|
2041
|
+
class PgredisCache {
|
|
2042
|
+
db;
|
|
2043
|
+
constructor(db) {
|
|
2044
|
+
this.db = db;
|
|
2045
|
+
}
|
|
2046
|
+
static async create(db) {
|
|
2047
|
+
await db.exec(CACHE_SCHEMA_SQL);
|
|
2048
|
+
if (!db.engine.minimalBootstrap)
|
|
2049
|
+
await db.exec(CACHE_PERMISSIONS_SQL);
|
|
2050
|
+
return new PgredisCache(db);
|
|
2051
|
+
}
|
|
2052
|
+
async get(key) {
|
|
2053
|
+
assertCacheKey(key);
|
|
2054
|
+
return this.db.transaction(async (query) => {
|
|
2055
|
+
await deleteExpiredKey(query, key);
|
|
2056
|
+
const stored = await query(`select value::text as serialized_value from ${CACHE_TABLE} where namespace = $1 and key = $2`, [CACHE_NAMESPACE, key]);
|
|
2057
|
+
return stored.rows[0] ? parseStoredValue(stored.rows[0]) : null;
|
|
2058
|
+
});
|
|
2059
|
+
}
|
|
2060
|
+
async set(key, cacheValue, ttlMs) {
|
|
2061
|
+
assertCacheKey(key);
|
|
2062
|
+
assertTtl(ttlMs);
|
|
2063
|
+
const serializedValue = serializeCacheValue(cacheValue);
|
|
2064
|
+
await this.db.query(`insert into ${CACHE_TABLE} (namespace, key, value, expires_at, updated_at)
|
|
2065
|
+
values ($1, $2, $3::jsonb,
|
|
2066
|
+
case when $4::bigint is null then null else clock_timestamp() + $4::double precision * interval '1 millisecond' end,
|
|
2067
|
+
now())
|
|
2068
|
+
on conflict (namespace, key) do update set
|
|
2069
|
+
value = excluded.value, expires_at = excluded.expires_at, updated_at = now()`, [CACHE_NAMESPACE, key, serializedValue, ttlMs ?? null]);
|
|
2070
|
+
return true;
|
|
2071
|
+
}
|
|
2072
|
+
async delete(key) {
|
|
2073
|
+
assertCacheKey(key);
|
|
2074
|
+
return this.db.transaction(async (query) => {
|
|
2075
|
+
await deleteExpiredKey(query, key);
|
|
2076
|
+
const deleted = await query(`delete from ${CACHE_TABLE} where namespace = $1 and key = $2 returning key`, [CACHE_NAMESPACE, key]);
|
|
2077
|
+
return deleted.rows.length > 0;
|
|
2078
|
+
});
|
|
2079
|
+
}
|
|
2080
|
+
async ttl(key) {
|
|
2081
|
+
assertCacheKey(key);
|
|
2082
|
+
return this.db.transaction(async (query) => {
|
|
2083
|
+
await deleteExpiredKey(query, key);
|
|
2084
|
+
const stored = await query(`select case when expires_at is null then null
|
|
2085
|
+
else greatest(0, ceil(extract(epoch from (expires_at - clock_timestamp())) * 1000))::bigint
|
|
2086
|
+
end as ttl_ms from ${CACHE_TABLE} where namespace = $1 and key = $2`, [CACHE_NAMESPACE, key]);
|
|
2087
|
+
const ttlMs = stored.rows[0]?.ttl_ms;
|
|
2088
|
+
return ttlMs === null || ttlMs === undefined ? null : Number(ttlMs);
|
|
2089
|
+
});
|
|
2090
|
+
}
|
|
2091
|
+
async getset(key, cacheValue) {
|
|
2092
|
+
assertCacheKey(key);
|
|
2093
|
+
const serializedValue = serializeCacheValue(cacheValue);
|
|
2094
|
+
return this.db.transaction(async (query) => {
|
|
2095
|
+
await deleteExpiredKey(query, key);
|
|
2096
|
+
const previous = await query(`select value::text as serialized_value from ${CACHE_TABLE}
|
|
2097
|
+
where namespace = $1 and key = $2 for update`, [CACHE_NAMESPACE, key]);
|
|
2098
|
+
await upsertWithoutTtl(query, key, serializedValue);
|
|
2099
|
+
return previous.rows[0] ? parseStoredValue(previous.rows[0]) : null;
|
|
2100
|
+
});
|
|
2101
|
+
}
|
|
2102
|
+
async getdel(key) {
|
|
2103
|
+
assertCacheKey(key);
|
|
2104
|
+
return this.db.transaction(async (query) => {
|
|
2105
|
+
await deleteExpiredKey(query, key);
|
|
2106
|
+
const deleted = await query(`delete from ${CACHE_TABLE} where namespace = $1 and key = $2 returning value::text as serialized_value`, [CACHE_NAMESPACE, key]);
|
|
2107
|
+
return deleted.rows[0] ? parseStoredValue(deleted.rows[0]) : null;
|
|
2108
|
+
});
|
|
2109
|
+
}
|
|
2110
|
+
}
|
|
2111
|
+
var cacheContexts = new AsyncLocalStorage2;
|
|
2112
|
+
var cacheFacade = Object.freeze({
|
|
2113
|
+
get: async (key) => activeCache().get(key),
|
|
2114
|
+
set: async (key, cacheValue, ttlMs) => activeCache().set(key, cacheValue, ttlMs),
|
|
2115
|
+
delete: async (key) => activeCache().delete(key),
|
|
2116
|
+
ttl: async (key) => activeCache().ttl(key),
|
|
2117
|
+
getset: async (key, cacheValue) => activeCache().getset(key, cacheValue),
|
|
2118
|
+
getdel: async (key) => activeCache().getdel(key)
|
|
2119
|
+
});
|
|
2120
|
+
function installPgredisShim() {
|
|
2121
|
+
const runtime = globalThis;
|
|
2122
|
+
if (runtime.SupaCloud?.pgredis) {
|
|
2123
|
+
if (runtime.SupaCloud.pgredis === cacheFacade)
|
|
2124
|
+
return;
|
|
2125
|
+
throw new Error("globalThis.SupaCloud.pgredis is already owned by another runtime");
|
|
2126
|
+
}
|
|
2127
|
+
Object.defineProperty(runtime, "SupaCloud", {
|
|
2128
|
+
configurable: false,
|
|
2129
|
+
enumerable: true,
|
|
2130
|
+
value: Object.freeze({ ...runtime.SupaCloud ?? {}, pgredis: cacheFacade }),
|
|
2131
|
+
writable: false
|
|
2132
|
+
});
|
|
2133
|
+
}
|
|
2134
|
+
function runWithPgredisCache(cache, operation) {
|
|
2135
|
+
const context = { cache, active: true };
|
|
2136
|
+
return cacheContexts.run(context, async () => {
|
|
2137
|
+
try {
|
|
2138
|
+
return await operation();
|
|
2139
|
+
} finally {
|
|
2140
|
+
context.active = false;
|
|
2141
|
+
}
|
|
2142
|
+
});
|
|
2143
|
+
}
|
|
2144
|
+
function activeCache() {
|
|
2145
|
+
const context = cacheContexts.getStore();
|
|
2146
|
+
if (!context?.active)
|
|
2147
|
+
throw new Error("pgredis binding is unavailable outside a function request");
|
|
2148
|
+
return context.cache;
|
|
2149
|
+
}
|
|
2150
|
+
function assertCacheKey(key) {
|
|
2151
|
+
if (typeof key !== "string" || key.length < 1 || key.length > MAX_KEY_CHARACTERS) {
|
|
2152
|
+
throw new TypeError(`pgredis key must contain between 1 and ${MAX_KEY_CHARACTERS} characters`);
|
|
2153
|
+
}
|
|
2154
|
+
}
|
|
2155
|
+
function assertTtl(ttlMs) {
|
|
2156
|
+
if (ttlMs === null || ttlMs === undefined)
|
|
2157
|
+
return;
|
|
2158
|
+
if (!Number.isSafeInteger(ttlMs) || ttlMs < 0)
|
|
2159
|
+
throw new TypeError("pgredis ttlMs must be a non-negative safe integer or null");
|
|
2160
|
+
if (ttlMs > MAX_TTL_MS)
|
|
2161
|
+
throw new RangeError(`pgredis ttlMs must not exceed ${MAX_TTL_MS}`);
|
|
2162
|
+
}
|
|
2163
|
+
function serializeCacheValue(cacheValue) {
|
|
2164
|
+
const serializedValue = JSON.stringify(cacheValue);
|
|
2165
|
+
if (serializedValue === undefined)
|
|
2166
|
+
throw new TypeError("pgredis value must be JSON serializable");
|
|
2167
|
+
if (new TextEncoder().encode(serializedValue).byteLength > MAX_VALUE_BYTES) {
|
|
2168
|
+
throw new RangeError(`pgredis value must not exceed ${MAX_VALUE_BYTES} bytes`);
|
|
2169
|
+
}
|
|
2170
|
+
return serializedValue;
|
|
2171
|
+
}
|
|
2172
|
+
function parseStoredValue(stored) {
|
|
2173
|
+
return JSON.parse(stored.serialized_value);
|
|
2174
|
+
}
|
|
2175
|
+
async function deleteExpiredKey(query, key) {
|
|
2176
|
+
await query(`delete from ${CACHE_TABLE} where namespace = $1 and key = $2 and expires_at <= clock_timestamp()`, [CACHE_NAMESPACE, key]);
|
|
2177
|
+
}
|
|
2178
|
+
async function upsertWithoutTtl(query, key, serializedValue) {
|
|
2179
|
+
await query(`insert into ${CACHE_TABLE} (namespace, key, value, expires_at, updated_at)
|
|
2180
|
+
values ($1, $2, $3::jsonb, null, now())
|
|
2181
|
+
on conflict (namespace, key) do update set value = excluded.value, expires_at = null, updated_at = now()`, [CACHE_NAMESPACE, key, serializedValue]);
|
|
2182
|
+
}
|
|
2183
|
+
|
|
1992
2184
|
// src/vendor/tinbase/functions/handler.ts
|
|
1993
2185
|
class FunctionsHandler {
|
|
1994
2186
|
functions;
|
|
1995
2187
|
env;
|
|
1996
|
-
|
|
2188
|
+
pgredis;
|
|
2189
|
+
constructor(functions, env, pgredis) {
|
|
1997
2190
|
this.functions = functions;
|
|
1998
2191
|
this.env = env;
|
|
2192
|
+
this.pgredis = pgredis;
|
|
1999
2193
|
}
|
|
2000
2194
|
register(name, fn) {
|
|
2001
2195
|
this.functions.set(name, fn);
|
|
@@ -2013,7 +2207,8 @@ class FunctionsHandler {
|
|
|
2013
2207
|
return json2(404, { error: `function "${name}" not found` });
|
|
2014
2208
|
}
|
|
2015
2209
|
try {
|
|
2016
|
-
const
|
|
2210
|
+
const invoke = () => Promise.resolve(fn(req, { auth: ctx, env: this.env }));
|
|
2211
|
+
const res = await runWithDenoEnv(this.env, () => runWithPgredisCache(this.pgredis, invoke));
|
|
2017
2212
|
if (!(res instanceof Response)) {
|
|
2018
2213
|
return json2(500, { error: `function "${name}" did not return a Response` });
|
|
2019
2214
|
}
|
|
@@ -2555,7 +2750,24 @@ grant execute on function realtime.send(jsonb, text, text, boolean) to anon, aut
|
|
|
2555
2750
|
// src/vendor/tinbase/db/emulated.ts
|
|
2556
2751
|
var PGMQ_SQL = `
|
|
2557
2752
|
create schema if not exists pgmq;
|
|
2558
|
-
|
|
2753
|
+
revoke all on schema pgmq from public, anon, authenticated;
|
|
2754
|
+
grant usage on schema pgmq to service_role;
|
|
2755
|
+
|
|
2756
|
+
create table if not exists pgmq.meta (
|
|
2757
|
+
queue_name text primary key,
|
|
2758
|
+
physical_name text not null unique,
|
|
2759
|
+
is_partitioned boolean not null default false,
|
|
2760
|
+
is_unlogged boolean not null default false,
|
|
2761
|
+
created_at timestamptz not null default now()
|
|
2762
|
+
);
|
|
2763
|
+
|
|
2764
|
+
-- Preserve queues created by older Lite versions. Their physical names were
|
|
2765
|
+
-- the queue names, so only the already-truncated identifier can be recovered.
|
|
2766
|
+
insert into pgmq.meta (queue_name, physical_name)
|
|
2767
|
+
select substring(tablename from 3), substring(tablename from 3)
|
|
2768
|
+
from pg_tables
|
|
2769
|
+
where schemaname = 'pgmq' and left(tablename, 2) = 'q_'
|
|
2770
|
+
on conflict do nothing;
|
|
2559
2771
|
|
|
2560
2772
|
do $$ begin
|
|
2561
2773
|
if not exists (select 1 from pg_type t join pg_namespace n on n.oid = t.typnamespace
|
|
@@ -2566,32 +2778,78 @@ do $$ begin
|
|
|
2566
2778
|
end if;
|
|
2567
2779
|
end $$;
|
|
2568
2780
|
|
|
2781
|
+
create or replace function pgmq._normalize_queue_name(queue_name text)
|
|
2782
|
+
returns text language plpgsql immutable set search_path = pgmq, pg_catalog, public as $pgmq$
|
|
2783
|
+
declare normalized text := btrim(queue_name);
|
|
2784
|
+
begin
|
|
2785
|
+
if normalized is null or normalized !~ '^[a-z0-9][a-z0-9_-]{0,127}$' then
|
|
2786
|
+
raise exception 'invalid queue name: must match ^[a-z0-9][a-z0-9_-]{0,127}$'
|
|
2787
|
+
using errcode = '22023';
|
|
2788
|
+
end if;
|
|
2789
|
+
return normalized;
|
|
2790
|
+
end $pgmq$;
|
|
2791
|
+
|
|
2792
|
+
-- PostgreSQL identifiers are limited to 63 bytes. Keep the conventional table
|
|
2793
|
+
-- name for short queues and add a deterministic hash for valid 62-128 byte
|
|
2794
|
+
-- public names so truncation can never merge two queues.
|
|
2795
|
+
create or replace function pgmq._physical_queue_name(queue_name text)
|
|
2796
|
+
returns text language sql immutable set search_path = pgmq, pg_catalog, public as $pgmq$
|
|
2797
|
+
select case when length(normalized) <= 61 then normalized
|
|
2798
|
+
else left(normalized, 28) || '_' || md5(normalized) end
|
|
2799
|
+
from (select pgmq._normalize_queue_name(queue_name) as normalized) names;
|
|
2800
|
+
$pgmq$;
|
|
2801
|
+
|
|
2802
|
+
create or replace function pgmq._resolve_queue(queue_name text)
|
|
2803
|
+
returns text language plpgsql stable security definer set search_path = pgmq, pg_catalog, public as $pgmq$
|
|
2804
|
+
declare normalized text := pgmq._normalize_queue_name(queue_name); physical text;
|
|
2805
|
+
begin
|
|
2806
|
+
select physical_name into physical from pgmq.meta where pgmq.meta.queue_name = normalized;
|
|
2807
|
+
if physical is null then
|
|
2808
|
+
raise exception 'queue "%" does not exist', normalized using errcode = '42P01';
|
|
2809
|
+
end if;
|
|
2810
|
+
return physical;
|
|
2811
|
+
end $pgmq$;
|
|
2812
|
+
|
|
2569
2813
|
create or replace function pgmq.create(queue_name text) returns void language plpgsql security definer set search_path = pgmq, pg_catalog, public as $pgmq$
|
|
2814
|
+
declare normalized text := pgmq._normalize_queue_name(queue_name); physical text := pgmq._physical_queue_name(queue_name);
|
|
2570
2815
|
begin
|
|
2816
|
+
insert into pgmq.meta (queue_name, physical_name) values (normalized, physical)
|
|
2817
|
+
on conflict do nothing;
|
|
2818
|
+
select physical_name into physical from pgmq.meta where pgmq.meta.queue_name = normalized;
|
|
2571
2819
|
execute format('create table if not exists pgmq.%I (
|
|
2572
2820
|
msg_id bigint generated always as identity primary key,
|
|
2573
2821
|
read_ct integer not null default 0,
|
|
2574
2822
|
enqueued_at timestamptz not null default now(),
|
|
2575
2823
|
vt timestamptz not null default now(),
|
|
2576
|
-
message jsonb)', 'q_' ||
|
|
2824
|
+
message jsonb)', 'q_' || physical);
|
|
2577
2825
|
execute format('create table if not exists pgmq.%I (
|
|
2578
2826
|
msg_id bigint primary key, read_ct integer not null default 0,
|
|
2579
2827
|
enqueued_at timestamptz not null, archived_at timestamptz not null default now(),
|
|
2580
|
-
vt timestamptz, message jsonb)', 'a_' ||
|
|
2828
|
+
vt timestamptz, message jsonb)', 'a_' || physical);
|
|
2581
2829
|
end $pgmq$;
|
|
2582
2830
|
|
|
2583
2831
|
create or replace function pgmq.send(queue_name text, msg jsonb, delay integer default 0)
|
|
2584
2832
|
returns bigint language plpgsql security definer set search_path = pgmq, pg_catalog, public as $pgmq$
|
|
2585
|
-
declare id bigint;
|
|
2833
|
+
declare id bigint; physical text := pgmq._resolve_queue(queue_name);
|
|
2586
2834
|
begin
|
|
2587
|
-
|
|
2588
|
-
execute format('insert into pgmq.%I (vt, message) values (now() + make_interval(secs => $1), $2) returning msg_id', 'q_' || queue_name)
|
|
2835
|
+
execute format('insert into pgmq.%I (vt, message) values (now() + make_interval(secs => $1), $2) returning msg_id', 'q_' || physical)
|
|
2589
2836
|
into id using delay, msg;
|
|
2590
2837
|
return id;
|
|
2591
2838
|
end $pgmq$;
|
|
2592
2839
|
|
|
2840
|
+
create or replace function pgmq.send_batch(queue_name text, msgs jsonb[], delay integer default 0)
|
|
2841
|
+
returns setof bigint language plpgsql security definer set search_path = pgmq, pg_catalog, public as $pgmq$
|
|
2842
|
+
declare queue_message jsonb;
|
|
2843
|
+
begin
|
|
2844
|
+
perform pgmq._resolve_queue(queue_name);
|
|
2845
|
+
foreach queue_message in array msgs loop
|
|
2846
|
+
return next pgmq.send(queue_name, queue_message, delay);
|
|
2847
|
+
end loop;
|
|
2848
|
+
end $pgmq$;
|
|
2849
|
+
|
|
2593
2850
|
create or replace function pgmq.read(queue_name text, vt integer, qty integer)
|
|
2594
2851
|
returns setof pgmq.message_record language plpgsql security definer set search_path = pgmq, pg_catalog, public as $pgmq$
|
|
2852
|
+
declare physical text := pgmq._resolve_queue(queue_name);
|
|
2595
2853
|
begin
|
|
2596
2854
|
return query execute format($fmt$
|
|
2597
2855
|
with cte as (
|
|
@@ -2600,52 +2858,68 @@ begin
|
|
|
2600
2858
|
update pgmq.%I m set vt = now() + make_interval(secs => $2), read_ct = read_ct + 1
|
|
2601
2859
|
from cte where m.msg_id = cte.msg_id
|
|
2602
2860
|
returning m.msg_id, m.read_ct, m.enqueued_at, m.vt, m.message
|
|
2603
|
-
$fmt$, 'q_' ||
|
|
2861
|
+
$fmt$, 'q_' || physical, 'q_' || physical) using qty, vt;
|
|
2604
2862
|
end $pgmq$;
|
|
2605
2863
|
|
|
2606
2864
|
create or replace function pgmq.pop(queue_name text)
|
|
2607
2865
|
returns setof pgmq.message_record language plpgsql security definer set search_path = pgmq, pg_catalog, public as $pgmq$
|
|
2866
|
+
declare physical text := pgmq._resolve_queue(queue_name);
|
|
2608
2867
|
begin
|
|
2609
2868
|
return query execute format($fmt$
|
|
2610
2869
|
with cte as (select msg_id from pgmq.%I where vt <= now() order by msg_id limit 1 for update skip locked)
|
|
2611
2870
|
delete from pgmq.%I m using cte where m.msg_id = cte.msg_id
|
|
2612
2871
|
returning m.msg_id, m.read_ct, m.enqueued_at, m.vt, m.message
|
|
2613
|
-
$fmt$, 'q_' ||
|
|
2872
|
+
$fmt$, 'q_' || physical, 'q_' || physical);
|
|
2873
|
+
end $pgmq$;
|
|
2874
|
+
|
|
2875
|
+
create or replace function pgmq.set_vt(queue_name text, msg_id bigint, vt_offset integer)
|
|
2876
|
+
returns setof pgmq.message_record language plpgsql security definer set search_path = pgmq, pg_catalog, public as $pgmq$
|
|
2877
|
+
declare physical text := pgmq._resolve_queue(queue_name);
|
|
2878
|
+
begin
|
|
2879
|
+
return query execute format($fmt$
|
|
2880
|
+
update pgmq.%I m set vt = now() + make_interval(secs => $1)
|
|
2881
|
+
where m.msg_id = $2
|
|
2882
|
+
returning m.msg_id, m.read_ct, m.enqueued_at, m.vt, m.message
|
|
2883
|
+
$fmt$, 'q_' || physical) using vt_offset, msg_id;
|
|
2614
2884
|
end $pgmq$;
|
|
2615
2885
|
|
|
2616
2886
|
create or replace function pgmq.delete(queue_name text, msg_id bigint)
|
|
2617
2887
|
returns boolean language plpgsql security definer set search_path = pgmq, pg_catalog, public as $pgmq$
|
|
2618
|
-
declare n integer;
|
|
2888
|
+
declare n integer; physical text := pgmq._resolve_queue(queue_name);
|
|
2619
2889
|
begin
|
|
2620
|
-
execute format('delete from pgmq.%I where msg_id = $1', 'q_' ||
|
|
2890
|
+
execute format('delete from pgmq.%I where msg_id = $1', 'q_' || physical) using msg_id;
|
|
2621
2891
|
get diagnostics n = row_count;
|
|
2622
2892
|
return n > 0;
|
|
2623
2893
|
end $pgmq$;
|
|
2624
2894
|
|
|
2625
2895
|
create or replace function pgmq.archive(queue_name text, msg_id bigint)
|
|
2626
2896
|
returns boolean language plpgsql security definer set search_path = pgmq, pg_catalog, public as $pgmq$
|
|
2627
|
-
declare n integer;
|
|
2897
|
+
declare n integer; physical text := pgmq._resolve_queue(queue_name);
|
|
2628
2898
|
begin
|
|
2629
2899
|
execute format($fmt$
|
|
2630
2900
|
with del as (delete from pgmq.%I where msg_id = $1 returning *)
|
|
2631
2901
|
insert into pgmq.%I (msg_id, read_ct, enqueued_at, vt, message)
|
|
2632
2902
|
select msg_id, read_ct, enqueued_at, vt, message from del
|
|
2633
|
-
$fmt$, 'q_' ||
|
|
2903
|
+
$fmt$, 'q_' || physical, 'a_' || physical) using msg_id;
|
|
2634
2904
|
get diagnostics n = row_count;
|
|
2635
2905
|
return n > 0;
|
|
2636
2906
|
end $pgmq$;
|
|
2637
2907
|
|
|
2638
2908
|
create or replace function pgmq.drop_queue(queue_name text) returns boolean language plpgsql security definer set search_path = pgmq, pg_catalog, public as $pgmq$
|
|
2909
|
+
declare normalized text := pgmq._normalize_queue_name(queue_name); physical text;
|
|
2639
2910
|
begin
|
|
2640
|
-
|
|
2641
|
-
|
|
2911
|
+
select physical_name into physical from pgmq.meta where pgmq.meta.queue_name = normalized;
|
|
2912
|
+
if physical is null then return false; end if;
|
|
2913
|
+
execute format('drop table if exists pgmq.%I', 'q_' || physical);
|
|
2914
|
+
execute format('drop table if exists pgmq.%I', 'a_' || physical);
|
|
2915
|
+
delete from pgmq.meta where pgmq.meta.queue_name = normalized;
|
|
2642
2916
|
return true;
|
|
2643
2917
|
end $pgmq$;
|
|
2644
2918
|
|
|
2645
2919
|
create or replace function pgmq.purge_queue(queue_name text) returns bigint language plpgsql security definer set search_path = pgmq, pg_catalog, public as $pgmq$
|
|
2646
|
-
declare n bigint;
|
|
2920
|
+
declare n bigint; physical text := pgmq._resolve_queue(queue_name);
|
|
2647
2921
|
begin
|
|
2648
|
-
execute format('delete from pgmq.%I', 'q_' ||
|
|
2922
|
+
execute format('delete from pgmq.%I', 'q_' || physical);
|
|
2649
2923
|
get diagnostics n = row_count;
|
|
2650
2924
|
return n;
|
|
2651
2925
|
end $pgmq$;
|
|
@@ -2653,11 +2927,47 @@ end $pgmq$;
|
|
|
2653
2927
|
create or replace function pgmq.list_queues()
|
|
2654
2928
|
returns table(queue_name text, is_partitioned boolean, is_unlogged boolean, created_at timestamptz)
|
|
2655
2929
|
language sql security definer set search_path = pgmq, pg_catalog, public as $pgmq$
|
|
2656
|
-
select
|
|
2657
|
-
from pg_tables where schemaname = 'pgmq' and left(tablename, 2) = 'q_';
|
|
2930
|
+
select queue_name, is_partitioned, is_unlogged, created_at from pgmq.meta;
|
|
2658
2931
|
$pgmq$;
|
|
2659
2932
|
|
|
2660
|
-
|
|
2933
|
+
revoke all on all functions in schema pgmq from public, anon, authenticated;
|
|
2934
|
+
grant execute on all functions in schema pgmq to service_role;
|
|
2935
|
+
|
|
2936
|
+
create schema if not exists pgmq_public;
|
|
2937
|
+
grant usage on schema pgmq_public to anon, authenticated, service_role;
|
|
2938
|
+
|
|
2939
|
+
create or replace function pgmq_public.send(queue_name text, message jsonb, sleep_seconds integer default 0)
|
|
2940
|
+
returns setof bigint language sql volatile security definer set search_path = pgmq, pg_catalog, public as $pgmq_public$
|
|
2941
|
+
select * from pgmq.send(queue_name, message, sleep_seconds);
|
|
2942
|
+
$pgmq_public$;
|
|
2943
|
+
|
|
2944
|
+
create or replace function pgmq_public.send_batch(queue_name text, messages jsonb[], sleep_seconds integer default 0)
|
|
2945
|
+
returns setof bigint language sql volatile security definer set search_path = pgmq, pg_catalog, public as $pgmq_public$
|
|
2946
|
+
select * from pgmq.send_batch(queue_name, messages, sleep_seconds);
|
|
2947
|
+
$pgmq_public$;
|
|
2948
|
+
|
|
2949
|
+
create or replace function pgmq_public.read(queue_name text, sleep_seconds integer, n integer)
|
|
2950
|
+
returns setof pgmq.message_record language sql volatile security definer set search_path = pgmq, pg_catalog, public as $pgmq_public$
|
|
2951
|
+
select * from pgmq.read(queue_name, sleep_seconds, n);
|
|
2952
|
+
$pgmq_public$;
|
|
2953
|
+
|
|
2954
|
+
create or replace function pgmq_public.pop(queue_name text)
|
|
2955
|
+
returns setof pgmq.message_record language sql volatile security definer set search_path = pgmq, pg_catalog, public as $pgmq_public$
|
|
2956
|
+
select * from pgmq.pop(queue_name);
|
|
2957
|
+
$pgmq_public$;
|
|
2958
|
+
|
|
2959
|
+
create or replace function pgmq_public.archive(queue_name text, message_id bigint)
|
|
2960
|
+
returns boolean language sql volatile security definer set search_path = pgmq, pg_catalog, public as $pgmq_public$
|
|
2961
|
+
select pgmq.archive(queue_name, message_id);
|
|
2962
|
+
$pgmq_public$;
|
|
2963
|
+
|
|
2964
|
+
create or replace function pgmq_public."delete"(queue_name text, message_id bigint)
|
|
2965
|
+
returns boolean language sql volatile security definer set search_path = pgmq, pg_catalog, public as $pgmq_public$
|
|
2966
|
+
select pgmq.delete(queue_name, message_id);
|
|
2967
|
+
$pgmq_public$;
|
|
2968
|
+
|
|
2969
|
+
revoke all on all functions in schema pgmq_public from public;
|
|
2970
|
+
grant execute on all functions in schema pgmq_public to anon, authenticated, service_role;
|
|
2661
2971
|
`;
|
|
2662
2972
|
var CRON_SQL = `
|
|
2663
2973
|
create schema if not exists cron;
|
|
@@ -2987,9 +3297,16 @@ function pickTag(text, base) {
|
|
|
2987
3297
|
// src/vendor/tinbase/db/pglite-engine.ts
|
|
2988
3298
|
import { mkdir, open, readFile, unlink } from "fs/promises";
|
|
2989
3299
|
import { dirname, resolve } from "path";
|
|
3300
|
+
|
|
3301
|
+
// src/standalone-assets-protocol.ts
|
|
3302
|
+
var STANDALONE_PGLITE_ASSETS = Symbol.for("supacloud-lite.pglite-standalone-assets");
|
|
3303
|
+
|
|
3304
|
+
// src/vendor/tinbase/db/pglite-engine.ts
|
|
2990
3305
|
async function createPgliteEngine(dataDir) {
|
|
2991
3306
|
const releaseLock = await acquireDataDirLock(dataDir);
|
|
2992
3307
|
let PGlite, extensions;
|
|
3308
|
+
const standaloneAssets = getStandaloneAssets();
|
|
3309
|
+
let cleanupStandaloneBundles = async () => {};
|
|
2993
3310
|
try {
|
|
2994
3311
|
({ PGlite } = await import("@electric-sql/pglite"));
|
|
2995
3312
|
const [uuid_ossp, pgcrypto, citext, pg_trgm, ltree, hstore, fuzzystrmatch] = await Promise.all([
|
|
@@ -3002,19 +3319,48 @@ async function createPgliteEngine(dataDir) {
|
|
|
3002
3319
|
import("@electric-sql/pglite/contrib/fuzzystrmatch").then((m) => m.fuzzystrmatch)
|
|
3003
3320
|
]);
|
|
3004
3321
|
extensions = { uuid_ossp, pgcrypto, citext, pg_trgm, ltree, hstore, fuzzystrmatch };
|
|
3005
|
-
} catch (
|
|
3322
|
+
} catch (error) {
|
|
3006
3323
|
await releaseLock();
|
|
3007
|
-
if (
|
|
3008
|
-
throw
|
|
3324
|
+
if (error instanceof Error && /wasm/.test(error.message))
|
|
3325
|
+
throw error;
|
|
3009
3326
|
throw new Error("the PGlite WASM engine is not available in this build");
|
|
3010
3327
|
}
|
|
3328
|
+
if (standaloneAssets) {
|
|
3329
|
+
try {
|
|
3330
|
+
const standaloneBundles = await standaloneAssets.prepareExtensionBundles();
|
|
3331
|
+
cleanupStandaloneBundles = standaloneBundles.cleanup;
|
|
3332
|
+
extensions = {
|
|
3333
|
+
uuid_ossp: withEmbeddedBundle(extensions.uuid_ossp, standaloneBundles.bundles.uuid_ossp),
|
|
3334
|
+
pgcrypto: withEmbeddedBundle(extensions.pgcrypto, standaloneBundles.bundles.pgcrypto),
|
|
3335
|
+
citext: withEmbeddedBundle(extensions.citext, standaloneBundles.bundles.citext),
|
|
3336
|
+
pg_trgm: withEmbeddedBundle(extensions.pg_trgm, standaloneBundles.bundles.pg_trgm),
|
|
3337
|
+
ltree: withEmbeddedBundle(extensions.ltree, standaloneBundles.bundles.ltree),
|
|
3338
|
+
hstore: withEmbeddedBundle(extensions.hstore, standaloneBundles.bundles.hstore),
|
|
3339
|
+
fuzzystrmatch: withEmbeddedBundle(extensions.fuzzystrmatch, standaloneBundles.bundles.fuzzystrmatch)
|
|
3340
|
+
};
|
|
3341
|
+
} catch (error) {
|
|
3342
|
+
await removePreparedBundles(cleanupStandaloneBundles);
|
|
3343
|
+
await releaseLock();
|
|
3344
|
+
throw error;
|
|
3345
|
+
}
|
|
3346
|
+
}
|
|
3011
3347
|
let pg;
|
|
3012
3348
|
try {
|
|
3013
|
-
pg = new PGlite({
|
|
3349
|
+
pg = new PGlite({
|
|
3350
|
+
dataDir,
|
|
3351
|
+
extensions,
|
|
3352
|
+
...standaloneAssets ? {
|
|
3353
|
+
pgliteWasmModule: standaloneAssets.pgliteWasmModule,
|
|
3354
|
+
initdbWasmModule: standaloneAssets.initdbWasmModule,
|
|
3355
|
+
fsBundle: standaloneAssets.fsBundle
|
|
3356
|
+
} : {}
|
|
3357
|
+
});
|
|
3014
3358
|
await pg.waitReady;
|
|
3015
3359
|
} catch (error) {
|
|
3016
3360
|
await releaseLock();
|
|
3017
3361
|
throw error;
|
|
3362
|
+
} finally {
|
|
3363
|
+
await removePreparedBundles(cleanupStandaloneBundles);
|
|
3018
3364
|
}
|
|
3019
3365
|
let closed = false;
|
|
3020
3366
|
return {
|
|
@@ -3053,6 +3399,25 @@ async function createPgliteEngine(dataDir) {
|
|
|
3053
3399
|
}
|
|
3054
3400
|
};
|
|
3055
3401
|
}
|
|
3402
|
+
function getStandaloneAssets() {
|
|
3403
|
+
return globalThis[STANDALONE_PGLITE_ASSETS];
|
|
3404
|
+
}
|
|
3405
|
+
function withEmbeddedBundle(extension, bundlePath) {
|
|
3406
|
+
return {
|
|
3407
|
+
...extension,
|
|
3408
|
+
setup: async (pg, emscriptenOpts, clientOnly) => ({
|
|
3409
|
+
...await extension.setup(pg, emscriptenOpts, clientOnly),
|
|
3410
|
+
bundlePath
|
|
3411
|
+
})
|
|
3412
|
+
};
|
|
3413
|
+
}
|
|
3414
|
+
async function removePreparedBundles(cleanup) {
|
|
3415
|
+
try {
|
|
3416
|
+
await cleanup();
|
|
3417
|
+
} catch (error) {
|
|
3418
|
+
console.error("Unable to remove temporary PGlite extension bundles:", error);
|
|
3419
|
+
}
|
|
3420
|
+
}
|
|
3056
3421
|
async function acquireDataDirLock(dataDir) {
|
|
3057
3422
|
if (!dataDir || dataDir.includes("://"))
|
|
3058
3423
|
return async () => {};
|
|
@@ -3060,21 +3425,7 @@ async function acquireDataDirLock(dataDir) {
|
|
|
3060
3425
|
const lockPath = `${absoluteDataDir}.supacloud-lite.lock`;
|
|
3061
3426
|
await mkdir(dirname(absoluteDataDir), { recursive: true, mode: 448 });
|
|
3062
3427
|
const nonce = crypto.randomUUID();
|
|
3063
|
-
|
|
3064
|
-
try {
|
|
3065
|
-
handle = await open(lockPath, "wx", 384);
|
|
3066
|
-
await handle.writeFile(`${JSON.stringify({ pid: process.pid, nonce, createdAt: new Date().toISOString() })}
|
|
3067
|
-
`);
|
|
3068
|
-
} catch (error) {
|
|
3069
|
-
await handle?.close().catch(() => {});
|
|
3070
|
-
if (error.code !== "EEXIST")
|
|
3071
|
-
throw error;
|
|
3072
|
-
const owner = await readLockOwner(lockPath);
|
|
3073
|
-
if (owner && isProcessAlive(owner.pid)) {
|
|
3074
|
-
throw new Error(`PGlite data directory is already in use: ${absoluteDataDir} (pid ${owner.pid})`);
|
|
3075
|
-
}
|
|
3076
|
-
throw new Error(`PGlite data directory has a stale or unreadable lock: ${lockPath}. Confirm no SupaCloud Lite process is using it, then remove the lock manually.`);
|
|
3077
|
-
}
|
|
3428
|
+
const handle = await createDataDirLock(absoluteDataDir, lockPath, nonce);
|
|
3078
3429
|
let released = false;
|
|
3079
3430
|
return async () => {
|
|
3080
3431
|
if (released)
|
|
@@ -3090,10 +3441,48 @@ async function acquireDataDirLock(dataDir) {
|
|
|
3090
3441
|
});
|
|
3091
3442
|
};
|
|
3092
3443
|
}
|
|
3444
|
+
async function createDataDirLock(absoluteDataDir, lockPath, nonce) {
|
|
3445
|
+
for (let attempt = 0;attempt < 2; attempt++) {
|
|
3446
|
+
try {
|
|
3447
|
+
return await writeDataDirLock(lockPath, nonce);
|
|
3448
|
+
} catch (error) {
|
|
3449
|
+
if (error.code !== "EEXIST")
|
|
3450
|
+
throw error;
|
|
3451
|
+
const owner = await readLockOwner(lockPath);
|
|
3452
|
+
if (owner && isProcessAlive(owner.pid))
|
|
3453
|
+
throw lockInUseError(absoluteDataDir, owner.pid);
|
|
3454
|
+
if (!owner || attempt > 0)
|
|
3455
|
+
throw unreadableLockError(lockPath);
|
|
3456
|
+
await unlink(lockPath).catch((unlinkError) => {
|
|
3457
|
+
if (unlinkError.code !== "ENOENT")
|
|
3458
|
+
throw unlinkError;
|
|
3459
|
+
});
|
|
3460
|
+
}
|
|
3461
|
+
}
|
|
3462
|
+
throw unreadableLockError(lockPath);
|
|
3463
|
+
}
|
|
3464
|
+
async function writeDataDirLock(lockPath, nonce) {
|
|
3465
|
+
const handle = await open(lockPath, "wx", 384);
|
|
3466
|
+
try {
|
|
3467
|
+
await handle.writeFile(`${JSON.stringify({ pid: process.pid, nonce, createdAt: new Date().toISOString() })}
|
|
3468
|
+
`);
|
|
3469
|
+
return handle;
|
|
3470
|
+
} catch (error) {
|
|
3471
|
+
await handle.close().catch(() => {});
|
|
3472
|
+
await unlink(lockPath).catch(() => {});
|
|
3473
|
+
throw error;
|
|
3474
|
+
}
|
|
3475
|
+
}
|
|
3476
|
+
function lockInUseError(dataDir, pid) {
|
|
3477
|
+
return new Error(`PGlite data directory is already in use: ${dataDir} (pid ${pid})`);
|
|
3478
|
+
}
|
|
3479
|
+
function unreadableLockError(lockPath) {
|
|
3480
|
+
return new Error(`PGlite data directory has an unreadable lock: ${lockPath}. Confirm no SupaCloud Lite process is using it, then remove the lock manually.`);
|
|
3481
|
+
}
|
|
3093
3482
|
async function readLockOwner(lockPath) {
|
|
3094
3483
|
try {
|
|
3095
3484
|
const value = JSON.parse(await readFile(lockPath, "utf8"));
|
|
3096
|
-
return typeof value.pid === "number" && Number.isInteger(value.pid) && typeof value.nonce === "string" ? { pid: value.pid, nonce: value.nonce } : null;
|
|
3485
|
+
return typeof value.pid === "number" && Number.isInteger(value.pid) && value.pid > 0 && typeof value.nonce === "string" ? { pid: value.pid, nonce: value.nonce } : null;
|
|
3097
3486
|
} catch {
|
|
3098
3487
|
return null;
|
|
3099
3488
|
}
|
|
@@ -3142,6 +3531,9 @@ class Database {
|
|
|
3142
3531
|
exec(sql) {
|
|
3143
3532
|
return this.engine.exec(sql);
|
|
3144
3533
|
}
|
|
3534
|
+
transaction(fn) {
|
|
3535
|
+
return this.engine.transaction((tx) => fn((sql, params) => tx.query(sql, params)));
|
|
3536
|
+
}
|
|
3145
3537
|
async withContext(ctx, fn) {
|
|
3146
3538
|
return this.engine.transaction(async (tx) => {
|
|
3147
3539
|
await tx.query(`select set_config('role', $1, true),
|
|
@@ -6910,6 +7302,7 @@ class RetentionService {
|
|
|
6910
7302
|
await this.run(`delete from auth.one_time_tokens where expires_at < now()`);
|
|
6911
7303
|
await this.run(`delete from auth.mfa_challenges where expires_at < now()`);
|
|
6912
7304
|
await this.run(`delete from auth.flow_state where expires_at < now()`);
|
|
7305
|
+
await this.run(`delete from public.supacloud_pgredis_kv where expires_at <= now()`);
|
|
6913
7306
|
if (this.refreshTokenDays > 0) {
|
|
6914
7307
|
const cutoff = new Date(now.getTime() - this.refreshTokenDays * DAY_MS).toISOString();
|
|
6915
7308
|
await this.run(`delete from auth.refresh_tokens where revoked = true and updated_at < $1`, [cutoff]);
|
|
@@ -7232,11 +7625,13 @@ async function createBackend(config = {}) {
|
|
|
7232
7625
|
} catch (e) {
|
|
7233
7626
|
await failStartup(e);
|
|
7234
7627
|
}
|
|
7628
|
+
const pgredis = await PgredisCache.create(db).catch(failStartup);
|
|
7235
7629
|
const now = Math.floor(Date.now() / 1000);
|
|
7236
7630
|
const tenYears = 10 * 365 * 24 * 3600;
|
|
7237
7631
|
const anonKey = await signJwt({ iss: "supabase", ref: "local", role: "anon", iat: now, exp: now + tenYears }, jwtSecret);
|
|
7238
7632
|
const serviceRoleKey = await signJwt({ iss: "supabase", ref: "local", role: "service_role", iat: now, exp: now + tenYears }, jwtSecret);
|
|
7239
|
-
const
|
|
7633
|
+
const exposedSchemas = [...new Set(config.dbSchemas ?? ["public", "pgmq_public"])];
|
|
7634
|
+
const rest = new RestHandler(db, { exposedSchemas, maxRows: config.maxRows });
|
|
7240
7635
|
const exposed = isNetworkExposed(config.host);
|
|
7241
7636
|
const inbox = config.mailer || exposed ? null : new InboxMailer((msg) => log(config.logMailBody ? `[mail] to=${msg.to} subject="${msg.subject}"
|
|
7242
7637
|
${msg.text}` : `[mail] to=${msg.to} subject="${msg.subject}"`));
|
|
@@ -7297,7 +7692,12 @@ ${msg.text}` : `[mail] to=${msg.to} subject="${msg.subject}"`));
|
|
|
7297
7692
|
...config.functionEnv ?? {}
|
|
7298
7693
|
};
|
|
7299
7694
|
installDenoShim();
|
|
7300
|
-
|
|
7695
|
+
try {
|
|
7696
|
+
installPgredisShim();
|
|
7697
|
+
} catch (error) {
|
|
7698
|
+
await failStartup(error);
|
|
7699
|
+
}
|
|
7700
|
+
const functions = new FunctionsHandler(fnMap, fnEnv, pgredis);
|
|
7301
7701
|
async function resolveContext(req, url) {
|
|
7302
7702
|
const authz = req.headers.get("authorization");
|
|
7303
7703
|
const bearer = authz?.toLowerCase().startsWith("bearer ") ? authz.slice(7) : null;
|
|
@@ -8447,11 +8847,397 @@ async function findEphemeralPort(host = "127.0.0.1") {
|
|
|
8447
8847
|
throw new Error("Bun did not allocate an ephemeral port");
|
|
8448
8848
|
return port;
|
|
8449
8849
|
}
|
|
8850
|
+
// src/snapshot.ts
|
|
8851
|
+
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";
|
|
8852
|
+
import { dirname as dirname4, join as join7, parse as parse2, relative as relative2, resolve as resolve3, sep as sep2 } from "path";
|
|
8853
|
+
import { create as createTar, extract as extractTar } from "tar";
|
|
8854
|
+
var SNAPSHOT_FORMAT = "supacloud-lite-snapshot";
|
|
8855
|
+
var SNAPSHOT_VERSION = 1;
|
|
8856
|
+
async function createSnapshot(options) {
|
|
8857
|
+
const paths = normalizePaths(options.paths);
|
|
8858
|
+
await assertSnapshotPaths(paths);
|
|
8859
|
+
await assertNoDataDirectoryLock(paths);
|
|
8860
|
+
const manifest = {
|
|
8861
|
+
format: SNAPSHOT_FORMAT,
|
|
8862
|
+
version: SNAPSHOT_VERSION,
|
|
8863
|
+
createdAt: new Date().toISOString(),
|
|
8864
|
+
packageVersion: options.packageVersion,
|
|
8865
|
+
storageBackend: options.storageBackend,
|
|
8866
|
+
includesDatabase: Boolean(paths.dataDir),
|
|
8867
|
+
includesLocalStorage: options.storageBackend === "fs",
|
|
8868
|
+
includesSecrets: true
|
|
8869
|
+
};
|
|
8870
|
+
const output = resolve3(options.output);
|
|
8871
|
+
if (await existingInfo(output))
|
|
8872
|
+
throw new Error(`snapshot output already exists: ${output}`);
|
|
8873
|
+
await mkdir5(dirname4(output), { recursive: true });
|
|
8874
|
+
const stagingRoot = await mkdtemp(join7(dirname4(output), ".supacloud-lite-snapshot-"));
|
|
8875
|
+
try {
|
|
8876
|
+
await writeFile4(join7(stagingRoot, "manifest.json"), `${JSON.stringify(manifest, null, 2)}
|
|
8877
|
+
`);
|
|
8878
|
+
await stageFile(paths.secretsFile, join7(stagingRoot, "secrets.json"));
|
|
8879
|
+
if (paths.dataDir)
|
|
8880
|
+
await stageDirectory(paths.dataDir, join7(stagingRoot, "database"));
|
|
8881
|
+
if (options.storageBackend === "fs")
|
|
8882
|
+
await stageDirectory(paths.storageDir, join7(stagingRoot, "storage"));
|
|
8883
|
+
const entries = ["manifest.json", "secrets.json"];
|
|
8884
|
+
if (paths.dataDir)
|
|
8885
|
+
entries.push("database");
|
|
8886
|
+
if (options.storageBackend === "fs")
|
|
8887
|
+
entries.push("storage");
|
|
8888
|
+
await createTar({ cwd: stagingRoot, file: output, gzip: true, portable: true }, entries);
|
|
8889
|
+
if (process.platform !== "win32")
|
|
8890
|
+
await chmod2(output, 384);
|
|
8891
|
+
return manifest;
|
|
8892
|
+
} catch (error) {
|
|
8893
|
+
await rm3(output, { force: true });
|
|
8894
|
+
throw error;
|
|
8895
|
+
} finally {
|
|
8896
|
+
await rm3(stagingRoot, { recursive: true, force: true });
|
|
8897
|
+
}
|
|
8898
|
+
}
|
|
8899
|
+
async function restoreSnapshot(options) {
|
|
8900
|
+
const paths = normalizePaths(options.paths);
|
|
8901
|
+
await assertSnapshotPaths(paths, { requireSecrets: false, allowMissingState: true });
|
|
8902
|
+
await assertNoDataDirectoryLock(paths);
|
|
8903
|
+
const stagingRoot = await mkdtemp(join7(dirname4(paths.stateDir), ".supacloud-lite-restore-"));
|
|
8904
|
+
const payloadRoot = join7(stagingRoot, "payload");
|
|
8905
|
+
const rollbackId = crypto.randomUUID();
|
|
8906
|
+
const rollbackPaths = [];
|
|
8907
|
+
try {
|
|
8908
|
+
await mkdir5(payloadRoot, { recursive: true });
|
|
8909
|
+
await extractTar({
|
|
8910
|
+
cwd: payloadRoot,
|
|
8911
|
+
file: resolve3(options.input),
|
|
8912
|
+
preserveOwner: false,
|
|
8913
|
+
preservePaths: false,
|
|
8914
|
+
strict: true,
|
|
8915
|
+
unlink: true,
|
|
8916
|
+
filter: (path, entry) => {
|
|
8917
|
+
const normalized = path.replaceAll("\\", "/");
|
|
8918
|
+
const allowedPath = normalized === "manifest.json" || normalized === "secrets.json" || normalized === "database" || normalized.startsWith("database/") || normalized === "storage" || normalized.startsWith("storage/");
|
|
8919
|
+
if (!allowedPath || normalized.startsWith("/") || normalized.split("/").includes("..")) {
|
|
8920
|
+
throw new Error(`snapshot contains an unsafe path: ${path}`);
|
|
8921
|
+
}
|
|
8922
|
+
const entryType = "type" in entry ? entry.type : undefined;
|
|
8923
|
+
if (entryType !== "File" && entryType !== "Directory") {
|
|
8924
|
+
throw new Error(`snapshot contains an unsupported entry type: ${entryType ?? "unknown"}`);
|
|
8925
|
+
}
|
|
8926
|
+
return true;
|
|
8927
|
+
}
|
|
8928
|
+
});
|
|
8929
|
+
await assertNoSymlinks(payloadRoot);
|
|
8930
|
+
const manifest = await readManifest(payloadRoot);
|
|
8931
|
+
if (manifest.storageBackend !== options.storageBackend) {
|
|
8932
|
+
throw new Error(`snapshot storage backend is ${manifest.storageBackend}, but the target uses ${options.storageBackend}; ` + "restore with the matching --storage-backend value");
|
|
8933
|
+
}
|
|
8934
|
+
if (manifest.includesDatabase !== Boolean(paths.dataDir)) {
|
|
8935
|
+
throw new Error("snapshot database mode does not match the target; do not restore a persistent snapshot into --memory");
|
|
8936
|
+
}
|
|
8937
|
+
if (manifest.includesLocalStorage !== (options.storageBackend === "fs")) {
|
|
8938
|
+
throw new Error("snapshot storage payload does not match the target storage backend");
|
|
8939
|
+
}
|
|
8940
|
+
await assertSnapshotPayload(payloadRoot, manifest);
|
|
8941
|
+
if (manifest.includesDatabase)
|
|
8942
|
+
await mkdir5(join7(payloadRoot, "database"), { recursive: true });
|
|
8943
|
+
if (manifest.includesLocalStorage)
|
|
8944
|
+
await mkdir5(join7(payloadRoot, "storage"), { recursive: true });
|
|
8945
|
+
await assertRestoreTargets(paths, manifest, options.force === true);
|
|
8946
|
+
const stateStage = join7(stagingRoot, "state");
|
|
8947
|
+
await mkdir5(stateStage, { recursive: true });
|
|
8948
|
+
await copyEntry(join7(payloadRoot, "secrets.json"), join7(stateStage, "secrets.json"));
|
|
8949
|
+
if (paths.dataDir && isWithin(paths.stateDir, paths.dataDir)) {
|
|
8950
|
+
await copyEntry(join7(payloadRoot, "database"), join7(stateStage, relative2(paths.stateDir, paths.dataDir)));
|
|
8951
|
+
}
|
|
8952
|
+
if (options.storageBackend === "fs" && isWithin(paths.stateDir, paths.storageDir)) {
|
|
8953
|
+
await copyEntry(join7(payloadRoot, "storage"), join7(stateStage, relative2(paths.stateDir, paths.storageDir)));
|
|
8954
|
+
}
|
|
8955
|
+
const swaps = [];
|
|
8956
|
+
try {
|
|
8957
|
+
await applyDirectorySwap(stateStage, paths.stateDir, options.force === true, rollbackId, swaps);
|
|
8958
|
+
if (paths.dataDir && !isWithin(paths.stateDir, paths.dataDir)) {
|
|
8959
|
+
await applyDirectorySwap(join7(payloadRoot, "database"), paths.dataDir, options.force === true, rollbackId, swaps);
|
|
8960
|
+
}
|
|
8961
|
+
if (options.storageBackend === "fs" && !isWithin(paths.stateDir, paths.storageDir)) {
|
|
8962
|
+
await applyDirectorySwap(join7(payloadRoot, "storage"), paths.storageDir, options.force === true, rollbackId, swaps);
|
|
8963
|
+
}
|
|
8964
|
+
} catch (error) {
|
|
8965
|
+
await rollbackDirectorySwaps(swaps);
|
|
8966
|
+
throw error;
|
|
8967
|
+
}
|
|
8968
|
+
rollbackPaths.push(...swaps.flatMap((swap) => swap.rollbackPath ? [swap.rollbackPath] : []));
|
|
8969
|
+
if (process.platform !== "win32") {
|
|
8970
|
+
await hardenRestoredTree(paths.stateDir);
|
|
8971
|
+
if (paths.dataDir && !isWithin(paths.stateDir, paths.dataDir))
|
|
8972
|
+
await hardenRestoredTree(paths.dataDir);
|
|
8973
|
+
if (options.storageBackend === "fs" && !isWithin(paths.stateDir, paths.storageDir)) {
|
|
8974
|
+
await hardenRestoredTree(paths.storageDir);
|
|
8975
|
+
}
|
|
8976
|
+
}
|
|
8977
|
+
return { manifest, rollbackPaths };
|
|
8978
|
+
} catch (error) {
|
|
8979
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
8980
|
+
} finally {
|
|
8981
|
+
await rm3(stagingRoot, { recursive: true, force: true });
|
|
8982
|
+
}
|
|
8983
|
+
}
|
|
8984
|
+
function normalizePaths(paths) {
|
|
8985
|
+
return {
|
|
8986
|
+
...paths,
|
|
8987
|
+
projectDir: resolve3(paths.projectDir),
|
|
8988
|
+
stateDir: resolve3(paths.stateDir),
|
|
8989
|
+
dataDir: paths.dataDir ? resolve3(paths.dataDir) : undefined,
|
|
8990
|
+
storageDir: resolve3(paths.storageDir),
|
|
8991
|
+
secretsFile: resolve3(paths.secretsFile)
|
|
8992
|
+
};
|
|
8993
|
+
}
|
|
8994
|
+
async function assertSnapshotPaths(paths, options = {}) {
|
|
8995
|
+
try {
|
|
8996
|
+
if (paths.stateDir === parse2(paths.stateDir).root)
|
|
8997
|
+
throw new Error("snapshot state directory must not be the filesystem root");
|
|
8998
|
+
if (paths.secretsFile !== join7(paths.stateDir, "secrets.json"))
|
|
8999
|
+
throw new Error("snapshot secrets path must be inside the state directory");
|
|
9000
|
+
const stateInfo = await lstat2(paths.stateDir);
|
|
9001
|
+
if (!stateInfo.isDirectory() || stateInfo.isSymbolicLink())
|
|
9002
|
+
throw new Error(`state directory must be a real directory: ${paths.stateDir}`);
|
|
9003
|
+
} catch (error) {
|
|
9004
|
+
if (error.code !== "ENOENT" || options.allowMissingState !== true)
|
|
9005
|
+
throw error;
|
|
9006
|
+
}
|
|
9007
|
+
if (paths.dataDir && paths.storageDir && pathsOverlap(paths.dataDir, paths.storageDir)) {
|
|
9008
|
+
throw new Error("database and storage directories must not overlap");
|
|
9009
|
+
}
|
|
9010
|
+
await assertDirectoryOrMissing(paths.dataDir);
|
|
9011
|
+
await assertDirectoryOrMissing(paths.storageDir);
|
|
9012
|
+
if (options.requireSecrets !== false) {
|
|
9013
|
+
const secretInfo = await lstat2(paths.secretsFile);
|
|
9014
|
+
if (!secretInfo.isFile() || secretInfo.isSymbolicLink())
|
|
9015
|
+
throw new Error(`secrets file must be a regular file: ${paths.secretsFile}`);
|
|
9016
|
+
}
|
|
9017
|
+
}
|
|
9018
|
+
async function assertDirectoryOrMissing(path) {
|
|
9019
|
+
if (!path)
|
|
9020
|
+
return;
|
|
9021
|
+
if (resolve3(path) === parse2(resolve3(path)).root)
|
|
9022
|
+
throw new Error(`snapshot path must not be the filesystem root: ${path}`);
|
|
9023
|
+
try {
|
|
9024
|
+
const info = await lstat2(path);
|
|
9025
|
+
if (!info.isDirectory() || info.isSymbolicLink())
|
|
9026
|
+
throw new Error(`snapshot path must be a real directory: ${path}`);
|
|
9027
|
+
} catch (error) {
|
|
9028
|
+
if (error.code !== "ENOENT")
|
|
9029
|
+
throw error;
|
|
9030
|
+
}
|
|
9031
|
+
}
|
|
9032
|
+
async function assertNoDataDirectoryLock(paths) {
|
|
9033
|
+
if (!paths.dataDir)
|
|
9034
|
+
return;
|
|
9035
|
+
const lockPath = `${paths.dataDir}.supacloud-lite.lock`;
|
|
9036
|
+
try {
|
|
9037
|
+
await lstat2(lockPath);
|
|
9038
|
+
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`);
|
|
9039
|
+
} catch (error) {
|
|
9040
|
+
if (error.code !== "ENOENT")
|
|
9041
|
+
throw error;
|
|
9042
|
+
}
|
|
9043
|
+
}
|
|
9044
|
+
async function stageDirectory(root, destination) {
|
|
9045
|
+
try {
|
|
9046
|
+
const info = await lstat2(root);
|
|
9047
|
+
if (!info.isDirectory() || info.isSymbolicLink())
|
|
9048
|
+
throw new Error(`snapshot path must be a real directory: ${root}`);
|
|
9049
|
+
} catch (error) {
|
|
9050
|
+
if (error.code === "ENOENT") {
|
|
9051
|
+
await mkdir5(destination, { recursive: true });
|
|
9052
|
+
return;
|
|
9053
|
+
}
|
|
9054
|
+
throw error;
|
|
9055
|
+
}
|
|
9056
|
+
await mkdir5(destination, { recursive: true });
|
|
9057
|
+
const walk = async (current, target) => {
|
|
9058
|
+
for (const entry of await readdir3(current, { withFileTypes: true })) {
|
|
9059
|
+
const fullPath = join7(current, entry.name);
|
|
9060
|
+
const targetPath = join7(target, entry.name);
|
|
9061
|
+
if (entry.isSymbolicLink())
|
|
9062
|
+
throw new Error(`snapshot refuses symbolic link: ${fullPath}`);
|
|
9063
|
+
if (entry.isDirectory()) {
|
|
9064
|
+
await mkdir5(targetPath, { recursive: true });
|
|
9065
|
+
await walk(fullPath, targetPath);
|
|
9066
|
+
} else if (entry.isFile()) {
|
|
9067
|
+
await stageFile(fullPath, targetPath);
|
|
9068
|
+
} else {
|
|
9069
|
+
throw new Error(`snapshot refuses unsupported filesystem entry: ${fullPath}`);
|
|
9070
|
+
}
|
|
9071
|
+
}
|
|
9072
|
+
};
|
|
9073
|
+
await walk(root, destination);
|
|
9074
|
+
}
|
|
9075
|
+
async function stageFile(source, target) {
|
|
9076
|
+
await mkdir5(dirname4(target), { recursive: true });
|
|
9077
|
+
try {
|
|
9078
|
+
await link2(source, target);
|
|
9079
|
+
} catch (error) {
|
|
9080
|
+
const code = error.code;
|
|
9081
|
+
if (code !== "EXDEV" && code !== "EPERM" && code !== "EACCES")
|
|
9082
|
+
throw error;
|
|
9083
|
+
await copyFile(source, target);
|
|
9084
|
+
}
|
|
9085
|
+
}
|
|
9086
|
+
async function readManifest(payloadRoot) {
|
|
9087
|
+
let parsed;
|
|
9088
|
+
try {
|
|
9089
|
+
parsed = JSON.parse(await readFile7(join7(payloadRoot, "manifest.json"), "utf8"));
|
|
9090
|
+
} catch (error) {
|
|
9091
|
+
throw new Error(`invalid snapshot manifest: ${error instanceof Error ? error.message : String(error)}`);
|
|
9092
|
+
}
|
|
9093
|
+
if (!isSnapshotManifest(parsed))
|
|
9094
|
+
throw new Error("unsupported or invalid SupaCloud Lite snapshot manifest");
|
|
9095
|
+
return parsed;
|
|
9096
|
+
}
|
|
9097
|
+
function isSnapshotManifest(value) {
|
|
9098
|
+
if (!value || typeof value !== "object")
|
|
9099
|
+
return false;
|
|
9100
|
+
const candidate = value;
|
|
9101
|
+
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;
|
|
9102
|
+
}
|
|
9103
|
+
async function assertSnapshotPayload(payloadRoot, manifest) {
|
|
9104
|
+
const required = ["manifest.json", "secrets.json"];
|
|
9105
|
+
for (const path of required) {
|
|
9106
|
+
try {
|
|
9107
|
+
await lstat2(join7(payloadRoot, path));
|
|
9108
|
+
} catch {
|
|
9109
|
+
throw new Error(`snapshot is missing required payload: ${path}`);
|
|
9110
|
+
}
|
|
9111
|
+
}
|
|
9112
|
+
const allowed = [...required];
|
|
9113
|
+
if (manifest.includesDatabase)
|
|
9114
|
+
allowed.push("database");
|
|
9115
|
+
if (manifest.includesLocalStorage)
|
|
9116
|
+
allowed.push("storage");
|
|
9117
|
+
for (const entry of await readdir3(payloadRoot)) {
|
|
9118
|
+
if (!allowed.includes(entry)) {
|
|
9119
|
+
throw new Error(`snapshot contains an unexpected payload entry: ${entry}`);
|
|
9120
|
+
}
|
|
9121
|
+
}
|
|
9122
|
+
}
|
|
9123
|
+
async function assertRestoreTargets(paths, manifest, force) {
|
|
9124
|
+
const targets = [paths.stateDir];
|
|
9125
|
+
if (paths.dataDir && !isWithin(paths.stateDir, paths.dataDir))
|
|
9126
|
+
targets.push(paths.dataDir);
|
|
9127
|
+
if (manifest.includesLocalStorage && !isWithin(paths.stateDir, paths.storageDir))
|
|
9128
|
+
targets.push(paths.storageDir);
|
|
9129
|
+
if (!force) {
|
|
9130
|
+
for (const target of targets) {
|
|
9131
|
+
if (await directoryHasEntries(target))
|
|
9132
|
+
throw new Error(`restore target is not empty: ${target}; pass --force to replace it`);
|
|
9133
|
+
}
|
|
9134
|
+
}
|
|
9135
|
+
}
|
|
9136
|
+
async function directoryHasEntries(path) {
|
|
9137
|
+
try {
|
|
9138
|
+
return (await readdir3(path)).length > 0;
|
|
9139
|
+
} catch (error) {
|
|
9140
|
+
if (error.code === "ENOENT")
|
|
9141
|
+
return false;
|
|
9142
|
+
throw error;
|
|
9143
|
+
}
|
|
9144
|
+
}
|
|
9145
|
+
async function applyDirectorySwap(source, target, force, rollbackId, swaps) {
|
|
9146
|
+
const targetInfo = await existingInfo(target);
|
|
9147
|
+
if (targetInfo && !targetInfo.isDirectory())
|
|
9148
|
+
throw new Error(`restore target is not a directory: ${target}`);
|
|
9149
|
+
const swap = { target };
|
|
9150
|
+
if (targetInfo) {
|
|
9151
|
+
if (!force) {
|
|
9152
|
+
if (await directoryHasEntries(target))
|
|
9153
|
+
throw new Error(`restore target is not empty: ${target}; pass --force to replace it`);
|
|
9154
|
+
await rm3(target, { recursive: true, force: true });
|
|
9155
|
+
} else {
|
|
9156
|
+
swap.rollbackPath = join7(dirname4(target), `.${target.split(sep2).pop() ?? "state"}.restore-${rollbackId}`);
|
|
9157
|
+
await rename2(target, swap.rollbackPath);
|
|
9158
|
+
}
|
|
9159
|
+
}
|
|
9160
|
+
try {
|
|
9161
|
+
await mkdir5(dirname4(target), { recursive: true });
|
|
9162
|
+
await rename2(source, target);
|
|
9163
|
+
swaps.push(swap);
|
|
9164
|
+
} catch (error) {
|
|
9165
|
+
if (swap.rollbackPath)
|
|
9166
|
+
await rename2(swap.rollbackPath, target).catch(() => {});
|
|
9167
|
+
throw error;
|
|
9168
|
+
}
|
|
9169
|
+
}
|
|
9170
|
+
async function rollbackDirectorySwaps(swaps) {
|
|
9171
|
+
for (const swap of [...swaps].reverse()) {
|
|
9172
|
+
await rm3(swap.target, { recursive: true, force: true });
|
|
9173
|
+
if (swap.rollbackPath)
|
|
9174
|
+
await rename2(swap.rollbackPath, swap.target);
|
|
9175
|
+
}
|
|
9176
|
+
}
|
|
9177
|
+
async function existingInfo(path) {
|
|
9178
|
+
try {
|
|
9179
|
+
return await lstat2(path);
|
|
9180
|
+
} catch (error) {
|
|
9181
|
+
if (error.code === "ENOENT")
|
|
9182
|
+
return null;
|
|
9183
|
+
throw error;
|
|
9184
|
+
}
|
|
9185
|
+
}
|
|
9186
|
+
async function copyEntry(source, target) {
|
|
9187
|
+
const info = await lstat2(source);
|
|
9188
|
+
if (info.isSymbolicLink())
|
|
9189
|
+
throw new Error(`snapshot refuses symbolic link: ${source}`);
|
|
9190
|
+
if (info.isDirectory()) {
|
|
9191
|
+
await mkdir5(target, { recursive: true });
|
|
9192
|
+
for (const entry of await readdir3(source))
|
|
9193
|
+
await copyEntry(join7(source, entry), join7(target, entry));
|
|
9194
|
+
} else if (info.isFile()) {
|
|
9195
|
+
await mkdir5(dirname4(target), { recursive: true });
|
|
9196
|
+
await Bun.write(target, Bun.file(source));
|
|
9197
|
+
} else
|
|
9198
|
+
throw new Error(`snapshot refuses unsupported filesystem entry: ${source}`);
|
|
9199
|
+
}
|
|
9200
|
+
async function hardenRestoredTree(root) {
|
|
9201
|
+
const info = await lstat2(root);
|
|
9202
|
+
if (info.isSymbolicLink())
|
|
9203
|
+
throw new Error(`snapshot refuses symbolic link: ${root}`);
|
|
9204
|
+
if (info.isDirectory()) {
|
|
9205
|
+
await chmod2(root, 448);
|
|
9206
|
+
for (const entry of await readdir3(root))
|
|
9207
|
+
await hardenRestoredTree(join7(root, entry));
|
|
9208
|
+
return;
|
|
9209
|
+
}
|
|
9210
|
+
if (info.isFile()) {
|
|
9211
|
+
await chmod2(root, 384);
|
|
9212
|
+
return;
|
|
9213
|
+
}
|
|
9214
|
+
throw new Error(`snapshot refuses unsupported filesystem entry: ${root}`);
|
|
9215
|
+
}
|
|
9216
|
+
async function assertNoSymlinks(root) {
|
|
9217
|
+
for (const entry of await readdir3(root, { withFileTypes: true })) {
|
|
9218
|
+
const fullPath = join7(root, entry.name);
|
|
9219
|
+
if (entry.isSymbolicLink())
|
|
9220
|
+
throw new Error(`snapshot refuses symbolic link in archive: ${fullPath}`);
|
|
9221
|
+
if (entry.isDirectory())
|
|
9222
|
+
await assertNoSymlinks(fullPath);
|
|
9223
|
+
}
|
|
9224
|
+
}
|
|
9225
|
+
function isWithin(parent, child) {
|
|
9226
|
+
const normalizedParent = resolve3(parent);
|
|
9227
|
+
const normalizedChild = resolve3(child);
|
|
9228
|
+
return normalizedChild !== normalizedParent && normalizedChild.startsWith(`${normalizedParent}${sep2}`);
|
|
9229
|
+
}
|
|
9230
|
+
function pathsOverlap(left, right) {
|
|
9231
|
+
const normalizedLeft = resolve3(left);
|
|
9232
|
+
const normalizedRight = resolve3(right);
|
|
9233
|
+
return normalizedLeft === normalizedRight || isWithin(normalizedLeft, normalizedRight) || isWithin(normalizedRight, normalizedLeft);
|
|
9234
|
+
}
|
|
8450
9235
|
export {
|
|
8451
9236
|
verifyJwt,
|
|
8452
9237
|
startProjectServer,
|
|
8453
9238
|
signJwt,
|
|
8454
9239
|
serveBun,
|
|
9240
|
+
restoreSnapshot,
|
|
8455
9241
|
resolveStorageBackend,
|
|
8456
9242
|
resolveProjectPaths,
|
|
8457
9243
|
mintProjectKeys,
|
|
@@ -8459,6 +9245,7 @@ export {
|
|
|
8459
9245
|
generateTypes,
|
|
8460
9246
|
ensureProjectSecrets,
|
|
8461
9247
|
decodeJwt,
|
|
9248
|
+
createSnapshot,
|
|
8462
9249
|
createProjectBackend,
|
|
8463
9250
|
createPgliteEngine,
|
|
8464
9251
|
createBackend as createLiteBackend,
|