@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.
Files changed (35) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/LICENSES/BUN-1.3.14-RUNTIME-NOTICES.txt +78 -0
  3. package/LICENSES/CHOWNR-BLUEOAK-1.0.0.txt +63 -0
  4. package/LICENSES/FS-MINIPASS-ISC.txt +15 -0
  5. package/LICENSES/MINIPASS-BLUEOAK-1.0.0.txt +55 -0
  6. package/LICENSES/MINIZLIB-MIT.txt +26 -0
  7. package/LICENSES/NODE-TAR-BLUEOAK-1.0.0.txt +55 -0
  8. package/LICENSES/YALLIST-BLUEOAK-1.0.0.txt +63 -0
  9. package/README.md +142 -3
  10. package/RELEASING.md +14 -1
  11. package/THIRD_PARTY_NOTICES.md +44 -0
  12. package/dist/cli.js +965 -104
  13. package/dist/index.d.ts +2 -0
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +867 -80
  16. package/dist/launcher.cjs +22 -0
  17. package/dist/snapshot.d.ts +41 -0
  18. package/dist/snapshot.d.ts.map +1 -0
  19. package/dist/standalone-assets-protocol.d.ts +13 -0
  20. package/dist/standalone-assets-protocol.d.ts.map +1 -0
  21. package/dist/vendor/tinbase/auth/handler.d.ts +4 -0
  22. package/dist/vendor/tinbase/auth/handler.d.ts.map +1 -1
  23. package/dist/vendor/tinbase/db/database.d.ts +5 -1
  24. package/dist/vendor/tinbase/db/database.d.ts.map +1 -1
  25. package/dist/vendor/tinbase/db/emulated.d.ts +1 -1
  26. package/dist/vendor/tinbase/db/emulated.d.ts.map +1 -1
  27. package/dist/vendor/tinbase/db/pglite-engine.d.ts.map +1 -1
  28. package/dist/vendor/tinbase/functions/handler.d.ts +3 -1
  29. package/dist/vendor/tinbase/functions/handler.d.ts.map +1 -1
  30. package/dist/vendor/tinbase/functions/pgredis.d.ts +26 -0
  31. package/dist/vendor/tinbase/functions/pgredis.d.ts.map +1 -0
  32. package/dist/vendor/tinbase/index.d.ts +1 -0
  33. package/dist/vendor/tinbase/index.d.ts.map +1 -1
  34. package/dist/vendor/tinbase/retention/service.d.ts.map +1 -1
  35. package/package.json +14 -6
package/dist/cli.js CHANGED
@@ -3,17 +3,17 @@
3
3
  var __require = import.meta.require;
4
4
 
5
5
  // src/cli.ts
6
- import { mkdir as mkdir6, rm as rm3, writeFile as writeFile5 } from "fs/promises";
7
- import { dirname as dirname4, join as join8, resolve as resolve3 } from "path";
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.2.0",
11
+ version: "0.5.0",
12
12
  description: "Bun-native, single-project Supabase-compatible backend powered by PGlite",
13
13
  type: "module",
14
14
  license: "Apache-2.0",
15
15
  bin: {
16
- "supacloud-lite": "dist/cli.js"
16
+ "supacloud-lite": "dist/launcher.cjs"
17
17
  },
