@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/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.2.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/cli.js"
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.57.4",
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
- const res = await this.db.query(`select rt.*, u.id as uid from auth.refresh_tokens rt
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 this.db.query(`select id, friendly_name, factor_type, status, created_at, updated_at
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 this.db.query(`select id, provider_id, user_id, identity_data, provider, created_at, updated_at, last_sign_in_at
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 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]);
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
- constructor(functions, env) {
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 res = await runWithDenoEnv(this.env, () => Promise.resolve(fn(req, { auth: ctx, env: this.env })));
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
- grant usage on schema pgmq to anon, authenticated, service_role;
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_' || queue_name);
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_' || queue_name);
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
- perform pgmq.create(queue_name);
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_' || queue_name, 'q_' || queue_name) using qty, vt;
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_' || queue_name, 'q_' || queue_name);
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_' || queue_name) using msg_id;
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_' || queue_name, 'a_' || queue_name) using msg_id;
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
- execute format('drop table if exists pgmq.%I', 'q_' || queue_name);
2641
- execute format('drop table if exists pgmq.%I', 'a_' || queue_name);
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_' || queue_name);
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 substring(tablename from 3), false, false, now()
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
- grant execute on all functions in schema pgmq to anon, authenticated, service_role;
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 (e) {
3322
+ } catch (error) {
3006
3323
  await releaseLock();
3007
- if (e instanceof Error && /wasm/.test(e.message))
3008
- throw e;
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({ dataDir, extensions });
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
- let handle;
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 rest = new RestHandler(db, { exposedSchemas: config.dbSchemas, maxRows: config.maxRows });
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
- const functions = new FunctionsHandler(fnMap, fnEnv);
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,