18
18
  exports: {
19
19
  ".": {
@@ -24,6 +24,7 @@ var package_default = {
24
24
  },
25
25
  files: [
26
26
  "dist",
27
+ "!dist/standalone",
27
28
  "README.md",
28
29
  "CHANGELOG.md",
29
30
  "RELEASING.md",
@@ -32,21 +33,28 @@ var package_default = {
32
33
  ],
33
34
  scripts: {
34
35
  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",
36
+ "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",
37
+ "build:standalone:host": "bun run scripts/build-standalone.ts host",
38
+ "build:standalone:linux": "bun run scripts/build-standalone.ts linux-x64 linux-arm64",
39
+ "build:standalone:macos": "bun run scripts/build-standalone.ts macos-x64 macos-arm64",
40
+ "build:standalone:windows": "bun run scripts/build-standalone.ts windows-x64",
41
+ "build:standalone:release": "bun run scripts/build-standalone.ts linux-x64 linux-arm64 macos-x64 macos-arm64 windows-x64",
36
42
  "build:types": "bun x tsc --emitDeclarationOnly -p tsconfig.build.json",
37
- check: "bun run typecheck && bun run test && bun run build && bun run test:package",
43
+ check: "bun run typecheck && bun run test && bun run build && bun run test:package && bun run build:standalone:host && bun run test:standalone",
38
44
  dev: "bun run src/cli.ts start",
39
45
  start: "bun run src/cli.ts start",
40
46
  test: "bun test --timeout 20000",
41
47
  "test:package": "bun run scripts/package-smoke.ts",
48
+ "test:standalone": "bun run scripts/standalone-smoke.ts",
42
49
  prepack: "bun run build",
43
50
  typecheck: "bun x tsc --noEmit -p tsconfig.json"
44
51
  },
45
52
  dependencies: {
46
- "@electric-sql/pglite": "0.5.4"
53
+ "@electric-sql/pglite": "0.5.4",
54
+ tar: "^7.5.22"
47
55
  },
48
56
  devDependencies: {
49
- "@supabase/supabase-js": "^2.57.4",
57
+ "@supabase/supabase-js": "^2.110.9",
50
58
  "@types/bun": "^1.3.14",
51
59
  typescript: "^5.9.3"
52
60
  },
@@ -1257,30 +1265,7 @@ class AuthHandler {
1257
1265
  const token = body.refresh_token;
1258
1266
  if (!token)
1259
1267
  return authError(400, "validation_failed", "refresh_token required");
1260
- const res = await this.db.query(`select rt.*, u.id as uid from auth.refresh_tokens rt
1261
- join auth.users u on u.id = rt.user_id
1262
- where rt.token = $1`, [token]);
1263
- const row = res.rows[0];
1264
- if (!row || row.revoked) {
1265
- return authError(400, "refresh_token_not_found", "Invalid Refresh Token: Refresh Token Not Found");
1266
- }
1267
- const now = Date.now();
1268
- const lastActivity = timestampMs(row.created_at);
1269
- if (this.config.sessionInactivitySeconds && lastActivity !== null && now - lastActivity >= this.config.sessionInactivitySeconds * 1000) {
1270
- await this.db.query(`update auth.refresh_tokens set revoked = true, updated_at = now() where token = $1`, [token]);
1271
- return authError(400, "session_expired", "Session expired due to inactivity");
1272
- }
1273
- 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;
1274
- if (this.config.sessionTimeboxSeconds && sessionStartedAt !== null && now - sessionStartedAt >= this.config.sessionTimeboxSeconds * 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");
1277
- }
1278
- await this.db.query(`update auth.refresh_tokens set revoked = true, updated_at = now() where token = $1`, [token]);
1279
- const ures = await this.db.query(`select * from auth.users where id = $1`, [row.user_id]);
1280
- return json(200, await this.sessionFor(ures.rows[0], token, {
1281
- sessionId: row.session_id ?? undefined,
1282
- sessionStartedAt: sessionStartedAt === null ? undefined : Math.floor(sessionStartedAt / 1000)
1283
- }));
1268
+ return this.rotateRefreshToken(token);
1284
1269
  }
1285
1270
  if (grantType === "pkce") {
1286
1271
  const authCode = body.auth_code;
@@ -1704,8 +1689,8 @@ Or sign in with this link: ${link}`
1704
1689
  return authError(404, "mfa_factor_not_found", "MFA factor not found");
1705
1690
  return json(200, { id: factorId });
1706
1691
  }
1707
- async getUserFactors(userId) {
1708
- const res = await this.db.query(`select id, friendly_name, factor_type, status, created_at, updated_at
1692
+ async getUserFactors(userId, query = (sql, params) => this.db.query(sql, params)) {
1693
+ const res = await query(`select id, friendly_name, factor_type, status, created_at, updated_at
1709
1694
  from auth.mfa_factors where user_id = $1 order by created_at`, [userId]);
1710
1695
  return res.rows.map((f) => ({
1711
1696
  id: f.id,
@@ -1716,8 +1701,8 @@ Or sign in with this link: ${link}`
1716
1701
  updated_at: iso(f.updated_at)
1717
1702
  }));
1718
1703
  }
1719
- async getUserIdentities(userId) {
1720
- const res = await this.db.query(`select id, provider_id, user_id, identity_data, provider, created_at, updated_at, last_sign_in_at
1704
+ async getUserIdentities(userId, query = (sql, params) => this.db.query(sql, params)) {
1705
+ const res = await query(`select id, provider_id, user_id, identity_data, provider, created_at, updated_at, last_sign_in_at
1721
1706
  from auth.identities where user_id = $1 order by created_at`, [userId]);
1722
1707
  return res.rows.map((r) => ({
1723
1708
  identity_id: r.id,
@@ -1730,6 +1715,46 @@ Or sign in with this link: ${link}`
1730
1715
  updated_at: iso(r.updated_at)
1731
1716
  }));
1732
1717
  }
1718
+ async rotateRefreshToken(token) {
1719
+ return this.db.transaction(async (query) => {
1720
+ const claimedToken = await this.claimRefreshToken(query, token);
1721
+ if (!claimedToken)
1722
+ return authError(400, "refresh_token_not_found", "Invalid Refresh Token: Refresh Token Not Found");
1723
+ const sessionStartedAt = await this.refreshSessionStartedAt(query, claimedToken);
1724
+ const expiryError = this.refreshExpiryError(claimedToken, sessionStartedAt);
1725
+ if (expiryError)
1726
+ return expiryError;
1727
+ const userResult = await query(`select * from auth.users where id = $1`, [claimedToken.user_id]);
1728
+ const session = await this.sessionFor(userResult.rows[0], token, {
1729
+ sessionId: claimedToken.session_id ?? undefined,
1730
+ sessionStartedAt: sessionStartedAt === null ? undefined : Math.floor(sessionStartedAt / 1000)
1731
+ }, query);
1732
+ return json(200, session);
1733
+ });
1734
+ }
1735
+ async claimRefreshToken(query, token) {
1736
+ const claimed = await query(`update auth.refresh_tokens set revoked = true, updated_at = now()
1737
+ where token = $1 and revoked is not true
1738
+ returning user_id, session_id, created_at`, [token]);
1739
+ return claimed.rows[0];
1740
+ }
1741
+ async refreshSessionStartedAt(query, refreshToken) {
1742
+ if (!refreshToken.session_id)
1743
+ return timestampMs(refreshToken.created_at);
1744
+ const sessionStart = await query(`select min(created_at) as started_at from auth.refresh_tokens where session_id = $1`, [refreshToken.session_id]);
1745
+ return timestampMs(sessionStart.rows[0]?.started_at ?? null);
1746
+ }
1747
+ refreshExpiryError(refreshToken, sessionStartedAt) {
1748
+ const now = Date.now();
1749
+ const lastActivity = timestampMs(refreshToken.created_at);
1750
+ if (this.config.sessionInactivitySeconds && lastActivity !== null && now - lastActivity >= this.config.sessionInactivitySeconds * 1000) {
1751
+ return authError(400, "session_expired", "Session expired due to inactivity");
1752
+ }
1753
+ if (this.config.sessionTimeboxSeconds && sessionStartedAt !== null && now - sessionStartedAt >= this.config.sessionTimeboxSeconds * 1000) {
1754
+ return authError(400, "session_expired", "Session expired");
1755
+ }
1756
+ return null;
1757
+ }
1733
1758
  async userFromBearer(req) {
1734
1759
  const claims = await this.claimsFromBearer(req);
1735
1760
  if (!claims?.sub)
@@ -1767,7 +1792,7 @@ Or sign in with this link: ${link}`
1767
1792
  const session = await this.sessionFor(res.rows[0]);
1768
1793
  return { access_token: session.access_token, refresh_token: session.refresh_token, expires_in: session.expires_in };
1769
1794
  }
1770
- async sessionFor(user, parentToken, opts) {
1795
+ async sessionFor(user, parentToken, opts, query = (sql, params) => this.db.query(sql, params)) {
1771
1796
  const now = Math.floor(Date.now() / 1000);
1772
1797
  const timeboxRemaining = this.config.sessionTimeboxSeconds ? opts?.sessionStartedAt !== undefined ? opts.sessionStartedAt + this.config.sessionTimeboxSeconds - now : this.config.sessionTimeboxSeconds : this.config.jwtExpiry;
1773
1798
  const lifetime = Math.max(1, Math.min(this.config.jwtExpiry, timeboxRemaining));
@@ -1791,14 +1816,14 @@ Or sign in with this link: ${link}`
1791
1816
  };
1792
1817
  const accessToken = await signJwt(claims, this.config.jwtSecret);
1793
1818
  const refreshToken = randomToken(24);
1794
- await this.db.query(`insert into auth.refresh_tokens (token, user_id, parent, session_id) values ($1, $2, $3, $4)`, [refreshToken, user.id, parentToken ?? null, sessionId]);
1819
+ await query(`insert into auth.refresh_tokens (token, user_id, parent, session_id) values ($1, $2, $3, $4)`, [refreshToken, user.id, parentToken ?? null, sessionId]);
1795
1820
  return {
1796
1821
  access_token: accessToken,
1797
1822
  token_type: "bearer",
1798
1823
  expires_in: lifetime,
1799
1824
  expires_at: expiresAt,
1800
1825
  refresh_token: refreshToken,
1801
- user: this.userJson(user, await this.getUserFactors(user.id), await this.getUserIdentities(user.id))
1826
+ user: this.userJson(user, await this.getUserFactors(user.id, query), await this.getUserIdentities(user.id, query))
1802
1827
  };
1803
1828
  }
1804
1829
  }
@@ -1984,13 +2009,182 @@ function resetCapturedHandler() {
1984
2009
  captured.handler = undefined;
1985
2010
  }
1986
2011
 
2012
+ // src/vendor/tinbase/functions/pgredis.ts
2013
+ import { AsyncLocalStorage as AsyncLocalStorage2 } from "async_hooks";
2014
+ var CACHE_NAMESPACE = "supacloud-edge-runtime";
2015
+ var CACHE_TABLE = "public.supacloud_pgredis_kv";
2016
+ var MAX_KEY_CHARACTERS = 512;
2017
+ var MAX_VALUE_BYTES = 1048576;
2018
+ var MAX_TTL_MS = 31536000000;
2019
+ var CACHE_SCHEMA_SQL = `
2020
+ create table if not exists ${CACHE_TABLE} (
2021
+ namespace text not null,
2022
+ key text not null,
2023
+ value jsonb not null,
2024
+ expires_at timestamptz,
2025
+ updated_at timestamptz not null default now(),
2026
+ primary key (namespace, key)
2027
+ );
2028
+
2029
+ create index if not exists supacloud_pgredis_kv_expires_at_idx
2030
+ on ${CACHE_TABLE} (expires_at) where expires_at is not null;
2031
+ `;
2032
+ var CACHE_PERMISSIONS_SQL = `
2033
+ revoke all on table ${CACHE_TABLE} from public, anon, authenticated, service_role;
2034
+ `;
2035
+
2036
+ class PgredisCache {
2037
+ db;
2038
+ constructor(db) {
2039
+ this.db = db;
2040
+ }
2041
+ static async create(db) {
2042
+ await db.exec(CACHE_SCHEMA_SQL);
2043
+ if (!db.engine.minimalBootstrap)
2044
+ await db.exec(CACHE_PERMISSIONS_SQL);
2045
+ return new PgredisCache(db);
2046
+ }
2047
+ async get(key) {
2048
+ assertCacheKey(key);
2049
+ return this.db.transaction(async (query) => {
2050
+ await deleteExpiredKey(query, key);
2051
+ const stored = await query(`select value::text as serialized_value from ${CACHE_TABLE} where namespace = $1 and key = $2`, [CACHE_NAMESPACE, key]);
2052
+ return stored.rows[0] ? parseStoredValue(stored.rows[0]) : null;
2053
+ });
2054
+ }
2055
+ async set(key, cacheValue, ttlMs) {
2056
+ assertCacheKey(key);
2057
+ assertTtl(ttlMs);
2058
+ const serializedValue = serializeCacheValue(cacheValue);
2059
+ await this.db.query(`insert into ${CACHE_TABLE} (namespace, key, value, expires_at, updated_at)
2060
+ values ($1, $2, $3::jsonb,
2061
+ case when $4::bigint is null then null else clock_timestamp() + $4::double precision * interval '1 millisecond' end,
2062
+ now())
2063
+ on conflict (namespace, key) do update set
2064
+ value = excluded.value, expires_at = excluded.expires_at, updated_at = now()`, [CACHE_NAMESPACE, key, serializedValue, ttlMs ?? null]);
2065
+ return true;
2066
+ }
2067
+ async delete(key) {
2068
+ assertCacheKey(key);
2069
+ return this.db.transaction(async (query) => {
2070
+ await deleteExpiredKey(query, key);
2071
+ const deleted = await query(`delete from ${CACHE_TABLE} where namespace = $1 and key = $2 returning key`, [CACHE_NAMESPACE, key]);
2072
+ return deleted.rows.length > 0;
2073
+ });
2074
+ }
2075
+ async ttl(key) {
2076
+ assertCacheKey(key);
2077
+ return this.db.transaction(async (query) => {
2078
+ await deleteExpiredKey(query, key);
2079
+ const stored = await query(`select case when expires_at is null then null
2080
+ else greatest(0, ceil(extract(epoch from (expires_at - clock_timestamp())) * 1000))::bigint
2081
+ end as ttl_ms from ${CACHE_TABLE} where namespace = $1 and key = $2`, [CACHE_NAMESPACE, key]);
2082
+ const ttlMs = stored.rows[0]?.ttl_ms;
2083
+ return ttlMs === null || ttlMs === undefined ? null : Number(ttlMs);
2084
+ });
2085
+ }
2086
+ async getset(key, cacheValue) {
2087
+ assertCacheKey(key);
2088
+ const serializedValue = serializeCacheValue(cacheValue);
2089
+ return this.db.transaction(async (query) => {
2090
+ await deleteExpiredKey(query, key);
2091
+ const previous = await query(`select value::text as serialized_value from ${CACHE_TABLE}
2092
+ where namespace = $1 and key = $2 for update`, [CACHE_NAMESPACE, key]);
2093
+ await upsertWithoutTtl(query, key, serializedValue);
2094
+ return previous.rows[0] ? parseStoredValue(previous.rows[0]) : null;
2095
+ });
2096
+ }
2097
+ async getdel(key) {
2098
+ assertCacheKey(key);
2099
+ return this.db.transaction(async (query) => {
2100
+ await deleteExpiredKey(query, key);
2101
+ const deleted = await query(`delete from ${CACHE_TABLE} where namespace = $1 and key = $2 returning value::text as serialized_value`, [CACHE_NAMESPACE, key]);
2102
+ return deleted.rows[0] ? parseStoredValue(deleted.rows[0]) : null;
2103
+ });
2104
+ }
2105
+ }
2106
+ var cacheContexts = new AsyncLocalStorage2;
2107
+ var cacheFacade = Object.freeze({
2108
+ get: async (key) => activeCache().get(key),
2109
+ set: async (key, cacheValue, ttlMs) => activeCache().set(key, cacheValue, ttlMs),
2110
+ delete: async (key) => activeCache().delete(key),
2111
+ ttl: async (key) => activeCache().ttl(key),
2112
+ getset: async (key, cacheValue) => activeCache().getset(key, cacheValue),
2113
+ getdel: async (key) => activeCache().getdel(key)
2114
+ });
2115
+ function installPgredisShim() {
2116
+ const runtime = globalThis;
2117
+ if (runtime.SupaCloud?.pgredis) {
2118
+ if (runtime.SupaCloud.pgredis === cacheFacade)
2119
+ return;
2120
+ throw new Error("globalThis.SupaCloud.pgredis is already owned by another runtime");
2121
+ }
2122
+ Object.defineProperty(runtime, "SupaCloud", {
2123
+ configurable: false,
2124
+ enumerable: true,
2125
+ value: Object.freeze({ ...runtime.SupaCloud ?? {}, pgredis: cacheFacade }),
2126
+ writable: false
2127
+ });
2128
+ }
2129
+ function runWithPgredisCache(cache, operation) {
2130
+ const context = { cache, active: true };
2131
+ return cacheContexts.run(context, async () => {
2132
+ try {
2133
+ return await operation();
2134
+ } finally {
2135
+ context.active = false;
2136
+ }
2137
+ });
2138
+ }
2139
+ function activeCache() {
2140
+ const context = cacheContexts.getStore();
2141
+ if (!context?.active)
2142
+ throw new Error("pgredis binding is unavailable outside a function request");
2143
+ return context.cache;
2144
+ }
2145
+ function assertCacheKey(key) {
2146
+ if (typeof key !== "string" || key.length < 1 || key.length > MAX_KEY_CHARACTERS) {
2147
+ throw new TypeError(`pgredis key must contain between 1 and ${MAX_KEY_CHARACTERS} characters`);
2148
+ }
2149
+ }
2150
+ function assertTtl(ttlMs) {
2151
+ if (ttlMs === null || ttlMs === undefined)
2152
+ return;
2153
+ if (!Number.isSafeInteger(ttlMs) || ttlMs < 0)
2154
+ throw new TypeError("pgredis ttlMs must be a non-negative safe integer or null");
2155
+ if (ttlMs > MAX_TTL_MS)
2156
+ throw new RangeError(`pgredis ttlMs must not exceed ${MAX_TTL_MS}`);
2157
+ }
2158
+ function serializeCacheValue(cacheValue) {
2159
+ const serializedValue = JSON.stringify(cacheValue);
2160
+ if (serializedValue === undefined)
2161
+ throw new TypeError("pgredis value must be JSON serializable");
2162
+ if (new TextEncoder().encode(serializedValue).byteLength > MAX_VALUE_BYTES) {
2163
+ throw new RangeError(`pgredis value must not exceed ${MAX_VALUE_BYTES} bytes`);
2164
+ }
2165
+ return serializedValue;
2166
+ }
2167
+ function parseStoredValue(stored) {
2168
+ return JSON.parse(stored.serialized_value);
2169
+ }
2170
+ async function deleteExpiredKey(query, key) {
2171
+ await query(`delete from ${CACHE_TABLE} where namespace = $1 and key = $2 and expires_at <= clock_timestamp()`, [CACHE_NAMESPACE, key]);
2172
+ }
2173
+ async function upsertWithoutTtl(query, key, serializedValue) {
2174
+ await query(`insert into ${CACHE_TABLE} (namespace, key, value, expires_at, updated_at)
2175
+ values ($1, $2, $3::jsonb, null, now())
2176
+ on conflict (namespace, key) do update set value = excluded.value, expires_at = null, updated_at = now()`, [CACHE_NAMESPACE, key, serializedValue]);
2177
+ }
2178
+
1987
2179
  // src/vendor/tinbase/functions/handler.ts
1988
2180
  class FunctionsHandler {
1989
2181
  functions;
1990
2182
  env;
1991
- constructor(functions, env) {
2183
+ pgredis;
2184
+ constructor(functions, env, pgredis) {
1992
2185
  this.functions = functions;
1993
2186
  this.env = env;
2187
+ this.pgredis = pgredis;
1994
2188
  }
1995
2189
  register(name, fn) {
1996
2190
  this.functions.set(name, fn);
@@ -2008,7 +2202,8 @@ class FunctionsHandler {
2008
2202
  return json2(404, { error: `function "${name}" not found` });
2009
2203
  }
2010
2204
  try {
2011
- const res = await runWithDenoEnv(this.env, () => Promise.resolve(fn(req, { auth: ctx, env: this.env })));
2205
+ const invoke = () => Promise.resolve(fn(req, { auth: ctx, env: this.env }));
2206
+ const res = await runWithDenoEnv(this.env, () => runWithPgredisCache(this.pgredis, invoke));
2012
2207
  if (!(res instanceof Response)) {
2013
2208
  return json2(500, { error: `function "${name}" did not return a Response` });
2014
2209
  }
@@ -2550,7 +2745,24 @@ grant execute on function realtime.send(jsonb, text, text, boolean) to anon, aut
2550
2745
  // src/vendor/tinbase/db/emulated.ts
2551
2746
  var PGMQ_SQL = `
2552
2747
  create schema if not exists pgmq;
2553
- grant usage on schema pgmq to anon, authenticated, service_role;
2748
+ revoke all on schema pgmq from public, anon, authenticated;
2749
+ grant usage on schema pgmq to service_role;
2750
+
2751
+ create table if not exists pgmq.meta (
2752
+ queue_name text primary key,
2753
+ physical_name text not null unique,
2754
+ is_partitioned boolean not null default false,
2755
+ is_unlogged boolean not null default false,
2756
+ created_at timestamptz not null default now()
2757
+ );
2758
+
2759
+ -- Preserve queues created by older Lite versions. Their physical names were
2760
+ -- the queue names, so only the already-truncated identifier can be recovered.
2761
+ insert into pgmq.meta (queue_name, physical_name)
2762
+ select substring(tablename from 3), substring(tablename from 3)
2763
+ from pg_tables
2764
+ where schemaname = 'pgmq' and left(tablename, 2) = 'q_'
2765
+ on conflict do nothing;
2554
2766
 
2555
2767
  do $$ begin
2556
2768
  if not exists (select 1 from pg_type t join pg_namespace n on n.oid = t.typnamespace
@@ -2561,32 +2773,78 @@ do $$ begin
2561
2773
  end if;
2562
2774
  end $$;
2563
2775
 
2776
+ create or replace function pgmq._normalize_queue_name(queue_name text)
2777
+ returns text language plpgsql immutable set search_path = pgmq, pg_catalog, public as $pgmq$
2778
+ declare normalized text := btrim(queue_name);
2779
+ begin
2780
+ if normalized is null or normalized !~ '^[a-z0-9][a-z0-9_-]{0,127}$' then
2781
+ raise exception 'invalid queue name: must match ^[a-z0-9][a-z0-9_-]{0,127}$'
2782
+ using errcode = '22023';
2783
+ end if;
2784
+ return normalized;
2785
+ end $pgmq$;
2786
+
2787
+ -- PostgreSQL identifiers are limited to 63 bytes. Keep the conventional table
2788
+ -- name for short queues and add a deterministic hash for valid 62-128 byte
2789
+ -- public names so truncation can never merge two queues.
2790
+ create or replace function pgmq._physical_queue_name(queue_name text)
2791
+ returns text language sql immutable set search_path = pgmq, pg_catalog, public as $pgmq$
2792
+ select case when length(normalized) <= 61 then normalized
2793
+ else left(normalized, 28) || '_' || md5(normalized) end
2794
+ from (select pgmq._normalize_queue_name(queue_name) as normalized) names;
2795
+ $pgmq$;
2796
+
2797
+ create or replace function pgmq._resolve_queue(queue_name text)
2798
+ returns text language plpgsql stable security definer set search_path = pgmq, pg_catalog, public as $pgmq$
2799
+ declare normalized text := pgmq._normalize_queue_name(queue_name); physical text;
2800
+ begin
2801
+ select physical_name into physical from pgmq.meta where pgmq.meta.queue_name = normalized;
2802
+ if physical is null then
2803
+ raise exception 'queue "%" does not exist', normalized using errcode = '42P01';
2804
+ end if;
2805
+ return physical;
2806
+ end $pgmq$;
2807
+
2564
2808
  create or replace function pgmq.create(queue_name text) returns void language plpgsql security definer set search_path = pgmq, pg_catalog, public as $pgmq$
2809
+ declare normalized text := pgmq._normalize_queue_name(queue_name); physical text := pgmq._physical_queue_name(queue_name);
2565
2810
  begin
2811
+ insert into pgmq.meta (queue_name, physical_name) values (normalized, physical)
2812
+ on conflict do nothing;
2813
+ select physical_name into physical from pgmq.meta where pgmq.meta.queue_name = normalized;
2566
2814
  execute format('create table if not exists pgmq.%I (
2567
2815
  msg_id bigint generated always as identity primary key,
2568
2816
  read_ct integer not null default 0,
2569
2817
  enqueued_at timestamptz not null default now(),
2570
2818
  vt timestamptz not null default now(),
2571
- message jsonb)', 'q_' || queue_name);
2819
+ message jsonb)', 'q_' || physical);
2572
2820
  execute format('create table if not exists pgmq.%I (
2573
2821
  msg_id bigint primary key, read_ct integer not null default 0,
2574
2822
  enqueued_at timestamptz not null, archived_at timestamptz not null default now(),
2575
- vt timestamptz, message jsonb)', 'a_' || queue_name);
2823
+ vt timestamptz, message jsonb)', 'a_' || physical);
2576
2824
  end $pgmq$;
2577
2825
 
2578
2826
  create or replace function pgmq.send(queue_name text, msg jsonb, delay integer default 0)
2579
2827
  returns bigint language plpgsql security definer set search_path = pgmq, pg_catalog, public as $pgmq$
2580
- declare id bigint;
2828
+ declare id bigint; physical text := pgmq._resolve_queue(queue_name);
2581
2829
  begin
2582
- perform pgmq.create(queue_name);
2583
- execute format('insert into pgmq.%I (vt, message) values (now() + make_interval(secs => $1), $2) returning msg_id', 'q_' || queue_name)
2830
+ execute format('insert into pgmq.%I (vt, message) values (now() + make_interval(secs => $1), $2) returning msg_id', 'q_' || physical)
2584
2831
  into id using delay, msg;
2585
2832
  return id;
2586
2833
  end $pgmq$;
2587
2834
 
2835
+ create or replace function pgmq.send_batch(queue_name text, msgs jsonb[], delay integer default 0)
2836
+ returns setof bigint language plpgsql security definer set search_path = pgmq, pg_catalog, public as $pgmq$
2837
+ declare queue_message jsonb;
2838
+ begin
2839
+ perform pgmq._resolve_queue(queue_name);
2840
+ foreach queue_message in array msgs loop
2841
+ return next pgmq.send(queue_name, queue_message, delay);
2842
+ end loop;
2843
+ end $pgmq$;
2844
+
2588
2845
  create or replace function pgmq.read(queue_name text, vt integer, qty integer)
2589
2846
  returns setof pgmq.message_record language plpgsql security definer set search_path = pgmq, pg_catalog, public as $pgmq$
2847
+ declare physical text := pgmq._resolve_queue(queue_name);
2590
2848
  begin
2591
2849
  return query execute format($fmt$
2592
2850
  with cte as (
@@ -2595,52 +2853,68 @@ begin
2595
2853
  update pgmq.%I m set vt = now() + make_interval(secs => $2), read_ct = read_ct + 1
2596
2854
  from cte where m.msg_id = cte.msg_id
2597
2855
  returning m.msg_id, m.read_ct, m.enqueued_at, m.vt, m.message
2598
- $fmt$, 'q_' || queue_name, 'q_' || queue_name) using qty, vt;
2856
+ $fmt$, 'q_' || physical, 'q_' || physical) using qty, vt;
2599
2857
  end $pgmq$;
2600
2858
 
2601
2859
  create or replace function pgmq.pop(queue_name text)
2602
2860
  returns setof pgmq.message_record language plpgsql security definer set search_path = pgmq, pg_catalog, public as $pgmq$
2861
+ declare physical text := pgmq._resolve_queue(queue_name);
2603
2862
  begin
2604
2863
  return query execute format($fmt$
2605
2864
  with cte as (select msg_id from pgmq.%I where vt <= now() order by msg_id limit 1 for update skip locked)
2606
2865
  delete from pgmq.%I m using cte where m.msg_id = cte.msg_id
2607
2866
  returning m.msg_id, m.read_ct, m.enqueued_at, m.vt, m.message
2608
- $fmt$, 'q_' || queue_name, 'q_' || queue_name);
2867
+ $fmt$, 'q_' || physical, 'q_' || physical);
2868
+ end $pgmq$;
2869
+
2870
+ create or replace function pgmq.set_vt(queue_name text, msg_id bigint, vt_offset integer)
2871
+ returns setof pgmq.message_record language plpgsql security definer set search_path = pgmq, pg_catalog, public as $pgmq$
2872
+ declare physical text := pgmq._resolve_queue(queue_name);
2873
+ begin
2874
+ return query execute format($fmt$
2875
+ update pgmq.%I m set vt = now() + make_interval(secs => $1)
2876
+ where m.msg_id = $2
2877
+ returning m.msg_id, m.read_ct, m.enqueued_at, m.vt, m.message
2878
+ $fmt$, 'q_' || physical) using vt_offset, msg_id;
2609
2879
  end $pgmq$;
2610
2880
 
2611
2881
  create or replace function pgmq.delete(queue_name text, msg_id bigint)
2612
2882
  returns boolean language plpgsql security definer set search_path = pgmq, pg_catalog, public as $pgmq$
2613
- declare n integer;
2883
+ declare n integer; physical text := pgmq._resolve_queue(queue_name);
2614
2884
  begin
2615
- execute format('delete from pgmq.%I where msg_id = $1', 'q_' || queue_name) using msg_id;
2885
+ execute format('delete from pgmq.%I where msg_id = $1', 'q_' || physical) using msg_id;
2616
2886
  get diagnostics n = row_count;
2617
2887
  return n > 0;
2618
2888
  end $pgmq$;
2619
2889
 
2620
2890
  create or replace function pgmq.archive(queue_name text, msg_id bigint)
2621
2891
  returns boolean language plpgsql security definer set search_path = pgmq, pg_catalog, public as $pgmq$
2622
- declare n integer;
2892
+ declare n integer; physical text := pgmq._resolve_queue(queue_name);
2623
2893
  begin
2624
2894
  execute format($fmt$
2625
2895
  with del as (delete from pgmq.%I where msg_id = $1 returning *)
2626
2896
  insert into pgmq.%I (msg_id, read_ct, enqueued_at, vt, message)
2627
2897
  select msg_id, read_ct, enqueued_at, vt, message from del
2628
- $fmt$, 'q_' || queue_name, 'a_' || queue_name) using msg_id;
2898
+ $fmt$, 'q_' || physical, 'a_' || physical) using msg_id;
2629
2899
  get diagnostics n = row_count;
2630
2900
  return n > 0;
2631
2901
  end $pgmq$;
2632
2902
 
2633
2903
  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$
2904
+ declare normalized text := pgmq._normalize_queue_name(queue_name); physical text;
2634
2905
  begin
2635
- execute format('drop table if exists pgmq.%I', 'q_' || queue_name);
2636
- execute format('drop table if exists pgmq.%I', 'a_' || queue_name);
2906
+ select physical_name into physical from pgmq.meta where pgmq.meta.queue_name = normalized;
2907
+ if physical is null then return false; end if;
2908
+ execute format('drop table if exists pgmq.%I', 'q_' || physical);
2909
+ execute format('drop table if exists pgmq.%I', 'a_' || physical);
2910
+ delete from pgmq.meta where pgmq.meta.queue_name = normalized;
2637
2911
  return true;
2638
2912
  end $pgmq$;
2639
2913
 
2640
2914
  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$
2641
- declare n bigint;
2915
+ declare n bigint; physical text := pgmq._resolve_queue(queue_name);
2642
2916
  begin
2643
- execute format('delete from pgmq.%I', 'q_' || queue_name);
2917
+ execute format('delete from pgmq.%I', 'q_' || physical);
2644
2918
  get diagnostics n = row_count;
2645
2919
  return n;
2646
2920
  end $pgmq$;
@@ -2648,11 +2922,47 @@ end $pgmq$;
2648
2922
  create or replace function pgmq.list_queues()
2649
2923
  returns table(queue_name text, is_partitioned boolean, is_unlogged boolean, created_at timestamptz)
2650
2924
  language sql security definer set search_path = pgmq, pg_catalog, public as $pgmq$
2651
- select substring(tablename from 3), false, false, now()
2652
- from pg_tables where schemaname = 'pgmq' and left(tablename, 2) = 'q_';
2925
+ select queue_name, is_partitioned, is_unlogged, created_at from pgmq.meta;
2653
2926
  $pgmq$;
2654
2927
 
2655
- grant execute on all functions in schema pgmq to anon, authenticated, service_role;
2928
+ revoke all on all functions in schema pgmq from public, anon, authenticated;
2929
+ grant execute on all functions in schema pgmq to service_role;
2930
+
2931
+ create schema if not exists pgmq_public;
2932
+ grant usage on schema pgmq_public to anon, authenticated, service_role;
2933
+
2934
+ create or replace function pgmq_public.send(queue_name text, message jsonb, sleep_seconds integer default 0)
2935
+ returns setof bigint language sql volatile security definer set search_path = pgmq, pg_catalog, public as $pgmq_public$
2936
+ select * from pgmq.send(queue_name, message, sleep_seconds);
2937
+ $pgmq_public$;
2938
+
2939
+ create or replace function pgmq_public.send_batch(queue_name text, messages 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_batch(queue_name, messages, sleep_seconds);
2942
+ $pgmq_public$;
2943
+
2944
+ create or replace function pgmq_public.read(queue_name text, sleep_seconds integer, n integer)
2945
+ returns setof pgmq.message_record language sql volatile security definer set search_path = pgmq, pg_catalog, public as $pgmq_public$
2946
+ select * from pgmq.read(queue_name, sleep_seconds, n);
2947
+ $pgmq_public$;
2948
+
2949
+ create or replace function pgmq_public.pop(queue_name text)
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.pop(queue_name);
2952
+ $pgmq_public$;
2953
+
2954
+ create or replace function pgmq_public.archive(queue_name text, message_id bigint)
2955
+ returns boolean language sql volatile security definer set search_path = pgmq, pg_catalog, public as $pgmq_public$
2956
+ select pgmq.archive(queue_name, message_id);
2957
+ $pgmq_public$;
2958
+
2959
+ create or replace function pgmq_public."delete"(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.delete(queue_name, message_id);
2962
+ $pgmq_public$;
2963
+
2964
+ revoke all on all functions in schema pgmq_public from public;
2965
+ grant execute on all functions in schema pgmq_public to anon, authenticated, service_role;
2656
2966
  `;
2657
2967
  var CRON_SQL = `
2658
2968
  create schema if not exists cron;
@@ -2982,9 +3292,16 @@ function pickTag(text, base) {
2982
3292
  // src/vendor/tinbase/db/pglite-engine.ts
2983
3293
  import { mkdir, open, readFile, unlink } from "fs/promises";
2984
3294
  import { dirname, resolve } from "path";
3295
+
3296
+ // src/standalone-assets-protocol.ts
3297
+ var STANDALONE_PGLITE_ASSETS = Symbol.for("supacloud-lite.pglite-standalone-assets");
3298
+
3299
+ // src/vendor/tinbase/db/pglite-engine.ts
2985
3300
  async function createPgliteEngine(dataDir) {
2986
3301
  const releaseLock = await acquireDataDirLock(dataDir);
2987
3302
  let PGlite, extensions;
3303
+ const standaloneAssets = getStandaloneAssets();
3304
+ let cleanupStandaloneBundles = async () => {};
2988
3305
  try {
2989
3306
  ({ PGlite } = await import("@electric-sql/pglite"));
2990
3307
  const [uuid_ossp, pgcrypto, citext, pg_trgm, ltree, hstore, fuzzystrmatch] = await Promise.all([
@@ -2997,19 +3314,48 @@ async function createPgliteEngine(dataDir) {
2997
3314
  import("@electric-sql/pglite/contrib/fuzzystrmatch").then((m) => m.fuzzystrmatch)
2998
3315
  ]);
2999
3316
  extensions = { uuid_ossp, pgcrypto, citext, pg_trgm, ltree, hstore, fuzzystrmatch };
3000
- } catch (e) {
3317
+ } catch (error) {
3001
3318
  await releaseLock();
3002
- if (e instanceof Error && /wasm/.test(e.message))
3003
- throw e;
3319
+ if (error instanceof Error && /wasm/.test(error.message))
3320
+ throw error;
3004
3321
  throw new Error("the PGlite WASM engine is not available in this build");
3005
3322
  }
3323
+ if (standaloneAssets) {
3324
+ try {
3325
+ const standaloneBundles = await standaloneAssets.prepareExtensionBundles();
3326
+ cleanupStandaloneBundles = standaloneBundles.cleanup;
3327
+ extensions = {
3328
+ uuid_ossp: withEmbeddedBundle(extensions.uuid_ossp, standaloneBundles.bundles.uuid_ossp),
3329
+ pgcrypto: withEmbeddedBundle(extensions.pgcrypto, standaloneBundles.bundles.pgcrypto),
3330
+ citext: withEmbeddedBundle(extensions.citext, standaloneBundles.bundles.citext),
3331
+ pg_trgm: withEmbeddedBundle(extensions.pg_trgm, standaloneBundles.bundles.pg_trgm),
3332
+ ltree: withEmbeddedBundle(extensions.ltree, standaloneBundles.bundles.ltree),
3333
+ hstore: withEmbeddedBundle(extensions.hstore, standaloneBundles.bundles.hstore),
3334
+ fuzzystrmatch: withEmbeddedBundle(extensions.fuzzystrmatch, standaloneBundles.bundles.fuzzystrmatch)
3335
+ };
3336
+ } catch (error) {
3337
+ await removePreparedBundles(cleanupStandaloneBundles);
3338
+ await releaseLock();
3339
+ throw error;
3340
+ }
3341
+ }
3006
3342
  let pg;
3007
3343
  try {
3008
- pg = new PGlite({ dataDir, extensions });
3344
+ pg = new PGlite({
3345
+ dataDir,
3346
+ extensions,
3347
+ ...standaloneAssets ? {
3348
+ pgliteWasmModule: standaloneAssets.pgliteWasmModule,
3349
+ initdbWasmModule: standaloneAssets.initdbWasmModule,
3350
+ fsBundle: standaloneAssets.fsBundle
3351
+ } : {}
3352
+ });
3009
3353
  await pg.waitReady;
3010
3354
  } catch (error) {
3011
3355
  await releaseLock();
3012
3356
  throw error;
3357
+ } finally {
3358
+ await removePreparedBundles(cleanupStandaloneBundles);
3013
3359
  }
3014
3360
  let closed = false;
3015
3361
  return {
@@ -3048,6 +3394,25 @@ async function createPgliteEngine(dataDir) {
3048
3394
  }
3049
3395
  };
3050
3396
  }
3397
+ function getStandaloneAssets() {
3398
+ return globalThis[STANDALONE_PGLITE_ASSETS];
3399
+ }
3400
+ function withEmbeddedBundle(extension, bundlePath) {
3401
+ return {
3402
+ ...extension,
3403
+ setup: async (pg, emscriptenOpts, clientOnly) => ({
3404
+ ...await extension.setup(pg, emscriptenOpts, clientOnly),
3405
+ bundlePath
3406
+ })
3407
+ };
3408
+ }
3409
+ async function removePreparedBundles(cleanup) {
3410
+ try {
3411
+ await cleanup();
3412
+ } catch (error) {
3413
+ console.error("Unable to remove temporary PGlite extension bundles:", error);
3414
+ }
3415
+ }
3051
3416
  async function acquireDataDirLock(dataDir) {
3052
3417
  if (!dataDir || dataDir.includes("://"))
3053
3418
  return async () => {};
@@ -3055,21 +3420,7 @@ async function acquireDataDirLock(dataDir) {
3055
3420
  const lockPath = `${absoluteDataDir}.supacloud-lite.lock`;
3056
3421
  await mkdir(dirname(absoluteDataDir), { recursive: true, mode: 448 });
3057
3422
  const nonce = crypto.randomUUID();
3058
- let handle;
3059
- try {
3060
- handle = await open(lockPath, "wx", 384);
3061
- await handle.writeFile(`${JSON.stringify({ pid: process.pid, nonce, createdAt: new Date().toISOString() })}
3062
- `);
3063
- } catch (error) {
3064
- await handle?.close().catch(() => {});
3065
- if (error.code !== "EEXIST")
3066
- throw error;
3067
- const owner = await readLockOwner(lockPath);
3068
- if (owner && isProcessAlive(owner.pid)) {
3069
- throw new Error(`PGlite data directory is already in use: ${absoluteDataDir} (pid ${owner.pid})`);
3070
- }
3071
- 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.`);
3072
- }
3423
+ const handle = await createDataDirLock(absoluteDataDir, lockPath, nonce);
3073
3424
  let released = false;
3074
3425
  return async () => {
3075
3426
  if (released)
@@ -3085,10 +3436,48 @@ async function acquireDataDirLock(dataDir) {
3085
3436
  });
3086
3437
  };
3087
3438
  }
3439
+ async function createDataDirLock(absoluteDataDir, lockPath, nonce) {
3440
+ for (let attempt = 0;attempt < 2; attempt++) {
3441
+ try {
3442
+ return await writeDataDirLock(lockPath, nonce);
3443
+ } catch (error) {
3444
+ if (error.code !== "EEXIST")
3445
+ throw error;
3446
+ const owner = await readLockOwner(lockPath);
3447
+ if (owner && isProcessAlive(owner.pid))
3448
+ throw lockInUseError(absoluteDataDir, owner.pid);
3449
+ if (!owner || attempt > 0)
3450
+ throw unreadableLockError(lockPath);
3451
+ await unlink(lockPath).catch((unlinkError) => {
3452
+ if (unlinkError.code !== "ENOENT")
3453
+ throw unlinkError;
3454
+ });
3455
+ }
3456
+ }
3457
+ throw unreadableLockError(lockPath);
3458
+ }
3459
+ async function writeDataDirLock(lockPath, nonce) {
3460
+ const handle = await open(lockPath, "wx", 384);
3461
+ try {
3462
+ await handle.writeFile(`${JSON.stringify({ pid: process.pid, nonce, createdAt: new Date().toISOString() })}
3463
+ `);
3464
+ return handle;
3465
+ } catch (error) {
3466
+ await handle.close().catch(() => {});
3467
+ await unlink(lockPath).catch(() => {});
3468
+ throw error;
3469
+ }
3470
+ }
3471
+ function lockInUseError(dataDir, pid) {
3472
+ return new Error(`PGlite data directory is already in use: ${dataDir} (pid ${pid})`);
3473
+ }
3474
+ function unreadableLockError(lockPath) {
3475
+ return new Error(`PGlite data directory has an unreadable lock: ${lockPath}. Confirm no SupaCloud Lite process is using it, then remove the lock manually.`);
3476
+ }
3088
3477
  async function readLockOwner(lockPath) {
3089
3478
  try {
3090
3479
  const value = JSON.parse(await readFile(lockPath, "utf8"));
3091
- return typeof value.pid === "number" && Number.isInteger(value.pid) && typeof value.nonce === "string" ? { pid: value.pid, nonce: value.nonce } : null;
3480
+ return typeof value.pid === "number" && Number.isInteger(value.pid) && value.pid > 0 && typeof value.nonce === "string" ? { pid: value.pid, nonce: value.nonce } : null;
3092
3481
  } catch {
3093
3482
  return null;
3094
3483
  }
@@ -3137,6 +3526,9 @@ class Database {
3137
3526
  exec(sql) {
3138
3527
  return this.engine.exec(sql);
3139
3528
  }
3529
+ transaction(fn) {
3530
+ return this.engine.transaction((tx) => fn((sql, params) => tx.query(sql, params)));
3531
+ }
3140
3532
  async withContext(ctx, fn) {
3141
3533
  return this.engine.transaction(async (tx) => {
3142
3534
  await tx.query(`select set_config('role', $1, true),
@@ -6905,6 +7297,7 @@ class RetentionService {
6905
7297
  await this.run(`delete from auth.one_time_tokens where expires_at < now()`);
6906
7298
  await this.run(`delete from auth.mfa_challenges where expires_at < now()`);
6907
7299
  await this.run(`delete from auth.flow_state where expires_at < now()`);
7300
+ await this.run(`delete from public.supacloud_pgredis_kv where expires_at <= now()`);
6908
7301
  if (this.refreshTokenDays > 0) {
6909
7302
  const cutoff = new Date(now.getTime() - this.refreshTokenDays * DAY_MS).toISOString();
6910
7303
  await this.run(`delete from auth.refresh_tokens where revoked = true and updated_at < $1`, [cutoff]);
@@ -7388,11 +7781,13 @@ async function createBackend(config = {}) {
7388
7781
  } catch (e) {
7389
7782
  await failStartup(e);
7390
7783
  }
7784
+ const pgredis = await PgredisCache.create(db).catch(failStartup);
7391
7785
  const now = Math.floor(Date.now() / 1000);
7392
7786
  const tenYears = 10 * 365 * 24 * 3600;
7393
7787
  const anonKey = await signJwt({ iss: "supabase", ref: "local", role: "anon", iat: now, exp: now + tenYears }, jwtSecret);
7394
7788
  const serviceRoleKey = await signJwt({ iss: "supabase", ref: "local", role: "service_role", iat: now, exp: now + tenYears }, jwtSecret);
7395
- const rest = new RestHandler(db, { exposedSchemas: config.dbSchemas, maxRows: config.maxRows });
7789
+ const exposedSchemas = [...new Set(config.dbSchemas ?? ["public", "pgmq_public"])];
7790
+ const rest = new RestHandler(db, { exposedSchemas, maxRows: config.maxRows });
7396
7791
  const exposed = isNetworkExposed(config.host);
7397
7792
  const inbox = config.mailer || exposed ? null : new InboxMailer((msg) => log(config.logMailBody ? `[mail] to=${msg.to} subject="${msg.subject}"
7398
7793
  ${msg.text}` : `[mail] to=${msg.to} subject="${msg.subject}"`));
@@ -7453,7 +7848,12 @@ ${msg.text}` : `[mail] to=${msg.to} subject="${msg.subject}"`));
7453
7848
  ...config.functionEnv ?? {}
7454
7849
  };
7455
7850
  installDenoShim();
7456
- const functions = new FunctionsHandler(fnMap, fnEnv);
7851
+ try {
7852
+ installPgredisShim();
7853
+ } catch (error) {
7854
+ await failStartup(error);
7855
+ }
7856
+ const functions = new FunctionsHandler(fnMap, fnEnv, pgredis);
7457
7857
  async function resolveContext(req, url) {
7458
7858
  const authz = req.headers.get("authorization");
7459
7859
  const bearer = authz?.toLowerCase().startsWith("bearer ") ? authz.slice(7) : null;
@@ -8736,6 +9136,392 @@ async function findEphemeralPort(host = "127.0.0.1") {
8736
9136
  return port;
8737
9137
  }
8738
9138
 
9139
+ // src/snapshot.ts
9140
+ 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";
9141
+ import { dirname as dirname4, join as join8, parse as parse2, relative as relative2, resolve as resolve3, sep as sep2 } from "path";
9142
+ import { create as createTar, extract as extractTar } from "tar";
9143
+ var SNAPSHOT_FORMAT = "supacloud-lite-snapshot";
9144
+ var SNAPSHOT_VERSION = 1;
9145
+ async function createSnapshot(options) {
9146
+ const paths = normalizePaths(options.paths);
9147
+ await assertSnapshotPaths(paths);
9148
+ await assertNoDataDirectoryLock(paths);
9149
+ const manifest = {
9150
+ format: SNAPSHOT_FORMAT,
9151
+ version: SNAPSHOT_VERSION,
9152
+ createdAt: new Date().toISOString(),
9153
+ packageVersion: options.packageVersion,
9154
+ storageBackend: options.storageBackend,
9155
+ includesDatabase: Boolean(paths.dataDir),
9156
+ includesLocalStorage: options.storageBackend === "fs",
9157
+ includesSecrets: true
9158
+ };
9159
+ const output = resolve3(options.output);
9160
+ if (await existingInfo(output))
9161
+ throw new Error(`snapshot output already exists: ${output}`);
9162
+ await mkdir6(dirname4(output), { recursive: true });
9163
+ const stagingRoot = await mkdtemp(join8(dirname4(output), ".supacloud-lite-snapshot-"));
9164
+ try {
9165
+ await writeFile5(join8(stagingRoot, "manifest.json"), `${JSON.stringify(manifest, null, 2)}
9166
+ `);
9167
+ await stageFile(paths.secretsFile, join8(stagingRoot, "secrets.json"));
9168
+ if (paths.dataDir)
9169
+ await stageDirectory(paths.dataDir, join8(stagingRoot, "database"));
9170
+ if (options.storageBackend === "fs")
9171
+ await stageDirectory(paths.storageDir, join8(stagingRoot, "storage"));
9172
+ const entries = ["manifest.json", "secrets.json"];
9173
+ if (paths.dataDir)
9174
+ entries.push("database");
9175
+ if (options.storageBackend === "fs")
9176
+ entries.push("storage");
9177
+ await createTar({ cwd: stagingRoot, file: output, gzip: true, portable: true }, entries);
9178
+ if (process.platform !== "win32")
9179
+ await chmod2(output, 384);
9180
+ return manifest;
9181
+ } catch (error) {
9182
+ await rm3(output, { force: true });
9183
+ throw error;
9184
+ } finally {
9185
+ await rm3(stagingRoot, { recursive: true, force: true });
9186
+ }
9187
+ }
9188
+ async function restoreSnapshot(options) {
9189
+ const paths = normalizePaths(options.paths);
9190
+ await assertSnapshotPaths(paths, { requireSecrets: false, allowMissingState: true });
9191
+ await assertNoDataDirectoryLock(paths);
9192
+ const stagingRoot = await mkdtemp(join8(dirname4(paths.stateDir), ".supacloud-lite-restore-"));
9193
+ const payloadRoot = join8(stagingRoot, "payload");
9194
+ const rollbackId = crypto.randomUUID();
9195
+ const rollbackPaths = [];
9196
+ try {
9197
+ await mkdir6(payloadRoot, { recursive: true });
9198
+ await extractTar({
9199
+ cwd: payloadRoot,
9200
+ file: resolve3(options.input),
9201
+ preserveOwner: false,
9202
+ preservePaths: false,
9203
+ strict: true,
9204
+ unlink: true,
9205
+ filter: (path, entry) => {
9206
+ const normalized = path.replaceAll("\\", "/");
9207
+ const allowedPath = normalized === "manifest.json" || normalized === "secrets.json" || normalized === "database" || normalized.startsWith("database/") || normalized === "storage" || normalized.startsWith("storage/");
9208
+ if (!allowedPath || normalized.startsWith("/") || normalized.split("/").includes("..")) {
9209
+ throw new Error(`snapshot contains an unsafe path: ${path}`);
9210
+ }
9211
+ const entryType = "type" in entry ? entry.type : undefined;
9212
+ if (entryType !== "File" && entryType !== "Directory") {
9213
+ throw new Error(`snapshot contains an unsupported entry type: ${entryType ?? "unknown"}`);
9214
+ }
9215
+ return true;
9216
+ }
9217
+ });
9218
+ await assertNoSymlinks(payloadRoot);
9219
+ const manifest = await readManifest(payloadRoot);
9220
+ if (manifest.storageBackend !== options.storageBackend) {
9221
+ throw new Error(`snapshot storage backend is ${manifest.storageBackend}, but the target uses ${options.storageBackend}; ` + "restore with the matching --storage-backend value");
9222
+ }
9223
+ if (manifest.includesDatabase !== Boolean(paths.dataDir)) {
9224
+ throw new Error("snapshot database mode does not match the target; do not restore a persistent snapshot into --memory");
9225
+ }
9226
+ if (manifest.includesLocalStorage !== (options.storageBackend === "fs")) {
9227
+ throw new Error("snapshot storage payload does not match the target storage backend");
9228
+ }
9229
+ await assertSnapshotPayload(payloadRoot, manifest);
9230
+ if (manifest.includesDatabase)
9231
+ await mkdir6(join8(payloadRoot, "database"), { recursive: true });
9232
+ if (manifest.includesLocalStorage)
9233
+ await mkdir6(join8(payloadRoot, "storage"), { recursive: true });
9234
+ await assertRestoreTargets(paths, manifest, options.force === true);
9235
+ const stateStage = join8(stagingRoot, "state");
9236
+ await mkdir6(stateStage, { recursive: true });
9237
+ await copyEntry(join8(payloadRoot, "secrets.json"), join8(stateStage, "secrets.json"));
9238
+ if (paths.dataDir && isWithin(paths.stateDir, paths.dataDir)) {
9239
+ await copyEntry(join8(payloadRoot, "database"), join8(stateStage, relative2(paths.stateDir, paths.dataDir)));
9240
+ }
9241
+ if (options.storageBackend === "fs" && isWithin(paths.stateDir, paths.storageDir)) {
9242
+ await copyEntry(join8(payloadRoot, "storage"), join8(stateStage, relative2(paths.stateDir, paths.storageDir)));
9243
+ }
9244
+ const swaps = [];
9245
+ try {
9246
+ await applyDirectorySwap(stateStage, paths.stateDir, options.force === true, rollbackId, swaps);
9247
+ if (paths.dataDir && !isWithin(paths.stateDir, paths.dataDir)) {
9248
+ await applyDirectorySwap(join8(payloadRoot, "database"), paths.dataDir, options.force === true, rollbackId, swaps);
9249
+ }
9250
+ if (options.storageBackend === "fs" && !isWithin(paths.stateDir, paths.storageDir)) {
9251
+ await applyDirectorySwap(join8(payloadRoot, "storage"), paths.storageDir, options.force === true, rollbackId, swaps);
9252
+ }
9253
+ } catch (error) {
9254
+ await rollbackDirectorySwaps(swaps);
9255
+ throw error;
9256
+ }
9257
+ rollbackPaths.push(...swaps.flatMap((swap) => swap.rollbackPath ? [swap.rollbackPath] : []));
9258
+ if (process.platform !== "win32") {
9259
+ await hardenRestoredTree(paths.stateDir);
9260
+ if (paths.dataDir && !isWithin(paths.stateDir, paths.dataDir))
9261
+ await hardenRestoredTree(paths.dataDir);
9262
+ if (options.storageBackend === "fs" && !isWithin(paths.stateDir, paths.storageDir)) {
9263
+ await hardenRestoredTree(paths.storageDir);
9264
+ }
9265
+ }
9266
+ return { manifest, rollbackPaths };
9267
+ } catch (error) {
9268
+ throw error instanceof Error ? error : new Error(String(error));
9269
+ } finally {
9270
+ await rm3(stagingRoot, { recursive: true, force: true });
9271
+ }
9272
+ }
9273
+ function normalizePaths(paths) {
9274
+ return {
9275
+ ...paths,
9276
+ projectDir: resolve3(paths.projectDir),
9277
+ stateDir: resolve3(paths.stateDir),
9278
+ dataDir: paths.dataDir ? resolve3(paths.dataDir) : undefined,
9279
+ storageDir: resolve3(paths.storageDir),
9280
+ secretsFile: resolve3(paths.secretsFile)
9281
+ };
9282
+ }
9283
+ async function assertSnapshotPaths(paths, options = {}) {
9284
+ try {
9285
+ if (paths.stateDir === parse2(paths.stateDir).root)
9286
+ throw new Error("snapshot state directory must not be the filesystem root");
9287
+ if (paths.secretsFile !== join8(paths.stateDir, "secrets.json"))
9288
+ throw new Error("snapshot secrets path must be inside the state directory");
9289
+ const stateInfo = await lstat2(paths.stateDir);
9290
+ if (!stateInfo.isDirectory() || stateInfo.isSymbolicLink())
9291
+ throw new Error(`state directory must be a real directory: ${paths.stateDir}`);
9292
+ } catch (error) {
9293
+ if (error.code !== "ENOENT" || options.allowMissingState !== true)
9294
+ throw error;
9295
+ }
9296
+ if (paths.dataDir && paths.storageDir && pathsOverlap(paths.dataDir, paths.storageDir)) {
9297
+ throw new Error("database and storage directories must not overlap");
9298
+ }
9299
+ await assertDirectoryOrMissing(paths.dataDir);
9300
+ await assertDirectoryOrMissing(paths.storageDir);
9301
+ if (options.requireSecrets !== false) {
9302
+ const secretInfo = await lstat2(paths.secretsFile);
9303
+ if (!secretInfo.isFile() || secretInfo.isSymbolicLink())
9304
+ throw new Error(`secrets file must be a regular file: ${paths.secretsFile}`);
9305
+ }
9306
+ }
9307
+ async function assertDirectoryOrMissing(path) {
9308
+ if (!path)
9309
+ return;
9310
+ if (resolve3(path) === parse2(resolve3(path)).root)
9311
+ throw new Error(`snapshot path must not be the filesystem root: ${path}`);
9312
+ try {
9313
+ const info = await lstat2(path);
9314
+ if (!info.isDirectory() || info.isSymbolicLink())
9315
+ throw new Error(`snapshot path must be a real directory: ${path}`);
9316
+ } catch (error) {
9317
+ if (error.code !== "ENOENT")
9318
+ throw error;
9319
+ }
9320
+ }
9321
+ async function assertNoDataDirectoryLock(paths) {
9322
+ if (!paths.dataDir)
9323
+ return;
9324
+ const lockPath = `${paths.dataDir}.supacloud-lite.lock`;
9325
+ try {
9326
+ await lstat2(lockPath);
9327
+ 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`);
9328
+ } catch (error) {
9329
+ if (error.code !== "ENOENT")
9330
+ throw error;
9331
+ }
9332
+ }
9333
+ async function stageDirectory(root, destination) {
9334
+ try {
9335
+ const info = await lstat2(root);
9336
+ if (!info.isDirectory() || info.isSymbolicLink())
9337
+ throw new Error(`snapshot path must be a real directory: ${root}`);
9338
+ } catch (error) {
9339
+ if (error.code === "ENOENT") {
9340
+ await mkdir6(destination, { recursive: true });
9341
+ return;
9342
+ }
9343
+ throw error;
9344
+ }
9345
+ await mkdir6(destination, { recursive: true });
9346
+ const walk = async (current, target) => {
9347
+ for (const entry of await readdir3(current, { withFileTypes: true })) {
9348
+ const fullPath = join8(current, entry.name);
9349
+ const targetPath = join8(target, entry.name);
9350
+ if (entry.isSymbolicLink())
9351
+ throw new Error(`snapshot refuses symbolic link: ${fullPath}`);
9352
+ if (entry.isDirectory()) {
9353
+ await mkdir6(targetPath, { recursive: true });
9354
+ await walk(fullPath, targetPath);
9355
+ } else if (entry.isFile()) {
9356
+ await stageFile(fullPath, targetPath);
9357
+ } else {
9358
+ throw new Error(`snapshot refuses unsupported filesystem entry: ${fullPath}`);
9359
+ }
9360
+ }
9361
+ };
9362
+ await walk(root, destination);
9363
+ }
9364
+ async function stageFile(source, target) {
9365
+ await mkdir6(dirname4(target), { recursive: true });
9366
+ try {
9367
+ await link2(source, target);
9368
+ } catch (error) {
9369
+ const code = error.code;
9370
+ if (code !== "EXDEV" && code !== "EPERM" && code !== "EACCES")
9371
+ throw error;
9372
+ await copyFile(source, target);
9373
+ }
9374
+ }
9375
+ async function readManifest(payloadRoot) {
9376
+ let parsed;
9377
+ try {
9378
+ parsed = JSON.parse(await readFile7(join8(payloadRoot, "manifest.json"), "utf8"));
9379
+ } catch (error) {
9380
+ throw new Error(`invalid snapshot manifest: ${error instanceof Error ? error.message : String(error)}`);
9381
+ }
9382
+ if (!isSnapshotManifest(parsed))
9383
+ throw new Error("unsupported or invalid SupaCloud Lite snapshot manifest");
9384
+ return parsed;
9385
+ }
9386
+ function isSnapshotManifest(value) {
9387
+ if (!value || typeof value !== "object")
9388
+ return false;
9389
+ const candidate = value;
9390
+ 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;
9391
+ }
9392
+ async function assertSnapshotPayload(payloadRoot, manifest) {
9393
+ const required = ["manifest.json", "secrets.json"];
9394
+ for (const path of required) {
9395
+ try {
9396
+ await lstat2(join8(payloadRoot, path));
9397
+ } catch {
9398
+ throw new Error(`snapshot is missing required payload: ${path}`);
9399
+ }
9400
+ }
9401
+ const allowed = [...required];
9402
+ if (manifest.includesDatabase)
9403
+ allowed.push("database");
9404
+ if (manifest.includesLocalStorage)
9405
+ allowed.push("storage");
9406
+ for (const entry of await readdir3(payloadRoot)) {
9407
+ if (!allowed.includes(entry)) {
9408
+ throw new Error(`snapshot contains an unexpected payload entry: ${entry}`);
9409
+ }
9410
+ }
9411
+ }
9412
+ async function assertRestoreTargets(paths, manifest, force) {
9413
+ const targets = [paths.stateDir];
9414
+ if (paths.dataDir && !isWithin(paths.stateDir, paths.dataDir))
9415
+ targets.push(paths.dataDir);
9416
+ if (manifest.includesLocalStorage && !isWithin(paths.stateDir, paths.storageDir))
9417
+ targets.push(paths.storageDir);
9418
+ if (!force) {
9419
+ for (const target of targets) {
9420
+ if (await directoryHasEntries(target))
9421
+ throw new Error(`restore target is not empty: ${target}; pass --force to replace it`);
9422
+ }
9423
+ }
9424
+ }
9425
+ async function directoryHasEntries(path) {
9426
+ try {
9427
+ return (await readdir3(path)).length > 0;
9428
+ } catch (error) {
9429
+ if (error.code === "ENOENT")
9430
+ return false;
9431
+ throw error;
9432
+ }
9433
+ }
9434
+ async function applyDirectorySwap(source, target, force, rollbackId, swaps) {
9435
+ const targetInfo = await existingInfo(target);
9436
+ if (targetInfo && !targetInfo.isDirectory())
9437
+ throw new Error(`restore target is not a directory: ${target}`);
9438
+ const swap = { target };
9439
+ if (targetInfo) {
9440
+ if (!force) {
9441
+ if (await directoryHasEntries(target))
9442
+ throw new Error(`restore target is not empty: ${target}; pass --force to replace it`);
9443
+ await rm3(target, { recursive: true, force: true });
9444
+ } else {
9445
+ swap.rollbackPath = join8(dirname4(target), `.${target.split(sep2).pop() ?? "state"}.restore-${rollbackId}`);
9446
+ await rename2(target, swap.rollbackPath);
9447
+ }
9448
+ }
9449
+ try {
9450
+ await mkdir6(dirname4(target), { recursive: true });
9451
+ await rename2(source, target);
9452
+ swaps.push(swap);
9453
+ } catch (error) {
9454
+ if (swap.rollbackPath)
9455
+ await rename2(swap.rollbackPath, target).catch(() => {});
9456
+ throw error;
9457
+ }
9458
+ }
9459
+ async function rollbackDirectorySwaps(swaps) {
9460
+ for (const swap of [...swaps].reverse()) {
9461
+ await rm3(swap.target, { recursive: true, force: true });
9462
+ if (swap.rollbackPath)
9463
+ await rename2(swap.rollbackPath, swap.target);
9464
+ }
9465
+ }
9466
+ async function existingInfo(path) {
9467
+ try {
9468
+ return await lstat2(path);
9469
+ } catch (error) {
9470
+ if (error.code === "ENOENT")
9471
+ return null;
9472
+ throw error;
9473
+ }
9474
+ }
9475
+ async function copyEntry(source, target) {
9476
+ const info = await lstat2(source);
9477
+ if (info.isSymbolicLink())
9478
+ throw new Error(`snapshot refuses symbolic link: ${source}`);
9479
+ if (info.isDirectory()) {
9480
+ await mkdir6(target, { recursive: true });
9481
+ for (const entry of await readdir3(source))
9482
+ await copyEntry(join8(source, entry), join8(target, entry));
9483
+ } else if (info.isFile()) {
9484
+ await mkdir6(dirname4(target), { recursive: true });
9485
+ await Bun.write(target, Bun.file(source));
9486
+ } else
9487
+ throw new Error(`snapshot refuses unsupported filesystem entry: ${source}`);
9488
+ }
9489
+ async function hardenRestoredTree(root) {
9490
+ const info = await lstat2(root);
9491
+ if (info.isSymbolicLink())
9492
+ throw new Error(`snapshot refuses symbolic link: ${root}`);
9493
+ if (info.isDirectory()) {
9494
+ await chmod2(root, 448);
9495
+ for (const entry of await readdir3(root))
9496
+ await hardenRestoredTree(join8(root, entry));
9497
+ return;
9498
+ }
9499
+ if (info.isFile()) {
9500
+ await chmod2(root, 384);
9501
+ return;
9502
+ }
9503
+ throw new Error(`snapshot refuses unsupported filesystem entry: ${root}`);
9504
+ }
9505
+ async function assertNoSymlinks(root) {
9506
+ for (const entry of await readdir3(root, { withFileTypes: true })) {
9507
+ const fullPath = join8(root, entry.name);
9508
+ if (entry.isSymbolicLink())
9509
+ throw new Error(`snapshot refuses symbolic link in archive: ${fullPath}`);
9510
+ if (entry.isDirectory())
9511
+ await assertNoSymlinks(fullPath);
9512
+ }
9513
+ }
9514
+ function isWithin(parent, child) {
9515
+ const normalizedParent = resolve3(parent);
9516
+ const normalizedChild = resolve3(child);
9517
+ return normalizedChild !== normalizedParent && normalizedChild.startsWith(`${normalizedParent}${sep2}`);
9518
+ }
9519
+ function pathsOverlap(left, right) {
9520
+ const normalizedLeft = resolve3(left);
9521
+ const normalizedRight = resolve3(right);
9522
+ return normalizedLeft === normalizedRight || isWithin(normalizedLeft, normalizedRight) || isWithin(normalizedRight, normalizedLeft);
9523
+ }
9524
+
8739
9525
  // src/cli.ts
8740
9526
  function parseArgs(argv) {
8741
9527
  const args = [...argv];
@@ -8746,7 +9532,8 @@ function parseArgs(argv) {
8746
9532
  projectDir: process.cwd(),
8747
9533
  host: process.env.SUPACLOUD_LITE_HOST ?? "127.0.0.1",
8748
9534
  port: process.env.SUPACLOUD_LITE_PORT || process.env.PORT ? Number.parseInt(process.env.SUPACLOUD_LITE_PORT ?? process.env.PORT, 10) : 54321,
8749
- serviceRole: false
9535
+ serviceRole: false,
9536
+ force: false
8750
9537
  };
8751
9538
  for (let index = 0;index < args.length; index++) {
8752
9539
  const argument = args[index];
@@ -8765,13 +9552,13 @@ function parseArgs(argv) {
8765
9552
  else if (argument === "--site-url")
8766
9553
  options.siteUrl = next();
8767
9554
  else if (argument === "--project-dir" || argument === "--dir")
8768
- options.projectDir = resolve3(next());
9555
+ options.projectDir = resolve4(next());
8769
9556
  else if (argument === "--state-dir")
8770
- options.stateDir = resolve3(next());
9557
+ options.stateDir = resolve4(next());
8771
9558
  else if (argument === "--data-dir")
8772
- options.dataDir = resolve3(next());
9559
+ options.dataDir = resolve4(next());
8773
9560
  else if (argument === "--storage-dir")
8774
- options.storageDir = resolve3(next());
9561
+ options.storageDir = resolve4(next());
8775
9562
  else if (argument === "--storage-backend")
8776
9563
  options.storageBackend = next();
8777
9564
  else if (argument === "--s3-prefix")
@@ -8779,11 +9566,13 @@ function parseArgs(argv) {
8779
9566
  else if (argument === "--memory")
8780
9567
  options.memory = true;
8781
9568
  else if (argument === "--output" || argument === "-o")
8782
- options.output = resolve3(next());
9569
+ options.output = resolve4(next());
8783
9570
  else if (argument === "--file" || argument === "-f")
8784
9571
  options.diffFile = next();
8785
9572
  else if (argument === "--service-role")
8786
9573
  options.serviceRole = true;
9574
+ else if (argument === "--force")
9575
+ options.force = true;
8787
9576
  else if (argument === "--version") {
8788
9577
  console.log(package_default.version);
8789
9578
  process.exit(0);
@@ -8822,6 +9611,14 @@ Use --service-role to print the privileged key.`);
8822
9611
  await runDbCommand(options);
8823
9612
  return;
8824
9613
  }
9614
+ if (options.command === "snapshot") {
9615
+ await runSnapshotCommand(options);
9616
+ return;
9617
+ }
9618
+ if (options.command === "upgrade") {
9619
+ await runUpgradeCommand(options);
9620
+ return;
9621
+ }
8825
9622
  if (options.command === "gen") {
8826
9623
  if (options.positionals[0] && options.positionals[0] !== "types" && options.positionals[0] !== "typescript") {
8827
9624
  throw new Error(`unknown gen subcommand: ${options.positionals[0]}`);
@@ -8830,8 +9627,8 @@ Use --service-role to print the privileged key.`);
8830
9627
  try {
8831
9628
  const source = await generateTypes(project2.backend.db, "public");
8832
9629
  if (options.output) {
8833
- await mkdir6(dirname4(options.output), { recursive: true });
8834
- await writeFile5(options.output, source);
9630
+ await mkdir7(dirname5(options.output), { recursive: true });
9631
+ await writeFile6(options.output, source);
8835
9632
  console.log(`Wrote ${options.output}`);
8836
9633
  } else
8837
9634
  process.stdout.write(source);
@@ -8889,8 +9686,7 @@ Use --service-role to print the privileged key.`);
8889
9686
  Run "supacloud-lite keys" for the anon key.
8890
9687
  Run "supacloud-lite keys --service-role" only when privileged access is required.
8891
9688
  `);
8892
- await waitForShutdown();
8893
- await project.close();
9689
+ await waitForShutdown(() => project.close());
8894
9690
  }
8895
9691
  async function runDbCommand(options) {
8896
9692
  const subcommand = options.positionals[0];
@@ -8901,8 +9697,8 @@ async function runDbCommand(options) {
8901
9697
  }
8902
9698
  await assertResetPathsSafe(paths);
8903
9699
  if (paths.dataDir)
8904
- await rm3(paths.dataDir, { recursive: true, force: true });
8905
- await rm3(paths.storageDir, { recursive: true, force: true });
9700
+ await rm4(paths.dataDir, { recursive: true, force: true });
9701
+ await rm4(paths.storageDir, { recursive: true, force: true });
8906
9702
  const project2 = await createProjectBackend({ ...options, includeFunctions: false, includeWebhooks: false, log: quietLog });
8907
9703
  try {
8908
9704
  const applied = await project2.backend.db.listAppliedMigrations();
@@ -8912,7 +9708,7 @@ async function runDbCommand(options) {
8912
9708
  }
8913
9709
  return;
8914
9710
  }
8915
- const project = await loadSupabaseProject(resolve3(options.projectDir ?? process.cwd()));
9711
+ const project = await loadSupabaseProject(resolve4(options.projectDir ?? process.cwd()));
8916
9712
  if (subcommand === "diff") {
8917
9713
  const ddl = await computeDbDiff({ liveDataDir: paths.dataDir, migrations: project.migrations });
8918
9714
  if (ddl.length === 0) {
@@ -8925,9 +9721,9 @@ async function runDbCommand(options) {
8925
9721
  `;
8926
9722
  if (options.diffFile) {
8927
9723
  const stamp = timestamp();
8928
- const output = join8(paths.projectDir, "supabase", "migrations", `${stamp}_${options.diffFile}.sql`);
8929
- await mkdir6(join8(paths.projectDir, "supabase", "migrations"), { recursive: true });
8930
- await writeFile5(output, source);
9724
+ const output = join9(paths.projectDir, "supabase", "migrations", `${stamp}_${options.diffFile}.sql`);
9725
+ await mkdir7(join9(paths.projectDir, "supabase", "migrations"), { recursive: true });
9726
+ await writeFile6(output, source);
8931
9727
  console.log(`Wrote ${output}`);
8932
9728
  } else
8933
9729
  process.stdout.write(source);
@@ -8937,7 +9733,7 @@ async function runDbCommand(options) {
8937
9733
  const result = await pullSchema({
8938
9734
  liveDataDir: paths.dataDir,
8939
9735
  migrations: project.migrations,
8940
- migrationsDir: join8(paths.projectDir, "supabase", "migrations"),
9736
+ migrationsDir: join9(paths.projectDir, "supabase", "migrations"),
8941
9737
  name: options.positionals[1] ?? "remote_schema"
8942
9738
  });
8943
9739
  if (!result.path)
@@ -8948,6 +9744,63 @@ async function runDbCommand(options) {
8948
9744
  }
8949
9745
  throw new Error(`unknown db subcommand: ${subcommand ?? "(none)"}`);
8950
9746
  }
9747
+ async function runSnapshotCommand(options) {
9748
+ const subcommand = options.positionals[0];
9749
+ const paths = resolveProjectPaths(options);
9750
+ const storageBackend = resolveStorageBackend(options.storageBackend);
9751
+ if (options.memory)
9752
+ throw new Error("snapshot does not support --memory because the database is not durable");
9753
+ if (subcommand === "create") {
9754
+ await ensureProjectSecrets(paths);
9755
+ const output = options.output ?? join9(paths.stateDir, "backups", `snapshot-${timestamp()}.tar.gz`);
9756
+ const manifest = await createSnapshot({ paths, packageVersion: package_default.version, storageBackend, output });
9757
+ console.log(`Snapshot created: ${output}`);
9758
+ if (manifest.storageBackend === "s3")
9759
+ console.log("S3 objects were not copied; the snapshot contains database metadata and secrets only.");
9760
+ return;
9761
+ }
9762
+ if (subcommand === "restore") {
9763
+ const input = options.positionals[1];
9764
+ if (!input)
9765
+ throw new Error("snapshot restore requires a snapshot file");
9766
+ const result = await restoreSnapshot({ paths, storageBackend, input, force: options.force });
9767
+ console.log(`Snapshot restored from ${resolve4(input)}`);
9768
+ for (const rollbackPath of result.rollbackPaths)
9769
+ console.log(`Previous state retained at ${rollbackPath}`);
9770
+ if (result.manifest.storageBackend === "s3")
9771
+ console.log("Reconnect the original S3 bucket/prefix before starting Lite.");
9772
+ return;
9773
+ }
9774
+ throw new Error(`unknown snapshot subcommand: ${subcommand ?? "(none)"}`);
9775
+ }
9776
+ async function runUpgradeCommand(options) {
9777
+ if (options.memory)
9778
+ throw new Error("upgrade does not support --memory because there is no durable database to back up");
9779
+ const paths = resolveProjectPaths(options);
9780
+ const storageBackend = resolveStorageBackend(options.storageBackend);
9781
+ await ensureProjectSecrets(paths);
9782
+ const output = options.output ?? join9(paths.stateDir, "backups", `pre-upgrade-${timestamp()}.tar.gz`);
9783
+ await createSnapshot({ paths, packageVersion: package_default.version, storageBackend, output });
9784
+ console.log(`Pre-upgrade snapshot: ${output}`);
9785
+ try {
9786
+ const project = await createProjectBackend({
9787
+ ...options,
9788
+ applyMigrations: true,
9789
+ includeFunctions: false,
9790
+ includeWebhooks: false,
9791
+ includeSeed: true,
9792
+ log: quietLog
9793
+ });
9794
+ try {
9795
+ const applied = await project.backend.db.listAppliedMigrations();
9796
+ console.log(`Upgrade complete on @supacloud/lite ${package_default.version}: ${applied.length} migration(s) recorded.`);
9797
+ } finally {
9798
+ await project.backend.close();
9799
+ }
9800
+ } catch (error) {
9801
+ 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)}`);
9802
+ }
9803
+ }
8951
9804
  function printInspection(rows) {
8952
9805
  if (rows.length === 0) {
8953
9806
  console.log('No tables in schema "public".');
@@ -8962,11 +9815,15 @@ function timestamp() {
8962
9815
  return new Date().toISOString().replace(/[-:T]/g, "").slice(0, 14);
8963
9816
  }
8964
9817
  function quietLog() {}
8965
- async function waitForShutdown() {
8966
- await new Promise((resolveShutdown) => {
8967
- const stop = () => resolveShutdown();
8968
- process.once("SIGINT", stop);
8969
- process.once("SIGTERM", stop);
9818
+ function waitForShutdown(closeProject) {
9819
+ return new Promise((resolveShutdown, rejectShutdown) => {
9820
+ const closeOnSignal = () => {
9821
+ process.off("SIGINT", closeOnSignal);
9822
+ process.off("SIGTERM", closeOnSignal);
9823
+ closeProject().then(resolveShutdown, rejectShutdown);
9824
+ };
9825
+ process.once("SIGINT", closeOnSignal);
9826
+ process.once("SIGTERM", closeOnSignal);
8970
9827
  });
8971
9828
  }
8972
9829
  function printHelp() {
@@ -8984,6 +9841,9 @@ Commands:
8984
9841
  db reset wipe database and storage, then re-run migrations
8985
9842
  db diff print schema changes outside migrations
8986
9843
  db pull [name] write live schema changes as an applied migration
9844
+ snapshot create create a compressed database/storage/secrets snapshot
9845
+ snapshot restore <f> restore a snapshot into an empty target
9846
+ upgrade snapshot first, then apply pending migrations
8987
9847
  inspect show table rows and sizes
8988
9848
  version print the package version
8989
9849
 
@@ -9001,6 +9861,7 @@ Options:
9001
9861
  --memory use an in-memory PGlite database
9002
9862
  -o, --output <p> output file for gen types
9003
9863
  -f, --file <name> migration suffix for db diff
9864
+ --force replace non-empty restore targets and retain rollback copies
9004
9865
  `);
9005
9866
  }
9006
9867
  function formatStorage(backend, storageDir) {