@supacloud/lite 0.7.3 → 0.8.1

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 (40) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/LICENSES/POSTGRESQL-17-COPYRIGHT.txt +23 -0
  3. package/LICENSES/THESEUS-POSTGRESQL-LICENSE.txt +7 -0
  4. package/THIRD_PARTY_NOTICES.md +11 -1
  5. package/dist/cli.js +2674 -375
  6. package/dist/index.d.ts +3 -2
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +2537 -305
  9. package/dist/project-runtime.d.ts +5 -0
  10. package/dist/project-runtime.d.ts.map +1 -1
  11. package/dist/runtime/auth/handler.d.ts +5 -2
  12. package/dist/runtime/auth/handler.d.ts.map +1 -1
  13. package/dist/runtime/db/data-dir-lock.d.ts +2 -0
  14. package/dist/runtime/db/data-dir-lock.d.ts.map +1 -1
  15. package/dist/runtime/db/database.d.ts.map +1 -1
  16. package/dist/runtime/db/emulated.d.ts +2 -1
  17. package/dist/runtime/db/emulated.d.ts.map +1 -1
  18. package/dist/runtime/db/pglite-engine.d.ts.map +1 -1
  19. package/dist/runtime/node/fs-driver.d.ts +2 -0
  20. package/dist/runtime/node/fs-driver.d.ts.map +1 -1
  21. package/dist/runtime/node/native/engine.d.ts +23 -0
  22. package/dist/runtime/node/native/engine.d.ts.map +1 -0
  23. package/dist/runtime/node/native/wire-engine.d.ts +9 -0
  24. package/dist/runtime/node/native/wire-engine.d.ts.map +1 -0
  25. package/dist/runtime/node/native/wire.d.ts +56 -0
  26. package/dist/runtime/node/native/wire.d.ts.map +1 -0
  27. package/dist/runtime/rest/handler.d.ts.map +1 -1
  28. package/dist/runtime/storage/handler.d.ts +5 -0
  29. package/dist/runtime/storage/handler.d.ts.map +1 -1
  30. package/dist/runtime/storage/image-transform-cache.d.ts +13 -0
  31. package/dist/runtime/storage/image-transform-cache.d.ts.map +1 -0
  32. package/dist/runtime/storage/image-transform.d.ts +1 -1
  33. package/dist/runtime/storage/image-transform.d.ts.map +1 -1
  34. package/dist/runtime/storage/s3-driver.d.ts +2 -0
  35. package/dist/runtime/storage/s3-driver.d.ts.map +1 -1
  36. package/dist/runtime/types.d.ts +2 -0
  37. package/dist/runtime/types.d.ts.map +1 -1
  38. package/dist/snapshot.d.ts +5 -1
  39. package/dist/snapshot.d.ts.map +1 -1
  40. package/package.json +6 -2
package/dist/index.js CHANGED
@@ -85,8 +85,8 @@ function randomToken(bytes = 32) {
85
85
  // package.json
86
86
  var package_default = {
87
87
  name: "@supacloud/lite",
88
- version: "0.7.3",
89
- description: "Bun-native, single-project Supabase-compatible backend powered by PGlite",
88
+ version: "0.8.1",
89
+ description: "Bun-native, single-project Supabase-compatible backend powered by PGlite or native PostgreSQL",
90
90
  type: "module",
91
91
  license: "Apache-2.0",
92
92
  bin: {
@@ -122,6 +122,10 @@ var package_default = {
122
122
  dev: "bun run src/cli.ts start",
123
123
  start: "bun run src/cli.ts start",
124
124
  test: "bun test --timeout 20000",
125
+ "test:native": "SUPACLOUD_LITE_TEST_NATIVE=1 bun test test/native-engine.test.ts --timeout 180000",
126
+ parity: "bun run parity/harness.ts",
127
+ "parity:native": "SUPACLOUD_LITE_TEST_NATIVE=1 bun run parity/harness.ts --engine native",
128
+ "check:native": "bun run test:native && bun run parity:native",
125
129
  "test:package": "bun run scripts/package-smoke.ts",
126
130
  "test:standalone": "bun run scripts/standalone-smoke.ts",
127
131
  prepack: "bun run build",
@@ -1297,7 +1301,8 @@ class AuthHandler {
1297
1301
  return await this.oauth.authorize(url);
1298
1302
  }
1299
1303
  if (path === "callback" && (method === "GET" || method === "POST")) {
1300
- return await this.oauth.callback(url, (userId) => this.sessionTokensFor(userId));
1304
+ const callbackUrl = method === "POST" ? await this.oauthCallbackUrl(req, url) : url;
1305
+ return await this.oauth.callback(callbackUrl, (userId) => this.oauthSessionTokensFor(userId));
1301
1306
  }
1302
1307
  if (path.startsWith("admin/"))
1303
1308
  return await this.admin(req, ctx, path, method);
@@ -1307,6 +1312,16 @@ class AuthHandler {
1307
1312
  return authError(500, "unexpected_failure", msg);
1308
1313
  }
1309
1314
  }
1315
+ async oauthCallbackUrl(req, url) {
1316
+ const callbackUrl = new URL(url);
1317
+ const form = await req.formData();
1318
+ for (const field of ["code", "state"]) {
1319
+ const value = form.get(field);
1320
+ if (typeof value === "string" && value)
1321
+ callbackUrl.searchParams.set(field, value);
1322
+ }
1323
+ return callbackUrl;
1324
+ }
1310
1325
  async signup(req) {
1311
1326
  const body = await req.json().catch(() => ({}));
1312
1327
  if (!body.email && !body.password) {
@@ -1384,7 +1399,7 @@ class AuthHandler {
1384
1399
  if (!userId)
1385
1400
  return authError(403, "flow_state_not_found", "invalid or expired auth code");
1386
1401
  const ures = await this.db.query(`select * from auth.users where id = $1`, [userId]);
1387
- return json(200, await this.sessionFor(ures.rows[0]));
1402
+ return json(200, await this.sessionForOAuth(ures.rows[0]));
1388
1403
  }
1389
1404
  return authError(400, "invalid_grant", `unsupported grant_type: ${grantType}`);
1390
1405
  }
@@ -1429,17 +1444,18 @@ class AuthHandler {
1429
1444
  sets.push(`is_anonymous = false`);
1430
1445
  sets.push(`raw_app_meta_data = coalesce(raw_app_meta_data, '{}'::jsonb) || '{"provider":"email","providers":["email"]}'::jsonb`);
1431
1446
  }
1432
- if (sets.length === 0)
1433
- return json(200, this.userJson(user));
1434
- params.push(user.id);
1435
- const res = await this.db.query(`update auth.users set ${sets.join(", ")}, updated_at = now() where id = $${params.length} returning *`, params);
1436
- const updated = res.rows[0];
1437
- if (upgradingAnon) {
1438
- await this.db.query(`insert into auth.identities (user_id, provider, provider_id, identity_data)
1439
- values ($1, 'email', $2, $3)
1440
- on conflict (provider, provider_id) do nothing`, [updated.id, updated.id, JSON.stringify({ sub: updated.id, email: updated.email })]);
1447
+ if (sets.length === 0) {
1448
+ return json(200, this.userJson(user, await this.getUserFactors(user.id), await this.getUserIdentities(user.id)));
1441
1449
  }
1442
- return json(200, this.userJson(updated));
1450
+ params.push(user.id);
1451
+ const payload = await this.db.transaction(async (query) => {
1452
+ const res = await query(`update auth.users set ${sets.join(", ")}, updated_at = now() where id = $${params.length} returning *`, params);
1453
+ const updated = res.rows[0];
1454
+ if (body.email)
1455
+ await this.ensureEmailIdentity(updated, query);
1456
+ return this.userJsonWithRelations(updated, query);
1457
+ });
1458
+ return json(200, payload);
1443
1459
  }
1444
1460
  async logout(req, url) {
1445
1461
  const claims = await this.claimsFromBearer(req);
@@ -1469,7 +1485,7 @@ class AuthHandler {
1469
1485
  let user = res.rows[0];
1470
1486
  if (!user) {
1471
1487
  if (!createUser)
1472
- return authError(422, "otp_disabled", "Signups not allowed for otp");
1488
+ return json(200, {});
1473
1489
  if (this.settings.disableSignup)
1474
1490
  return authError(422, "signup_disabled", "Signups not allowed for this instance");
1475
1491
  res = await this.db.query(`insert into auth.users (aud, role, email, raw_app_meta_data, raw_user_meta_data)
@@ -1801,6 +1817,8 @@ Or sign in with this link: ${link}`
1801
1817
  }
1802
1818
  const idMatch = path.match(/^admin\/users\/([0-9a-f-]{36})$/);
1803
1819
  const exportMatch = path.match(/^admin\/users\/([0-9a-f-]{36})\/export$/);
1820
+ const factorsMatch = path.match(/^admin\/users\/([0-9a-f-]{36})\/factors$/);
1821
+ const factorMatch = path.match(/^admin\/users\/([0-9a-f-]{36})\/factors\/([0-9a-f-]{36})$/);
1804
1822
  if (path === "admin/generate_link" && method === "POST") {
1805
1823
  return await this.generateAdminMagicLink(req);
1806
1824
  }
@@ -1812,32 +1830,49 @@ Or sign in with this link: ${link}`
1812
1830
  if (exportMatch && method === "GET") {
1813
1831
  return await this.exportUser(exportMatch[1]);
1814
1832
  }
1833
+ if (factorsMatch && method === "GET") {
1834
+ const user = await this.db.query(`select 1 from auth.users where id = $1`, [factorsMatch[1]]);
1835
+ if (user.rows.length === 0)
1836
+ return authError(404, "user_not_found", "User not found");
1837
+ return json(200, await this.getUserFactors(factorsMatch[1]));
1838
+ }
1839
+ if (factorMatch && method === "DELETE") {
1840
+ const deleted = await this.db.query(`delete from auth.mfa_factors where user_id = $1 and id = $2 returning id`, [factorMatch[1], factorMatch[2]]);
1841
+ if (deleted.rows.length === 0)
1842
+ return authError(404, "mfa_factor_not_found", "MFA factor not found");
1843
+ return json(200, { id: factorMatch[2] });
1844
+ }
1815
1845
  if (path === "admin/users" && method === "GET") {
1816
1846
  const res = await this.db.query(`select * from auth.users order by created_at desc limit 1000`);
1817
- return json(200, { users: res.rows.map((u) => this.userJson(u)), aud: "authenticated" });
1847
+ return json(200, { users: res.rows.map((user) => this.userJson(user)), aud: "authenticated" });
1818
1848
  }
1819
1849
  if (path === "admin/users" && method === "POST") {
1820
1850
  const body = await req.json().catch(() => ({}));
1821
1851
  if (!body.email)
1822
1852
  return authError(400, "validation_failed", "email is required");
1823
1853
  const hashed = body.password ? await hashPassword(body.password) : null;
1824
- const res = await this.db.query(`insert into auth.users
1825
- (aud, role, email, encrypted_password, email_confirmed_at, raw_app_meta_data, raw_user_meta_data)
1826
- values ('authenticated', 'authenticated', $1, $2, case when $3 then now() else null end, $4, $5)
1827
- returning *`, [
1828
- body.email.toLowerCase().trim(),
1829
- hashed,
1830
- body.email_confirm ?? true,
1831
- JSON.stringify({ provider: "email", providers: ["email"], ...body.app_metadata ?? {} }),
1832
- JSON.stringify(body.user_metadata ?? {})
1833
- ]);
1834
- return json(200, this.userJson(res.rows[0]));
1854
+ const created = await this.db.transaction(async (query) => {
1855
+ const res = await query(`insert into auth.users
1856
+ (aud, role, email, encrypted_password, email_confirmed_at, raw_app_meta_data, raw_user_meta_data)
1857
+ values ('authenticated', 'authenticated', $1, $2, case when $3 then now() else null end, $4, $5)
1858
+ returning *`, [
1859
+ body.email.toLowerCase().trim(),
1860
+ hashed,
1861
+ body.email_confirm ?? true,
1862
+ JSON.stringify({ provider: "email", providers: ["email"], ...body.app_metadata ?? {} }),
1863
+ JSON.stringify(body.user_metadata ?? {})
1864
+ ]);
1865
+ const user = res.rows[0];
1866
+ await this.ensureEmailIdentity(user, query);
1867
+ return this.userJsonWithRelations(user, query);
1868
+ });
1869
+ return json(200, created);
1835
1870
  }
1836
1871
  if (idMatch && method === "GET") {
1837
1872
  const res = await this.db.query(`select * from auth.users where id = $1`, [idMatch[1]]);
1838
1873
  if (res.rows.length === 0)
1839
1874
  return authError(404, "user_not_found", "User not found");
1840
- return json(200, this.userJson(res.rows[0]));
1875
+ return json(200, await this.userJsonWithRelations(res.rows[0]));
1841
1876
  }
1842
1877
  if (idMatch && method === "PUT") {
1843
1878
  const body = await req.json().catch(() => ({}));
@@ -1864,10 +1899,18 @@ Or sign in with this link: ${link}`
1864
1899
  if (sets.length === 0)
1865
1900
  return authError(400, "validation_failed", "nothing to update");
1866
1901
  params.push(idMatch[1]);
1867
- const res = await this.db.query(`update auth.users set ${sets.join(", ")}, updated_at = now() where id = $${params.length} returning *`, params);
1868
- if (res.rows.length === 0)
1902
+ const updated = await this.db.transaction(async (query) => {
1903
+ const res = await query(`update auth.users set ${sets.join(", ")}, updated_at = now() where id = $${params.length} returning *`, params);
1904
+ const user = res.rows[0];
1905
+ if (!user)
1906
+ return null;
1907
+ if (typeof body.email === "string")
1908
+ await this.ensureEmailIdentity(user, query);
1909
+ return this.userJsonWithRelations(user, query);
1910
+ });
1911
+ if (!updated)
1869
1912
  return authError(404, "user_not_found", "User not found");
1870
- return json(200, this.userJson(res.rows[0]));
1913
+ return json(200, updated);
1871
1914
  }
1872
1915
  if (idMatch && method === "DELETE") {
1873
1916
  return await this.eraseUser(idMatch[1]);
@@ -2063,17 +2106,26 @@ Or sign in with this link: ${link}`
2063
2106
  if (!await verifyTotp(factor.secret, body.code ?? "")) {
2064
2107
  return authError(422, "mfa_verification_failed", "Invalid TOTP code entered");
2065
2108
  }
2066
- await this.db.query(`update auth.mfa_challenges set verified_at = now() where id = $1`, [challenge.id]);
2067
- if (factor.status !== "verified") {
2068
- await this.db.query(`update auth.mfa_factors set status = 'verified', updated_at = now() where id = $1`, [factorId]);
2069
- }
2070
- const session = await this.sessionFor(user, undefined, {
2071
- aal: "aal2",
2072
- amr: [
2073
- { method: "password", timestamp: Math.floor(Date.now() / 1000) },
2074
- { method: "totp", timestamp: Math.floor(Date.now() / 1000) }
2075
- ]
2109
+ const session = await this.db.transaction(async (query) => {
2110
+ const claimed = await query(`update auth.mfa_challenges set verified_at = now()
2111
+ where id = $1 and factor_id = $2 and verified_at is null and expires_at >= now()
2112
+ returning id`, [challenge.id, factorId]);
2113
+ if (claimed.rows.length === 0)
2114
+ return null;
2115
+ if (factor.status !== "verified") {
2116
+ await query(`update auth.mfa_factors set status = 'verified', updated_at = now() where id = $1`, [factorId]);
2117
+ }
2118
+ return this.sessionFor(user, undefined, {
2119
+ aal: "aal2",
2120
+ amr: [
2121
+ { method: "password", timestamp: Math.floor(Date.now() / 1000) },
2122
+ { method: "totp", timestamp: Math.floor(Date.now() / 1000) }
2123
+ ]
2124
+ }, query);
2076
2125
  });
2126
+ if (!session) {
2127
+ return authError(422, "mfa_verification_failed", "This challenge has already been verified");
2128
+ }
2077
2129
  return json(200, session);
2078
2130
  }
2079
2131
  async unenrollFactor(req, factorId) {
@@ -2111,6 +2163,17 @@ Or sign in with this link: ${link}`
2111
2163
  updated_at: iso(r.updated_at)
2112
2164
  }));
2113
2165
  }
2166
+ async ensureEmailIdentity(user, query = (sql, params) => this.db.query(sql, params)) {
2167
+ if (!user.email)
2168
+ return;
2169
+ await query(`insert into auth.identities (user_id, provider, provider_id, identity_data)
2170
+ values ($1::uuid, 'email', $1::text, $2::jsonb)
2171
+ on conflict (provider, provider_id) do update
2172
+ set identity_data = excluded.identity_data, updated_at = now()`, [user.id, JSON.stringify({ sub: user.id, email: user.email })]);
2173
+ }
2174
+ async userJsonWithRelations(user, query = (sql, params) => this.db.query(sql, params)) {
2175
+ return this.userJson(user, await this.getUserFactors(user.id, query), await this.getUserIdentities(user.id, query));
2176
+ }
2114
2177
  async rotateRefreshToken(token) {
2115
2178
  return this.db.transaction(async (query) => {
2116
2179
  const claimedToken = await this.claimRefreshToken(query, token);
@@ -2184,9 +2247,14 @@ Or sign in with this link: ${link}`
2184
2247
  is_anonymous: u.is_anonymous ?? false
2185
2248
  };
2186
2249
  }
2187
- async sessionTokensFor(userId) {
2250
+ sessionForOAuth(user) {
2251
+ return this.sessionFor(user, undefined, {
2252
+ amr: [{ method: "oauth", timestamp: Math.floor(Date.now() / 1000) }]
2253
+ });
2254
+ }
2255
+ async oauthSessionTokensFor(userId) {
2188
2256
  const res = await this.db.query(`select * from auth.users where id = $1`, [userId]);
2189
- const session = await this.sessionFor(res.rows[0]);
2257
+ const session = await this.sessionForOAuth(res.rows[0]);
2190
2258
  return { access_token: session.access_token, refresh_token: session.refresh_token, expires_in: session.expires_in };
2191
2259
  }
2192
2260
  async sessionFor(user, parentToken, opts, query = (sql, params) => this.db.query(sql, params)) {
@@ -3550,41 +3618,1158 @@ $pgmq$;
3550
3618
  revoke all on all functions in schema pgmq from public, anon, authenticated;
3551
3619
  grant execute on all functions in schema pgmq to service_role;
3552
3620
 
3553
- create schema if not exists pgmq_public;
3554
- grant usage on schema pgmq_public to anon, authenticated, service_role;
3555
-
3556
- create or replace function pgmq_public.send(queue_name text, message jsonb, sleep_seconds integer default 0)
3557
- returns setof bigint language sql volatile security definer set search_path = pgmq, pg_catalog, public as $pgmq_public$
3558
- select * from pgmq.send(queue_name, message, sleep_seconds);
3559
- $pgmq_public$;
3560
-
3561
- create or replace function pgmq_public.send_batch(queue_name text, messages jsonb[], sleep_seconds integer default 0)
3562
- returns setof bigint language sql volatile security definer set search_path = pgmq, pg_catalog, public as $pgmq_public$
3563
- select * from pgmq.send_batch(queue_name, messages, sleep_seconds);
3564
- $pgmq_public$;
3565
-
3566
- create or replace function pgmq_public.read(queue_name text, sleep_seconds integer, n integer)
3567
- returns setof pgmq.message_record language sql volatile security definer set search_path = pgmq, pg_catalog, public as $pgmq_public$
3568
- select * from pgmq.read(queue_name, sleep_seconds, n);
3569
- $pgmq_public$;
3570
-
3571
- create or replace function pgmq_public.pop(queue_name text)
3572
- returns setof pgmq.message_record language sql volatile security definer set search_path = pgmq, pg_catalog, public as $pgmq_public$
3573
- select * from pgmq.pop(queue_name);
3574
- $pgmq_public$;
3575
-
3576
- create or replace function pgmq_public.archive(queue_name text, message_id bigint)
3577
- returns boolean language sql volatile security definer set search_path = pgmq, pg_catalog, public as $pgmq_public$
3578
- select pgmq.archive(queue_name, message_id);
3579
- $pgmq_public$;
3580
-
3581
- create or replace function pgmq_public."delete"(queue_name text, message_id bigint)
3582
- returns boolean language sql volatile security definer set search_path = pgmq, pg_catalog, public as $pgmq_public$
3583
- select pgmq.delete(queue_name, message_id);
3584
- $pgmq_public$;
3585
-
3586
- revoke all on all functions in schema pgmq_public from public;
3587
- grant execute on all functions in schema pgmq_public to anon, authenticated, service_role;
3621
+ -- supacloud:sql-module:pgmq-public:start
3622
+ DO $pgmq_extension$
3623
+ BEGIN
3624
+ IF to_regprocedure('pgmq.send(text,jsonb,integer)') IS NULL THEN
3625
+ EXECUTE 'CREATE EXTENSION IF NOT EXISTS pgmq';
3626
+ END IF;
3627
+ END
3628
+ $pgmq_extension$;
3629
+
3630
+ CREATE SCHEMA IF NOT EXISTS pgmq_public;
3631
+ GRANT USAGE ON SCHEMA pgmq_public TO anon, authenticated, service_role;
3632
+
3633
+ CREATE OR REPLACE FUNCTION pgmq_public.require_public_queue(queue_name text)
3634
+ RETURNS text
3635
+ LANGUAGE plpgsql
3636
+ IMMUTABLE
3637
+ SET search_path = ''
3638
+ AS $$
3639
+ DECLARE
3640
+ normalized_queue_name text := lower(btrim(queue_name));
3641
+ BEGIN
3642
+ IF normalized_queue_name IS NULL
3643
+ OR left(normalized_queue_name, char_length('supacloud_internal_')) = 'supacloud_internal_' THEN
3644
+ RAISE EXCEPTION 'SUPACLOUD_QUEUE_NAME_RESERVED' USING ERRCODE = '42501';
3645
+ END IF;
3646
+ RETURN normalized_queue_name;
3647
+ END;
3648
+ $$;
3649
+
3650
+ CREATE OR REPLACE FUNCTION pgmq_public.send(queue_name text, message jsonb, sleep_seconds integer DEFAULT 0)
3651
+ RETURNS SETOF bigint
3652
+ LANGUAGE sql
3653
+ VOLATILE
3654
+ SECURITY DEFINER
3655
+ SET search_path = ''
3656
+ AS $$ SELECT * FROM pgmq.send(pgmq_public.require_public_queue(queue_name), message, sleep_seconds); $$;
3657
+
3658
+ CREATE OR REPLACE FUNCTION pgmq_public.send_batch(queue_name text, messages jsonb[], sleep_seconds integer DEFAULT 0)
3659
+ RETURNS SETOF bigint
3660
+ LANGUAGE sql
3661
+ VOLATILE
3662
+ SECURITY DEFINER
3663
+ SET search_path = ''
3664
+ AS $$ SELECT * FROM pgmq.send_batch(pgmq_public.require_public_queue(queue_name), messages, sleep_seconds); $$;
3665
+
3666
+ CREATE OR REPLACE FUNCTION pgmq_public.read(queue_name text, sleep_seconds integer, n integer)
3667
+ RETURNS SETOF pgmq.message_record
3668
+ LANGUAGE sql
3669
+ VOLATILE
3670
+ SECURITY DEFINER
3671
+ SET search_path = ''
3672
+ AS $$ SELECT * FROM pgmq.read(pgmq_public.require_public_queue(queue_name), sleep_seconds, n); $$;
3673
+
3674
+ CREATE OR REPLACE FUNCTION pgmq_public.pop(queue_name text)
3675
+ RETURNS SETOF pgmq.message_record
3676
+ LANGUAGE sql
3677
+ VOLATILE
3678
+ SECURITY DEFINER
3679
+ SET search_path = ''
3680
+ AS $$ SELECT * FROM pgmq.pop(pgmq_public.require_public_queue(queue_name)); $$;
3681
+
3682
+ CREATE OR REPLACE FUNCTION pgmq_public.archive(queue_name text, message_id bigint)
3683
+ RETURNS boolean
3684
+ LANGUAGE sql
3685
+ VOLATILE
3686
+ SECURITY DEFINER
3687
+ SET search_path = ''
3688
+ AS $$ SELECT pgmq.archive(pgmq_public.require_public_queue(queue_name), message_id); $$;
3689
+
3690
+ CREATE OR REPLACE FUNCTION pgmq_public."delete"(queue_name text, message_id bigint)
3691
+ RETURNS boolean
3692
+ LANGUAGE sql
3693
+ VOLATILE
3694
+ SECURITY DEFINER
3695
+ SET search_path = ''
3696
+ AS $$ SELECT pgmq.delete(pgmq_public.require_public_queue(queue_name), message_id); $$;
3697
+
3698
+ REVOKE ALL ON ALL FUNCTIONS IN SCHEMA pgmq_public FROM PUBLIC;
3699
+ GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA pgmq_public TO anon, authenticated, service_role;
3700
+ -- supacloud:sql-module:pgmq-public:end
3701
+ `;
3702
+ var WORKFLOWS_SQL = `
3703
+ -- supacloud:sql-module:workflows-public:start
3704
+ DO $pgmq_extension$
3705
+ BEGIN
3706
+ IF to_regprocedure('pgmq.send(text,jsonb,integer)') IS NULL THEN
3707
+ EXECUTE 'CREATE EXTENSION IF NOT EXISTS pgmq';
3708
+ END IF;
3709
+ END
3710
+ $pgmq_extension$;
3711
+ CREATE SCHEMA IF NOT EXISTS supacloud_workflows;
3712
+ REVOKE ALL ON SCHEMA supacloud_workflows FROM PUBLIC, anon, authenticated;
3713
+ GRANT USAGE ON SCHEMA supacloud_workflows TO service_role;
3714
+
3715
+ SELECT pgmq.create('supacloud_internal_workflows');
3716
+
3717
+ CREATE TABLE IF NOT EXISTS supacloud_workflows.runs (
3718
+ id uuid PRIMARY KEY,
3719
+ workflow_name text NOT NULL
3720
+ CHECK (workflow_name ~ '^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$'),
3721
+ workflow_version text NOT NULL
3722
+ CHECK (char_length(workflow_version) BETWEEN 1 AND 80),
3723
+ status text NOT NULL DEFAULT 'queued'
3724
+ CHECK (status IN ('queued', 'running', 'completed', 'failed', 'cancelled')),
3725
+ input jsonb NOT NULL DEFAULT '{}'::jsonb
3726
+ CHECK (jsonb_typeof(input) = 'object'),
3727
+ output jsonb NOT NULL DEFAULT '{}'::jsonb
3728
+ CHECK (jsonb_typeof(output) = 'object'),
3729
+ error_message text NOT NULL DEFAULT ''
3730
+ CHECK (char_length(error_message) <= 4000),
3731
+ row_version bigint NOT NULL DEFAULT 1 CHECK (row_version > 0),
3732
+ created_at timestamptz NOT NULL DEFAULT now(),
3733
+ started_at timestamptz,
3734
+ completed_at timestamptz,
3735
+ updated_at timestamptz NOT NULL DEFAULT now()
3736
+ );
3737
+
3738
+ CREATE TABLE IF NOT EXISTS supacloud_workflows.steps (
3739
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
3740
+ run_id uuid NOT NULL REFERENCES supacloud_workflows.runs(id) ON DELETE CASCADE,
3741
+ step_key text NOT NULL
3742
+ CHECK (step_key ~ '^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$'),
3743
+ status text NOT NULL DEFAULT 'queued'
3744
+ CHECK (status IN ('queued', 'running', 'completed', 'failed', 'dead_lettered', 'cancelled')),
3745
+ input jsonb NOT NULL DEFAULT '{}'::jsonb
3746
+ CHECK (jsonb_typeof(input) = 'object'),
3747
+ output jsonb NOT NULL DEFAULT '{}'::jsonb
3748
+ CHECK (jsonb_typeof(output) = 'object'),
3749
+ error_message text NOT NULL DEFAULT ''
3750
+ CHECK (char_length(error_message) <= 4000),
3751
+ attempts integer NOT NULL DEFAULT 0 CHECK (attempts >= 0),
3752
+ max_attempts integer NOT NULL DEFAULT 3 CHECK (max_attempts BETWEEN 1 AND 100),
3753
+ retry_delay_seconds integer NOT NULL DEFAULT 0
3754
+ CHECK (retry_delay_seconds BETWEEN 0 AND 86400),
3755
+ queue_message_id bigint NOT NULL UNIQUE,
3756
+ claimed_by text,
3757
+ claimed_at timestamptz,
3758
+ completed_at timestamptz,
3759
+ next_step_key text,
3760
+ created_at timestamptz NOT NULL DEFAULT now(),
3761
+ updated_at timestamptz NOT NULL DEFAULT now(),
3762
+ UNIQUE (run_id, step_key)
3763
+ );
3764
+
3765
+ ALTER TABLE supacloud_workflows.steps
3766
+ ADD COLUMN IF NOT EXISTS retry_delay_seconds integer NOT NULL DEFAULT 0
3767
+ CHECK (retry_delay_seconds BETWEEN 0 AND 86400);
3768
+
3769
+ CREATE UNIQUE INDEX IF NOT EXISTS supacloud_workflows_one_active_step_idx
3770
+ ON supacloud_workflows.steps (run_id)
3771
+ WHERE status IN ('queued', 'running');
3772
+
3773
+ CREATE INDEX IF NOT EXISTS supacloud_workflows_runs_status_idx
3774
+ ON supacloud_workflows.runs (status, updated_at DESC, id);
3775
+
3776
+ CREATE INDEX IF NOT EXISTS supacloud_workflows_steps_run_idx
3777
+ ON supacloud_workflows.steps (run_id, created_at, id);
3778
+
3779
+ CREATE TABLE IF NOT EXISTS supacloud_workflows.events (
3780
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
3781
+ run_id uuid NOT NULL REFERENCES supacloud_workflows.runs(id) ON DELETE CASCADE,
3782
+ step_id uuid REFERENCES supacloud_workflows.steps(id) ON DELETE CASCADE,
3783
+ event_type text NOT NULL
3784
+ CHECK (event_type IN (
3785
+ 'run_started', 'step_claimed', 'step_retried', 'step_completed',
3786
+ 'step_failed', 'step_dead_lettered', 'run_completed', 'run_cancelled'
3787
+ )),
3788
+ attempt integer CHECK (attempt IS NULL OR attempt > 0),
3789
+ details jsonb NOT NULL DEFAULT '{}'::jsonb
3790
+ CHECK (jsonb_typeof(details) = 'object'),
3791
+ created_at timestamptz NOT NULL DEFAULT now()
3792
+ );
3793
+
3794
+ CREATE INDEX IF NOT EXISTS supacloud_workflows_events_run_idx
3795
+ ON supacloud_workflows.events (run_id, id);
3796
+
3797
+ CREATE UNIQUE INDEX IF NOT EXISTS supacloud_workflows_retry_receipt_idx
3798
+ ON supacloud_workflows.events (step_id, attempt)
3799
+ WHERE event_type IN ('step_retried', 'step_dead_lettered')
3800
+ AND details ->> 'operation' = 'retry';
3801
+
3802
+ CREATE OR REPLACE FUNCTION supacloud_workflows.snapshot(
3803
+ p_run_id uuid,
3804
+ p_idempotent boolean DEFAULT false
3805
+ ) RETURNS jsonb
3806
+ LANGUAGE sql STABLE SECURITY DEFINER SET search_path = '' AS $$
3807
+ SELECT jsonb_build_object(
3808
+ 'runId', run.id,
3809
+ 'workflowName', run.workflow_name,
3810
+ 'workflowVersion', run.workflow_version,
3811
+ 'status', run.status,
3812
+ 'input', run.input,
3813
+ 'output', run.output,
3814
+ 'errorMessage', run.error_message,
3815
+ 'rowVersion', run.row_version::text,
3816
+ 'createdAt', run.created_at,
3817
+ 'startedAt', run.started_at,
3818
+ 'completedAt', run.completed_at,
3819
+ 'updatedAt', run.updated_at,
3820
+ 'idempotent', p_idempotent,
3821
+ 'steps', coalesce((
3822
+ SELECT jsonb_agg(jsonb_build_object(
3823
+ 'stepId', step.id,
3824
+ 'stepKey', step.step_key,
3825
+ 'status', step.status,
3826
+ 'input', step.input,
3827
+ 'output', step.output,
3828
+ 'errorMessage', step.error_message,
3829
+ 'attempts', step.attempts,
3830
+ 'maxAttempts', step.max_attempts,
3831
+ 'retryDelaySeconds', step.retry_delay_seconds,
3832
+ 'queueMessageId', step.queue_message_id::text,
3833
+ 'claimedBy', step.claimed_by,
3834
+ 'claimedAt', step.claimed_at,
3835
+ 'completedAt', step.completed_at,
3836
+ 'nextStepKey', step.next_step_key,
3837
+ 'createdAt', step.created_at,
3838
+ 'updatedAt', step.updated_at
3839
+ ) ORDER BY step.created_at, step.id)
3840
+ FROM supacloud_workflows.steps step
3841
+ WHERE step.run_id = run.id
3842
+ ), '[]'::jsonb)
3843
+ )
3844
+ FROM supacloud_workflows.runs run
3845
+ WHERE run.id = p_run_id
3846
+ $$;
3847
+
3848
+ CREATE OR REPLACE FUNCTION supacloud_workflows.enqueue_step(
3849
+ p_run_id uuid,
3850
+ p_step_key text,
3851
+ p_input jsonb,
3852
+ p_max_attempts integer
3853
+ ) RETURNS supacloud_workflows.steps
3854
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
3855
+ DECLARE
3856
+ normalized_step_key text := nullif(btrim(p_step_key), '');
3857
+ step_id uuid := gen_random_uuid();
3858
+ message_id bigint;
3859
+ created_step supacloud_workflows.steps%ROWTYPE;
3860
+ BEGIN
3861
+ IF normalized_step_key IS NULL
3862
+ OR normalized_step_key !~ '^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$'
3863
+ OR jsonb_typeof(p_input) IS DISTINCT FROM 'object'
3864
+ OR p_max_attempts NOT BETWEEN 1 AND 100 THEN
3865
+ RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_STEP_INVALID' USING ERRCODE = '22023';
3866
+ END IF;
3867
+
3868
+ IF NOT EXISTS (
3869
+ SELECT 1 FROM supacloud_workflows.runs run
3870
+ WHERE run.id = p_run_id AND run.status IN ('queued', 'running')
3871
+ ) THEN
3872
+ RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_RUN_NOT_ACTIVE' USING ERRCODE = '55000';
3873
+ END IF;
3874
+
3875
+ SELECT queued_id INTO message_id
3876
+ FROM pgmq.send(
3877
+ 'supacloud_internal_workflows',
3878
+ jsonb_build_object('run_id', p_run_id, 'step_id', step_id),
3879
+ 0
3880
+ ) AS queued_id;
3881
+
3882
+ INSERT INTO supacloud_workflows.steps (
3883
+ id, run_id, step_key, input, max_attempts, queue_message_id
3884
+ ) VALUES (
3885
+ step_id, p_run_id, normalized_step_key, p_input, p_max_attempts, message_id
3886
+ ) RETURNING * INTO created_step;
3887
+
3888
+ RETURN created_step;
3889
+ END;
3890
+ $$;
3891
+
3892
+ -- Clean-code exception: private transitions keep typed PostgreSQL arguments and
3893
+ -- the complete lock/queue/ledger/event mutation in one transaction. The public
3894
+ -- contract already uses one JSON request; revisit if a private routine gains a
3895
+ -- second caller or any transition can be decomposed without weakening atomicity.
3896
+ CREATE OR REPLACE FUNCTION supacloud_workflows.start_run(
3897
+ p_run_id uuid,
3898
+ p_workflow_name text,
3899
+ p_workflow_version text,
3900
+ p_first_step_key text,
3901
+ p_input jsonb,
3902
+ p_max_attempts integer
3903
+ ) RETURNS jsonb
3904
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
3905
+ DECLARE
3906
+ normalized_name text := nullif(btrim(p_workflow_name), '');
3907
+ normalized_version text := nullif(btrim(p_workflow_version), '');
3908
+ normalized_first_step_key text := nullif(btrim(p_first_step_key), '');
3909
+ existing_run supacloud_workflows.runs%ROWTYPE;
3910
+ existing_step supacloud_workflows.steps%ROWTYPE;
3911
+ first_step supacloud_workflows.steps%ROWTYPE;
3912
+ BEGIN
3913
+ IF p_run_id IS NULL
3914
+ OR normalized_name IS NULL
3915
+ OR normalized_name !~ '^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$'
3916
+ OR normalized_version IS NULL
3917
+ OR char_length(normalized_version) > 80
3918
+ OR normalized_first_step_key IS NULL
3919
+ OR normalized_first_step_key !~ '^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$'
3920
+ OR jsonb_typeof(p_input) IS DISTINCT FROM 'object'
3921
+ OR p_max_attempts NOT BETWEEN 1 AND 100 THEN
3922
+ RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_START_INVALID' USING ERRCODE = '22023';
3923
+ END IF;
3924
+
3925
+ PERFORM pg_advisory_xact_lock(hashtextextended(p_run_id::text, 0));
3926
+ SELECT * INTO existing_run FROM supacloud_workflows.runs WHERE id = p_run_id;
3927
+ IF FOUND THEN
3928
+ SELECT * INTO existing_step
3929
+ FROM supacloud_workflows.steps
3930
+ WHERE run_id = p_run_id
3931
+ ORDER BY created_at, id
3932
+ LIMIT 1;
3933
+ IF NOT FOUND THEN
3934
+ RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_IDEMPOTENCY_CONFLICT' USING ERRCODE = '23505';
3935
+ END IF;
3936
+ IF existing_run.workflow_name <> normalized_name
3937
+ OR existing_run.workflow_version <> normalized_version
3938
+ OR existing_run.input <> p_input
3939
+ OR existing_step.step_key <> normalized_first_step_key
3940
+ OR existing_step.input <> p_input
3941
+ OR existing_step.max_attempts <> p_max_attempts THEN
3942
+ RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_IDEMPOTENCY_CONFLICT' USING ERRCODE = '23505';
3943
+ END IF;
3944
+ RETURN supacloud_workflows.snapshot(p_run_id, true);
3945
+ END IF;
3946
+
3947
+ INSERT INTO supacloud_workflows.runs (
3948
+ id, workflow_name, workflow_version, input
3949
+ ) VALUES (
3950
+ p_run_id, normalized_name, normalized_version, p_input
3951
+ );
3952
+ first_step := supacloud_workflows.enqueue_step(
3953
+ p_run_id, normalized_first_step_key, p_input, p_max_attempts
3954
+ );
3955
+ INSERT INTO supacloud_workflows.events (run_id, step_id, event_type)
3956
+ VALUES (p_run_id, first_step.id, 'run_started');
3957
+ RETURN supacloud_workflows.snapshot(p_run_id, false);
3958
+ END;
3959
+ $$;
3960
+
3961
+ CREATE OR REPLACE FUNCTION supacloud_workflows.claim_step(
3962
+ p_worker_id text,
3963
+ p_visibility_timeout_seconds integer
3964
+ ) RETURNS jsonb
3965
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
3966
+ DECLARE
3967
+ normalized_worker_id text := nullif(btrim(p_worker_id), '');
3968
+ queued_message pgmq.message_record;
3969
+ message_run_id text;
3970
+ message_step_id text;
3971
+ candidate_run_id uuid;
3972
+ claimed_step supacloud_workflows.steps%ROWTYPE;
3973
+ claimed_run supacloud_workflows.runs%ROWTYPE;
3974
+ BEGIN
3975
+ IF normalized_worker_id IS NULL
3976
+ OR char_length(normalized_worker_id) > 200
3977
+ OR p_visibility_timeout_seconds NOT BETWEEN 15 AND 3600 THEN
3978
+ RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_CLAIM_INVALID' USING ERRCODE = '22023';
3979
+ END IF;
3980
+
3981
+ SELECT * INTO queued_message
3982
+ FROM pgmq.read('supacloud_internal_workflows', p_visibility_timeout_seconds, 1)
3983
+ LIMIT 1;
3984
+ IF NOT FOUND THEN RETURN NULL; END IF;
3985
+
3986
+ message_run_id := queued_message.message ->> 'run_id';
3987
+ message_step_id := queued_message.message ->> 'step_id';
3988
+ IF jsonb_typeof(queued_message.message) IS DISTINCT FROM 'object'
3989
+ OR message_run_id IS NULL
3990
+ OR message_step_id IS NULL
3991
+ OR message_run_id !~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$'
3992
+ OR message_step_id !~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$' THEN
3993
+ PERFORM pgmq.archive('supacloud_internal_workflows', queued_message.msg_id);
3994
+ RETURN jsonb_build_object(
3995
+ 'status', 'discarded',
3996
+ 'reason', 'invalid_message',
3997
+ 'messageId', queued_message.msg_id::text
3998
+ );
3999
+ END IF;
4000
+
4001
+ SELECT step.run_id INTO candidate_run_id
4002
+ FROM supacloud_workflows.steps step
4003
+ WHERE step.id::text = lower(message_step_id)
4004
+ AND step.run_id::text = lower(message_run_id)
4005
+ AND step.queue_message_id = queued_message.msg_id;
4006
+ IF NOT FOUND THEN
4007
+ PERFORM pgmq.archive('supacloud_internal_workflows', queued_message.msg_id);
4008
+ RETURN jsonb_build_object(
4009
+ 'status', 'discarded',
4010
+ 'reason', 'orphaned_message',
4011
+ 'messageId', queued_message.msg_id::text
4012
+ );
4013
+ END IF;
4014
+
4015
+ IF NOT pg_try_advisory_xact_lock(hashtextextended(candidate_run_id::text, 0)) THEN
4016
+ RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_CLAIM_RETRY' USING ERRCODE = '40001';
4017
+ END IF;
4018
+ SELECT * INTO claimed_step
4019
+ FROM supacloud_workflows.steps
4020
+ WHERE id::text = lower(message_step_id)
4021
+ AND run_id = candidate_run_id
4022
+ AND queue_message_id = queued_message.msg_id
4023
+ FOR UPDATE;
4024
+ IF NOT FOUND THEN
4025
+ PERFORM pgmq.archive('supacloud_internal_workflows', queued_message.msg_id);
4026
+ RETURN jsonb_build_object(
4027
+ 'status', 'discarded',
4028
+ 'reason', 'orphaned_message',
4029
+ 'messageId', queued_message.msg_id::text
4030
+ );
4031
+ END IF;
4032
+
4033
+ SELECT * INTO claimed_run
4034
+ FROM supacloud_workflows.runs
4035
+ WHERE id = claimed_step.run_id
4036
+ FOR UPDATE;
4037
+ IF claimed_run.status NOT IN ('queued', 'running')
4038
+ OR claimed_step.status NOT IN ('queued', 'running') THEN
4039
+ PERFORM pgmq.archive('supacloud_internal_workflows', queued_message.msg_id);
4040
+ RETURN jsonb_build_object(
4041
+ 'status', 'discarded',
4042
+ 'reason', 'step_not_claimable',
4043
+ 'runId', claimed_step.run_id,
4044
+ 'stepId', claimed_step.id,
4045
+ 'messageId', queued_message.msg_id::text
4046
+ );
4047
+ END IF;
4048
+
4049
+ IF queued_message.read_ct > claimed_step.max_attempts THEN
4050
+ UPDATE supacloud_workflows.steps
4051
+ SET status = 'dead_lettered', attempts = queued_message.read_ct,
4052
+ error_message = 'maximum attempts exceeded', completed_at = now(), updated_at = now()
4053
+ WHERE id = claimed_step.id;
4054
+ UPDATE supacloud_workflows.runs
4055
+ SET status = 'failed', error_message = 'maximum attempts exceeded',
4056
+ completed_at = now(), updated_at = now(), row_version = row_version + 1
4057
+ WHERE id = claimed_step.run_id;
4058
+ PERFORM pgmq.archive('supacloud_internal_workflows', queued_message.msg_id);
4059
+ INSERT INTO supacloud_workflows.events (
4060
+ run_id, step_id, event_type, attempt, details
4061
+ ) VALUES (
4062
+ claimed_step.run_id, claimed_step.id, 'step_dead_lettered', queued_message.read_ct,
4063
+ jsonb_build_object('errorMessage', 'maximum attempts exceeded')
4064
+ );
4065
+ RETURN jsonb_build_object(
4066
+ 'status', 'dead_lettered',
4067
+ 'runId', claimed_step.run_id,
4068
+ 'stepId', claimed_step.id,
4069
+ 'stepKey', claimed_step.step_key,
4070
+ 'messageId', queued_message.msg_id::text,
4071
+ 'attempt', queued_message.read_ct,
4072
+ 'maxAttempts', claimed_step.max_attempts
4073
+ );
4074
+ END IF;
4075
+
4076
+ UPDATE supacloud_workflows.steps
4077
+ SET status = 'running', attempts = queued_message.read_ct,
4078
+ retry_delay_seconds = 0, claimed_by = normalized_worker_id,
4079
+ claimed_at = now(), updated_at = now()
4080
+ WHERE id = claimed_step.id;
4081
+ UPDATE supacloud_workflows.runs
4082
+ SET status = 'running', started_at = coalesce(started_at, now()),
4083
+ updated_at = now(), row_version = row_version + 1
4084
+ WHERE id = claimed_step.run_id;
4085
+ INSERT INTO supacloud_workflows.events (run_id, step_id, event_type, attempt, details)
4086
+ VALUES (
4087
+ claimed_step.run_id, claimed_step.id, 'step_claimed', queued_message.read_ct,
4088
+ jsonb_build_object('workerId', normalized_worker_id)
4089
+ );
4090
+
4091
+ RETURN jsonb_build_object(
4092
+ 'status', 'claimed',
4093
+ 'runId', claimed_step.run_id,
4094
+ 'workflowName', claimed_run.workflow_name,
4095
+ 'workflowVersion', claimed_run.workflow_version,
4096
+ 'stepId', claimed_step.id,
4097
+ 'stepKey', claimed_step.step_key,
4098
+ 'input', claimed_step.input,
4099
+ 'messageId', queued_message.msg_id::text,
4100
+ 'attempt', queued_message.read_ct,
4101
+ 'maxAttempts', claimed_step.max_attempts,
4102
+ 'workerId', normalized_worker_id
4103
+ );
4104
+ END;
4105
+ $$;
4106
+
4107
+ CREATE OR REPLACE FUNCTION supacloud_workflows.lock_step(
4108
+ p_step_id uuid
4109
+ ) RETURNS supacloud_workflows.steps
4110
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
4111
+ DECLARE
4112
+ candidate_run_id uuid;
4113
+ active_step supacloud_workflows.steps%ROWTYPE;
4114
+ BEGIN
4115
+ SELECT step.run_id INTO candidate_run_id
4116
+ FROM supacloud_workflows.steps step
4117
+ WHERE step.id = p_step_id;
4118
+ IF NOT FOUND THEN
4119
+ RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_STEP_NOT_FOUND' USING ERRCODE = 'P0002';
4120
+ END IF;
4121
+ PERFORM pg_advisory_xact_lock(hashtextextended(candidate_run_id::text, 0));
4122
+ SELECT * INTO active_step
4123
+ FROM supacloud_workflows.steps
4124
+ WHERE id = p_step_id
4125
+ FOR UPDATE;
4126
+ IF NOT FOUND THEN
4127
+ RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_STEP_NOT_FOUND' USING ERRCODE = 'P0002';
4128
+ END IF;
4129
+ RETURN active_step;
4130
+ END;
4131
+ $$;
4132
+
4133
+ CREATE OR REPLACE FUNCTION supacloud_workflows.lock_step_attempt(
4134
+ p_step_id uuid,
4135
+ p_message_id bigint,
4136
+ p_attempt integer,
4137
+ p_worker_id text
4138
+ ) RETURNS supacloud_workflows.steps
4139
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
4140
+ DECLARE
4141
+ active_step supacloud_workflows.steps%ROWTYPE;
4142
+ normalized_worker_id text := nullif(btrim(p_worker_id), '');
4143
+ BEGIN
4144
+ active_step := supacloud_workflows.lock_step(p_step_id);
4145
+ IF normalized_worker_id IS NULL
4146
+ OR p_message_id IS NULL
4147
+ OR p_attempt IS NULL
4148
+ OR active_step.queue_message_id IS DISTINCT FROM p_message_id
4149
+ OR active_step.attempts IS DISTINCT FROM p_attempt
4150
+ OR active_step.claimed_by IS DISTINCT FROM normalized_worker_id THEN
4151
+ RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_STALE_ATTEMPT' USING ERRCODE = '40001';
4152
+ END IF;
4153
+ RETURN active_step;
4154
+ END;
4155
+ $$;
4156
+
4157
+ CREATE OR REPLACE FUNCTION supacloud_workflows.advance_step(
4158
+ p_step_id uuid,
4159
+ p_message_id bigint,
4160
+ p_attempt integer,
4161
+ p_worker_id text,
4162
+ p_output jsonb,
4163
+ p_next_step_key text,
4164
+ p_next_input jsonb,
4165
+ p_next_max_attempts integer
4166
+ ) RETURNS jsonb
4167
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
4168
+ DECLARE
4169
+ current_step supacloud_workflows.steps%ROWTYPE;
4170
+ next_step supacloud_workflows.steps%ROWTYPE;
4171
+ normalized_next_step_key text := nullif(btrim(p_next_step_key), '');
4172
+ archived boolean;
4173
+ BEGIN
4174
+ IF jsonb_typeof(p_output) IS DISTINCT FROM 'object'
4175
+ OR jsonb_typeof(p_next_input) IS DISTINCT FROM 'object'
4176
+ OR normalized_next_step_key IS NULL
4177
+ OR normalized_next_step_key !~ '^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$'
4178
+ OR p_next_max_attempts NOT BETWEEN 1 AND 100 THEN
4179
+ RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_ADVANCE_INVALID' USING ERRCODE = '22023';
4180
+ END IF;
4181
+ current_step := supacloud_workflows.lock_step_attempt(
4182
+ p_step_id, p_message_id, p_attempt, p_worker_id
4183
+ );
4184
+
4185
+ IF current_step.status = 'completed' THEN
4186
+ SELECT * INTO next_step
4187
+ FROM supacloud_workflows.steps
4188
+ WHERE run_id = current_step.run_id AND step_key = normalized_next_step_key;
4189
+ IF NOT FOUND
4190
+ OR current_step.output IS DISTINCT FROM p_output
4191
+ OR current_step.next_step_key IS DISTINCT FROM normalized_next_step_key
4192
+ OR next_step.input IS DISTINCT FROM p_next_input
4193
+ OR next_step.max_attempts IS DISTINCT FROM p_next_max_attempts THEN
4194
+ RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_IDEMPOTENCY_CONFLICT' USING ERRCODE = '23505';
4195
+ END IF;
4196
+ RETURN supacloud_workflows.snapshot(current_step.run_id, true);
4197
+ END IF;
4198
+ IF current_step.status <> 'running' THEN
4199
+ RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_STALE_ATTEMPT' USING ERRCODE = '40001';
4200
+ END IF;
4201
+
4202
+ SELECT pgmq.archive('supacloud_internal_workflows', p_message_id) INTO archived;
4203
+ IF archived IS DISTINCT FROM true THEN
4204
+ RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_QUEUE_MESSAGE_MISSING' USING ERRCODE = '55000';
4205
+ END IF;
4206
+ UPDATE supacloud_workflows.steps
4207
+ SET status = 'completed', output = p_output, error_message = '',
4208
+ completed_at = now(), next_step_key = normalized_next_step_key, updated_at = now()
4209
+ WHERE id = current_step.id;
4210
+ INSERT INTO supacloud_workflows.events (
4211
+ run_id, step_id, event_type, attempt, details
4212
+ ) VALUES (
4213
+ current_step.run_id, current_step.id, 'step_completed', p_attempt,
4214
+ jsonb_build_object('nextStepKey', normalized_next_step_key)
4215
+ );
4216
+ next_step := supacloud_workflows.enqueue_step(
4217
+ current_step.run_id, normalized_next_step_key, p_next_input, p_next_max_attempts
4218
+ );
4219
+ RETURN supacloud_workflows.snapshot(current_step.run_id, false);
4220
+ END;
4221
+ $$;
4222
+
4223
+ CREATE OR REPLACE FUNCTION supacloud_workflows.complete_run(
4224
+ p_step_id uuid,
4225
+ p_message_id bigint,
4226
+ p_attempt integer,
4227
+ p_worker_id text,
4228
+ p_step_output jsonb,
4229
+ p_run_output jsonb
4230
+ ) RETURNS jsonb
4231
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
4232
+ DECLARE
4233
+ current_step supacloud_workflows.steps%ROWTYPE;
4234
+ current_run supacloud_workflows.runs%ROWTYPE;
4235
+ archived boolean;
4236
+ BEGIN
4237
+ IF jsonb_typeof(p_step_output) IS DISTINCT FROM 'object'
4238
+ OR jsonb_typeof(p_run_output) IS DISTINCT FROM 'object' THEN
4239
+ RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_COMPLETION_INVALID' USING ERRCODE = '22023';
4240
+ END IF;
4241
+ current_step := supacloud_workflows.lock_step_attempt(
4242
+ p_step_id, p_message_id, p_attempt, p_worker_id
4243
+ );
4244
+ SELECT * INTO current_run
4245
+ FROM supacloud_workflows.runs
4246
+ WHERE id = current_step.run_id
4247
+ FOR UPDATE;
4248
+
4249
+ IF current_step.status = 'completed' THEN
4250
+ IF current_step.next_step_key IS NOT NULL
4251
+ OR current_step.output IS DISTINCT FROM p_step_output
4252
+ OR current_run.status IS DISTINCT FROM 'completed'
4253
+ OR current_run.output IS DISTINCT FROM p_run_output THEN
4254
+ RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_IDEMPOTENCY_CONFLICT' USING ERRCODE = '23505';
4255
+ END IF;
4256
+ RETURN supacloud_workflows.snapshot(current_step.run_id, true);
4257
+ END IF;
4258
+ IF current_step.status <> 'running' THEN
4259
+ RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_STALE_ATTEMPT' USING ERRCODE = '40001';
4260
+ END IF;
4261
+ IF current_run.status <> 'running' THEN
4262
+ RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_RUN_NOT_ACTIVE' USING ERRCODE = '55000';
4263
+ END IF;
4264
+
4265
+ SELECT pgmq.archive('supacloud_internal_workflows', p_message_id) INTO archived;
4266
+ IF archived IS DISTINCT FROM true THEN
4267
+ RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_QUEUE_MESSAGE_MISSING' USING ERRCODE = '55000';
4268
+ END IF;
4269
+ UPDATE supacloud_workflows.steps
4270
+ SET status = 'completed', output = p_step_output, error_message = '',
4271
+ completed_at = now(), updated_at = now()
4272
+ WHERE id = current_step.id;
4273
+ UPDATE supacloud_workflows.runs
4274
+ SET status = 'completed', output = p_run_output, error_message = '',
4275
+ completed_at = now(), updated_at = now(), row_version = row_version + 1
4276
+ WHERE id = current_step.run_id AND status = 'running';
4277
+ INSERT INTO supacloud_workflows.events (run_id, step_id, event_type, attempt)
4278
+ VALUES (current_step.run_id, current_step.id, 'step_completed', p_attempt);
4279
+ INSERT INTO supacloud_workflows.events (run_id, step_id, event_type, attempt)
4280
+ VALUES (current_step.run_id, current_step.id, 'run_completed', p_attempt);
4281
+ RETURN supacloud_workflows.snapshot(current_step.run_id, false);
4282
+ END;
4283
+ $$;
4284
+
4285
+ CREATE OR REPLACE FUNCTION supacloud_workflows.retry_step(
4286
+ p_step_id uuid,
4287
+ p_message_id bigint,
4288
+ p_attempt integer,
4289
+ p_worker_id text,
4290
+ p_error_message text,
4291
+ p_delay_seconds integer
4292
+ ) RETURNS jsonb
4293
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
4294
+ DECLARE
4295
+ current_step supacloud_workflows.steps%ROWTYPE;
4296
+ normalized_error text := nullif(btrim(p_error_message), '');
4297
+ normalized_worker_id text := nullif(btrim(p_worker_id), '');
4298
+ retry_receipt jsonb;
4299
+ queue_message_updated boolean;
4300
+ archived boolean;
4301
+ BEGIN
4302
+ IF normalized_error IS NULL OR char_length(normalized_error) > 4000
4303
+ OR p_delay_seconds NOT BETWEEN 0 AND 86400 THEN
4304
+ RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_RETRY_INVALID' USING ERRCODE = '22023';
4305
+ END IF;
4306
+ current_step := supacloud_workflows.lock_step(p_step_id);
4307
+ SELECT event.details INTO retry_receipt
4308
+ FROM supacloud_workflows.events event
4309
+ WHERE event.step_id = current_step.id
4310
+ AND event.attempt = p_attempt
4311
+ AND event.event_type IN ('step_retried', 'step_dead_lettered')
4312
+ AND event.details ->> 'operation' = 'retry'
4313
+ ORDER BY event.id DESC
4314
+ LIMIT 1;
4315
+ IF FOUND THEN
4316
+ IF retry_receipt ->> 'messageId' IS DISTINCT FROM p_message_id::text
4317
+ OR retry_receipt ->> 'workerId' IS DISTINCT FROM normalized_worker_id
4318
+ OR retry_receipt ->> 'errorMessage' IS DISTINCT FROM normalized_error
4319
+ OR (retry_receipt ->> 'delaySeconds')::integer IS DISTINCT FROM p_delay_seconds THEN
4320
+ RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_IDEMPOTENCY_CONFLICT' USING ERRCODE = '23505';
4321
+ END IF;
4322
+ RETURN supacloud_workflows.snapshot(current_step.run_id, true);
4323
+ END IF;
4324
+ IF normalized_worker_id IS NULL
4325
+ OR p_message_id IS NULL
4326
+ OR p_attempt IS NULL
4327
+ OR current_step.queue_message_id IS DISTINCT FROM p_message_id
4328
+ OR current_step.attempts IS DISTINCT FROM p_attempt
4329
+ OR current_step.claimed_by IS DISTINCT FROM normalized_worker_id
4330
+ OR current_step.status <> 'running' THEN
4331
+ RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_STALE_ATTEMPT' USING ERRCODE = '40001';
4332
+ END IF;
4333
+
4334
+ IF current_step.attempts >= current_step.max_attempts THEN
4335
+ SELECT pgmq.archive('supacloud_internal_workflows', p_message_id) INTO archived;
4336
+ IF archived IS DISTINCT FROM true THEN
4337
+ RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_QUEUE_MESSAGE_MISSING' USING ERRCODE = '55000';
4338
+ END IF;
4339
+ UPDATE supacloud_workflows.steps
4340
+ SET status = 'dead_lettered', error_message = normalized_error,
4341
+ completed_at = now(), updated_at = now()
4342
+ WHERE id = current_step.id;
4343
+ UPDATE supacloud_workflows.runs
4344
+ SET status = 'failed', error_message = normalized_error,
4345
+ completed_at = now(), updated_at = now(), row_version = row_version + 1
4346
+ WHERE id = current_step.run_id AND status = 'running';
4347
+ IF NOT FOUND THEN
4348
+ RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_RUN_NOT_ACTIVE' USING ERRCODE = '55000';
4349
+ END IF;
4350
+ INSERT INTO supacloud_workflows.events (
4351
+ run_id, step_id, event_type, attempt, details
4352
+ ) VALUES (
4353
+ current_step.run_id, current_step.id, 'step_dead_lettered', p_attempt,
4354
+ jsonb_build_object(
4355
+ 'operation', 'retry',
4356
+ 'messageId', p_message_id::text,
4357
+ 'workerId', normalized_worker_id,
4358
+ 'errorMessage', normalized_error,
4359
+ 'delaySeconds', p_delay_seconds
4360
+ )
4361
+ );
4362
+ RETURN supacloud_workflows.snapshot(current_step.run_id, false);
4363
+ END IF;
4364
+
4365
+ SELECT EXISTS (
4366
+ SELECT 1 FROM pgmq.set_vt('supacloud_internal_workflows', p_message_id, p_delay_seconds)
4367
+ ) INTO queue_message_updated;
4368
+ IF queue_message_updated IS DISTINCT FROM true THEN
4369
+ RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_QUEUE_MESSAGE_MISSING' USING ERRCODE = '55000';
4370
+ END IF;
4371
+ UPDATE supacloud_workflows.steps
4372
+ SET status = 'queued', error_message = normalized_error,
4373
+ retry_delay_seconds = p_delay_seconds, updated_at = now()
4374
+ WHERE id = current_step.id;
4375
+ INSERT INTO supacloud_workflows.events (
4376
+ run_id, step_id, event_type, attempt, details
4377
+ ) VALUES (
4378
+ current_step.run_id, current_step.id, 'step_retried', p_attempt,
4379
+ jsonb_build_object(
4380
+ 'operation', 'retry',
4381
+ 'messageId', p_message_id::text,
4382
+ 'workerId', normalized_worker_id,
4383
+ 'errorMessage', normalized_error,
4384
+ 'delaySeconds', p_delay_seconds
4385
+ )
4386
+ );
4387
+ RETURN supacloud_workflows.snapshot(current_step.run_id, false);
4388
+ END;
4389
+ $$;
4390
+
4391
+ CREATE OR REPLACE FUNCTION supacloud_workflows.fail_step(
4392
+ p_step_id uuid,
4393
+ p_message_id bigint,
4394
+ p_attempt integer,
4395
+ p_worker_id text,
4396
+ p_error_message text
4397
+ ) RETURNS jsonb
4398
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
4399
+ DECLARE
4400
+ current_step supacloud_workflows.steps%ROWTYPE;
4401
+ current_run supacloud_workflows.runs%ROWTYPE;
4402
+ normalized_error text := nullif(btrim(p_error_message), '');
4403
+ archived boolean;
4404
+ BEGIN
4405
+ IF normalized_error IS NULL OR char_length(normalized_error) > 4000 THEN
4406
+ RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_FAILURE_INVALID' USING ERRCODE = '22023';
4407
+ END IF;
4408
+ current_step := supacloud_workflows.lock_step_attempt(
4409
+ p_step_id, p_message_id, p_attempt, p_worker_id
4410
+ );
4411
+ SELECT * INTO current_run
4412
+ FROM supacloud_workflows.runs
4413
+ WHERE id = current_step.run_id
4414
+ FOR UPDATE;
4415
+
4416
+ IF current_step.status = 'failed' THEN
4417
+ IF current_step.error_message IS DISTINCT FROM normalized_error
4418
+ OR current_run.status IS DISTINCT FROM 'failed'
4419
+ OR current_run.error_message IS DISTINCT FROM normalized_error THEN
4420
+ RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_IDEMPOTENCY_CONFLICT' USING ERRCODE = '23505';
4421
+ END IF;
4422
+ RETURN supacloud_workflows.snapshot(current_step.run_id, true);
4423
+ END IF;
4424
+ IF current_step.status <> 'running' THEN
4425
+ RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_STALE_ATTEMPT' USING ERRCODE = '40001';
4426
+ END IF;
4427
+ IF current_run.status <> 'running' THEN
4428
+ RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_RUN_NOT_ACTIVE' USING ERRCODE = '55000';
4429
+ END IF;
4430
+
4431
+ SELECT pgmq.archive('supacloud_internal_workflows', p_message_id) INTO archived;
4432
+ IF archived IS DISTINCT FROM true THEN
4433
+ RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_QUEUE_MESSAGE_MISSING' USING ERRCODE = '55000';
4434
+ END IF;
4435
+ UPDATE supacloud_workflows.steps
4436
+ SET status = 'failed', error_message = normalized_error,
4437
+ completed_at = now(), updated_at = now()
4438
+ WHERE id = current_step.id;
4439
+ UPDATE supacloud_workflows.runs
4440
+ SET status = 'failed', error_message = normalized_error,
4441
+ completed_at = now(), updated_at = now(), row_version = row_version + 1
4442
+ WHERE id = current_step.run_id AND status = 'running';
4443
+ INSERT INTO supacloud_workflows.events (
4444
+ run_id, step_id, event_type, attempt, details
4445
+ ) VALUES (
4446
+ current_step.run_id, current_step.id, 'step_failed', p_attempt,
4447
+ jsonb_build_object('errorMessage', normalized_error)
4448
+ );
4449
+ RETURN supacloud_workflows.snapshot(current_step.run_id, false);
4450
+ END;
4451
+ $$;
4452
+
4453
+ CREATE OR REPLACE FUNCTION supacloud_workflows.cancel_run(
4454
+ p_run_id uuid,
4455
+ p_reason text
4456
+ ) RETURNS jsonb
4457
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
4458
+ DECLARE
4459
+ normalized_reason text := nullif(btrim(p_reason), '');
4460
+ locked_run supacloud_workflows.runs%ROWTYPE;
4461
+ active_step supacloud_workflows.steps%ROWTYPE;
4462
+ BEGIN
4463
+ IF p_run_id IS NULL OR normalized_reason IS NULL OR char_length(normalized_reason) > 4000 THEN
4464
+ RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_CANCEL_INVALID' USING ERRCODE = '22023';
4465
+ END IF;
4466
+ PERFORM pg_advisory_xact_lock(hashtextextended(p_run_id::text, 0));
4467
+ SELECT * INTO locked_run FROM supacloud_workflows.runs WHERE id = p_run_id FOR UPDATE;
4468
+ IF NOT FOUND THEN
4469
+ RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_RUN_NOT_FOUND' USING ERRCODE = 'P0002';
4470
+ END IF;
4471
+ IF locked_run.status = 'cancelled' THEN
4472
+ IF locked_run.error_message IS DISTINCT FROM normalized_reason THEN
4473
+ RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_IDEMPOTENCY_CONFLICT' USING ERRCODE = '23505';
4474
+ END IF;
4475
+ RETURN supacloud_workflows.snapshot(p_run_id, true);
4476
+ END IF;
4477
+ IF locked_run.status NOT IN ('queued', 'running') THEN
4478
+ RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_RUN_NOT_ACTIVE' USING ERRCODE = '55000';
4479
+ END IF;
4480
+ SELECT * INTO active_step
4481
+ FROM supacloud_workflows.steps
4482
+ WHERE run_id = p_run_id AND status IN ('queued', 'running')
4483
+ FOR UPDATE;
4484
+ IF FOUND THEN
4485
+ PERFORM pgmq.archive('supacloud_internal_workflows', active_step.queue_message_id);
4486
+ UPDATE supacloud_workflows.steps
4487
+ SET status = 'cancelled', error_message = normalized_reason,
4488
+ completed_at = now(), updated_at = now()
4489
+ WHERE id = active_step.id;
4490
+ END IF;
4491
+ UPDATE supacloud_workflows.runs
4492
+ SET status = 'cancelled', error_message = normalized_reason,
4493
+ completed_at = now(), updated_at = now(), row_version = row_version + 1
4494
+ WHERE id = p_run_id;
4495
+ INSERT INTO supacloud_workflows.events (
4496
+ run_id, step_id, event_type, details
4497
+ ) VALUES (
4498
+ p_run_id, active_step.id, 'run_cancelled',
4499
+ jsonb_build_object('reason', normalized_reason)
4500
+ );
4501
+ RETURN supacloud_workflows.snapshot(p_run_id, false);
4502
+ END;
4503
+ $$;
4504
+
4505
+ CREATE OR REPLACE FUNCTION supacloud_workflows.run_events(
4506
+ p_run_id uuid,
4507
+ p_after_event_id bigint,
4508
+ p_limit integer
4509
+ ) RETURNS jsonb
4510
+ LANGUAGE sql STABLE SECURITY DEFINER SET search_path = '' AS $$
4511
+ SELECT coalesce(jsonb_agg(jsonb_build_object(
4512
+ 'eventId', page.id::text,
4513
+ 'runId', page.run_id,
4514
+ 'stepId', page.step_id,
4515
+ 'eventType', page.event_type,
4516
+ 'attempt', page.attempt,
4517
+ 'details', page.details,
4518
+ 'createdAt', page.created_at
4519
+ ) ORDER BY page.id), '[]'::jsonb)
4520
+ FROM (
4521
+ SELECT event.*
4522
+ FROM supacloud_workflows.events event
4523
+ WHERE event.run_id = p_run_id AND event.id > p_after_event_id
4524
+ ORDER BY event.id
4525
+ LIMIT p_limit
4526
+ ) page
4527
+ $$;
4528
+
4529
+ CREATE OR REPLACE FUNCTION supacloud_workflows.request_uuid(
4530
+ request jsonb,
4531
+ key text
4532
+ ) RETURNS uuid
4533
+ LANGUAGE plpgsql IMMUTABLE SET search_path = '' AS $$
4534
+ DECLARE
4535
+ uuid_text text;
4536
+ BEGIN
4537
+ IF jsonb_typeof(request) IS DISTINCT FROM 'object' THEN
4538
+ RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_REQUEST_INVALID' USING ERRCODE = '22023';
4539
+ END IF;
4540
+ uuid_text := request ->> key;
4541
+ IF uuid_text IS NULL
4542
+ OR uuid_text !~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$' THEN
4543
+ RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_REQUEST_INVALID' USING ERRCODE = '22023';
4544
+ END IF;
4545
+ RETURN uuid_text::uuid;
4546
+ END;
4547
+ $$;
4548
+
4549
+ CREATE OR REPLACE FUNCTION public.supacloud_workflow_start(request jsonb)
4550
+ RETURNS jsonb
4551
+ LANGUAGE plpgsql VOLATILE SECURITY DEFINER SET search_path = '' AS $$
4552
+ DECLARE
4553
+ max_attempts integer;
4554
+ BEGIN
4555
+ max_attempts := coalesce((request ->> 'maxAttempts')::integer, 3);
4556
+ RETURN supacloud_workflows.start_run(
4557
+ supacloud_workflows.request_uuid(request, 'runId'),
4558
+ request ->> 'workflowName',
4559
+ request ->> 'workflowVersion',
4560
+ request ->> 'firstStepKey',
4561
+ coalesce(request -> 'input', '{}'::jsonb),
4562
+ max_attempts
4563
+ );
4564
+ EXCEPTION
4565
+ WHEN invalid_parameter_value OR invalid_text_representation OR numeric_value_out_of_range THEN
4566
+ RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_START_INVALID' USING ERRCODE = '22023';
4567
+ END;
4568
+ $$;
4569
+
4570
+ CREATE OR REPLACE FUNCTION public.supacloud_workflow_claim(request jsonb)
4571
+ RETURNS jsonb
4572
+ LANGUAGE plpgsql VOLATILE SECURITY DEFINER SET search_path = '' AS $$
4573
+ DECLARE
4574
+ visibility_timeout_seconds integer;
4575
+ BEGIN
4576
+ IF jsonb_typeof(request) IS DISTINCT FROM 'object' THEN
4577
+ RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_CLAIM_INVALID' USING ERRCODE = '22023';
4578
+ END IF;
4579
+ visibility_timeout_seconds := coalesce((request ->> 'visibilityTimeoutSeconds')::integer, 300);
4580
+ RETURN supacloud_workflows.claim_step(
4581
+ request ->> 'workerId', visibility_timeout_seconds
4582
+ );
4583
+ EXCEPTION
4584
+ WHEN invalid_parameter_value OR invalid_text_representation OR numeric_value_out_of_range THEN
4585
+ RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_CLAIM_INVALID' USING ERRCODE = '22023';
4586
+ END;
4587
+ $$;
4588
+
4589
+ CREATE OR REPLACE FUNCTION public.supacloud_workflow_advance(request jsonb)
4590
+ RETURNS jsonb
4591
+ LANGUAGE plpgsql VOLATILE SECURITY DEFINER SET search_path = '' AS $$
4592
+ DECLARE
4593
+ message_id bigint;
4594
+ attempt integer;
4595
+ next_max_attempts integer;
4596
+ BEGIN
4597
+ message_id := (request ->> 'messageId')::bigint;
4598
+ attempt := (request ->> 'attempt')::integer;
4599
+ next_max_attempts := coalesce((request ->> 'nextMaxAttempts')::integer, 3);
4600
+ IF message_id <= 0 OR attempt <= 0 THEN
4601
+ RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_ADVANCE_INVALID' USING ERRCODE = '22023';
4602
+ END IF;
4603
+ RETURN supacloud_workflows.advance_step(
4604
+ supacloud_workflows.request_uuid(request, 'stepId'),
4605
+ message_id,
4606
+ attempt,
4607
+ request ->> 'workerId',
4608
+ coalesce(request -> 'output', '{}'::jsonb),
4609
+ request ->> 'nextStepKey',
4610
+ coalesce(request -> 'nextInput', '{}'::jsonb),
4611
+ next_max_attempts
4612
+ );
4613
+ EXCEPTION
4614
+ WHEN invalid_parameter_value OR invalid_text_representation OR numeric_value_out_of_range THEN
4615
+ RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_ADVANCE_INVALID' USING ERRCODE = '22023';
4616
+ END;
4617
+ $$;
4618
+
4619
+ CREATE OR REPLACE FUNCTION public.supacloud_workflow_complete(request jsonb)
4620
+ RETURNS jsonb
4621
+ LANGUAGE plpgsql VOLATILE SECURITY DEFINER SET search_path = '' AS $$
4622
+ DECLARE
4623
+ message_id bigint;
4624
+ attempt integer;
4625
+ BEGIN
4626
+ message_id := (request ->> 'messageId')::bigint;
4627
+ attempt := (request ->> 'attempt')::integer;
4628
+ IF message_id <= 0 OR attempt <= 0 THEN
4629
+ RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_COMPLETION_INVALID' USING ERRCODE = '22023';
4630
+ END IF;
4631
+ RETURN supacloud_workflows.complete_run(
4632
+ supacloud_workflows.request_uuid(request, 'stepId'),
4633
+ message_id,
4634
+ attempt,
4635
+ request ->> 'workerId',
4636
+ coalesce(request -> 'stepOutput', '{}'::jsonb),
4637
+ coalesce(request -> 'runOutput', '{}'::jsonb)
4638
+ );
4639
+ EXCEPTION
4640
+ WHEN invalid_parameter_value OR invalid_text_representation OR numeric_value_out_of_range THEN
4641
+ RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_COMPLETION_INVALID' USING ERRCODE = '22023';
4642
+ END;
4643
+ $$;
4644
+
4645
+ CREATE OR REPLACE FUNCTION public.supacloud_workflow_retry(request jsonb)
4646
+ RETURNS jsonb
4647
+ LANGUAGE plpgsql VOLATILE SECURITY DEFINER SET search_path = '' AS $$
4648
+ DECLARE
4649
+ message_id bigint;
4650
+ attempt integer;
4651
+ delay_seconds integer;
4652
+ BEGIN
4653
+ message_id := (request ->> 'messageId')::bigint;
4654
+ attempt := (request ->> 'attempt')::integer;
4655
+ delay_seconds := coalesce((request ->> 'delaySeconds')::integer, 0);
4656
+ IF message_id <= 0 OR attempt <= 0 THEN
4657
+ RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_RETRY_INVALID' USING ERRCODE = '22023';
4658
+ END IF;
4659
+ RETURN supacloud_workflows.retry_step(
4660
+ supacloud_workflows.request_uuid(request, 'stepId'),
4661
+ message_id,
4662
+ attempt,
4663
+ request ->> 'workerId',
4664
+ request ->> 'errorMessage',
4665
+ delay_seconds
4666
+ );
4667
+ EXCEPTION
4668
+ WHEN invalid_parameter_value OR invalid_text_representation OR numeric_value_out_of_range THEN
4669
+ RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_RETRY_INVALID' USING ERRCODE = '22023';
4670
+ END;
4671
+ $$;
4672
+
4673
+ CREATE OR REPLACE FUNCTION public.supacloud_workflow_fail(request jsonb)
4674
+ RETURNS jsonb
4675
+ LANGUAGE plpgsql VOLATILE SECURITY DEFINER SET search_path = '' AS $$
4676
+ DECLARE
4677
+ message_id bigint;
4678
+ attempt integer;
4679
+ BEGIN
4680
+ message_id := (request ->> 'messageId')::bigint;
4681
+ attempt := (request ->> 'attempt')::integer;
4682
+ IF message_id <= 0 OR attempt <= 0 THEN
4683
+ RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_FAILURE_INVALID' USING ERRCODE = '22023';
4684
+ END IF;
4685
+ RETURN supacloud_workflows.fail_step(
4686
+ supacloud_workflows.request_uuid(request, 'stepId'),
4687
+ message_id,
4688
+ attempt,
4689
+ request ->> 'workerId',
4690
+ request ->> 'errorMessage'
4691
+ );
4692
+ EXCEPTION
4693
+ WHEN invalid_parameter_value OR invalid_text_representation OR numeric_value_out_of_range THEN
4694
+ RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_FAILURE_INVALID' USING ERRCODE = '22023';
4695
+ END;
4696
+ $$;
4697
+
4698
+ CREATE OR REPLACE FUNCTION public.supacloud_workflow_cancel(request jsonb)
4699
+ RETURNS jsonb
4700
+ LANGUAGE plpgsql VOLATILE SECURITY DEFINER SET search_path = '' AS $$
4701
+ BEGIN
4702
+ RETURN supacloud_workflows.cancel_run(
4703
+ supacloud_workflows.request_uuid(request, 'runId'),
4704
+ request ->> 'reason'
4705
+ );
4706
+ EXCEPTION
4707
+ WHEN invalid_parameter_value OR invalid_text_representation THEN
4708
+ RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_CANCEL_INVALID' USING ERRCODE = '22023';
4709
+ END;
4710
+ $$;
4711
+
4712
+ CREATE OR REPLACE FUNCTION public.supacloud_workflow_get(request jsonb)
4713
+ RETURNS jsonb
4714
+ LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = '' AS $$
4715
+ BEGIN
4716
+ RETURN supacloud_workflows.snapshot(
4717
+ supacloud_workflows.request_uuid(request, 'runId'), false
4718
+ );
4719
+ EXCEPTION
4720
+ WHEN invalid_parameter_value OR invalid_text_representation THEN
4721
+ RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_GET_INVALID' USING ERRCODE = '22023';
4722
+ END;
4723
+ $$;
4724
+
4725
+ CREATE OR REPLACE FUNCTION public.supacloud_workflow_events(request jsonb)
4726
+ RETURNS jsonb
4727
+ LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = '' AS $$
4728
+ DECLARE
4729
+ after_event_id bigint;
4730
+ event_limit integer;
4731
+ BEGIN
4732
+ after_event_id := coalesce((request ->> 'afterEventId')::bigint, 0);
4733
+ event_limit := coalesce((request ->> 'limit')::integer, 100);
4734
+ IF after_event_id < 0 OR event_limit NOT BETWEEN 1 AND 500 THEN
4735
+ RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_EVENTS_INVALID' USING ERRCODE = '22023';
4736
+ END IF;
4737
+ RETURN supacloud_workflows.run_events(
4738
+ supacloud_workflows.request_uuid(request, 'runId'), after_event_id, event_limit
4739
+ );
4740
+ EXCEPTION
4741
+ WHEN invalid_parameter_value OR invalid_text_representation OR numeric_value_out_of_range THEN
4742
+ RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_EVENTS_INVALID' USING ERRCODE = '22023';
4743
+ END;
4744
+ $$;
4745
+
4746
+ REVOKE ALL ON ALL TABLES IN SCHEMA supacloud_workflows
4747
+ FROM PUBLIC, anon, authenticated, service_role;
4748
+ REVOKE ALL ON ALL SEQUENCES IN SCHEMA supacloud_workflows
4749
+ FROM PUBLIC, anon, authenticated, service_role;
4750
+ REVOKE ALL ON ALL FUNCTIONS IN SCHEMA supacloud_workflows
4751
+ FROM PUBLIC, anon, authenticated, service_role;
4752
+
4753
+ REVOKE ALL ON FUNCTION public.supacloud_workflow_start(jsonb) FROM PUBLIC, anon, authenticated;
4754
+ REVOKE ALL ON FUNCTION public.supacloud_workflow_claim(jsonb) FROM PUBLIC, anon, authenticated;
4755
+ REVOKE ALL ON FUNCTION public.supacloud_workflow_advance(jsonb) FROM PUBLIC, anon, authenticated;
4756
+ REVOKE ALL ON FUNCTION public.supacloud_workflow_complete(jsonb) FROM PUBLIC, anon, authenticated;
4757
+ REVOKE ALL ON FUNCTION public.supacloud_workflow_retry(jsonb) FROM PUBLIC, anon, authenticated;
4758
+ REVOKE ALL ON FUNCTION public.supacloud_workflow_fail(jsonb) FROM PUBLIC, anon, authenticated;
4759
+ REVOKE ALL ON FUNCTION public.supacloud_workflow_cancel(jsonb) FROM PUBLIC, anon, authenticated;
4760
+ REVOKE ALL ON FUNCTION public.supacloud_workflow_get(jsonb) FROM PUBLIC, anon, authenticated;
4761
+ REVOKE ALL ON FUNCTION public.supacloud_workflow_events(jsonb) FROM PUBLIC, anon, authenticated;
4762
+
4763
+ GRANT EXECUTE ON FUNCTION public.supacloud_workflow_start(jsonb) TO service_role;
4764
+ GRANT EXECUTE ON FUNCTION public.supacloud_workflow_claim(jsonb) TO service_role;
4765
+ GRANT EXECUTE ON FUNCTION public.supacloud_workflow_advance(jsonb) TO service_role;
4766
+ GRANT EXECUTE ON FUNCTION public.supacloud_workflow_complete(jsonb) TO service_role;
4767
+ GRANT EXECUTE ON FUNCTION public.supacloud_workflow_retry(jsonb) TO service_role;
4768
+ GRANT EXECUTE ON FUNCTION public.supacloud_workflow_fail(jsonb) TO service_role;
4769
+ GRANT EXECUTE ON FUNCTION public.supacloud_workflow_cancel(jsonb) TO service_role;
4770
+ GRANT EXECUTE ON FUNCTION public.supacloud_workflow_get(jsonb) TO service_role;
4771
+ GRANT EXECUTE ON FUNCTION public.supacloud_workflow_events(jsonb) TO service_role;
4772
+ -- supacloud:sql-module:workflows-public:end
3588
4773
  `;
3589
4774
  var CRON_SQL = `
3590
4775
  create schema if not exists cron;
@@ -3911,12 +5096,32 @@ function pickTag(text, base) {
3911
5096
  return tag;
3912
5097
  }
3913
5098
 
3914
- // src/runtime/db/pglite-engine.ts
3915
- import { mkdir, open, unlink as unlink2 } from "fs/promises";
3916
- import { dirname, resolve } from "path";
3917
-
3918
5099
  // src/runtime/db/data-dir-lock.ts
3919
- import { readFile, unlink } from "fs/promises";
5100
+ import { mkdir, open, readFile, unlink } from "fs/promises";
5101
+ import { dirname, resolve } from "path";
5102
+ async function acquireDataDirLock(dataDir, engineName = "database") {
5103
+ if (!dataDir || dataDir.includes("://"))
5104
+ return async () => {};
5105
+ const absoluteDataDir = resolve(dataDir);
5106
+ const lockPath = `${absoluteDataDir}.supacloud-lite.lock`;
5107
+ await mkdir(dirname(absoluteDataDir), { recursive: true, mode: 448 });
5108
+ const nonce = crypto.randomUUID();
5109
+ const handle = await createDataDirLock(absoluteDataDir, lockPath, nonce, engineName);
5110
+ let released = false;
5111
+ return async () => {
5112
+ if (released)
5113
+ return;
5114
+ released = true;
5115
+ await handle.close();
5116
+ const owner = await readDataDirLockOwner(lockPath);
5117
+ if (owner?.nonce !== nonce)
5118
+ return;
5119
+ await unlink(lockPath).catch((error) => {
5120
+ if (error.code !== "ENOENT")
5121
+ throw error;
5122
+ });
5123
+ };
5124
+ }
3920
5125
  async function recoverStaleDataDirLock(lockPath) {
3921
5126
  const lockState = await inspectDataDirLock(lockPath);
3922
5127
  if (lockState.kind !== "stale")
@@ -3934,6 +5139,38 @@ async function readDataDirLockOwner(lockPath) {
3934
5139
  const lockState = await inspectDataDirLock(lockPath);
3935
5140
  return lockState.kind === "active" || lockState.kind === "stale" ? lockState.owner : null;
3936
5141
  }
5142
+ async function createDataDirLock(absoluteDataDir, lockPath, nonce, engineName) {
5143
+ for (let attempt = 0;attempt < 3; attempt++) {
5144
+ try {
5145
+ return await writeDataDirLock(lockPath, nonce);
5146
+ } catch (error) {
5147
+ if (error.code !== "EEXIST")
5148
+ throw error;
5149
+ const lockState = await recoverStaleDataDirLock(lockPath);
5150
+ if (lockState.kind === "active") {
5151
+ throw new Error(`${engineName} data directory is already in use: ${absoluteDataDir} (pid ${lockState.owner.pid})`);
5152
+ }
5153
+ if (lockState.kind === "unreadable")
5154
+ throw unreadableLockError(lockPath, engineName);
5155
+ }
5156
+ }
5157
+ throw unreadableLockError(lockPath, engineName);
5158
+ }
5159
+ async function writeDataDirLock(lockPath, nonce) {
5160
+ const handle = await open(lockPath, "wx", 384);
5161
+ try {
5162
+ await handle.writeFile(`${JSON.stringify({ pid: process.pid, nonce, createdAt: new Date().toISOString() })}
5163
+ `);
5164
+ return handle;
5165
+ } catch (error) {
5166
+ await handle.close().catch(() => {});
5167
+ await unlink(lockPath).catch(() => {});
5168
+ throw error;
5169
+ }
5170
+ }
5171
+ function unreadableLockError(lockPath, engineName) {
5172
+ return new Error(`${engineName} data directory has an unreadable lock: ${lockPath}. ` + "Confirm no SupaCloud Lite process is using it, then remove the lock manually.");
5173
+ }
3937
5174
  async function inspectDataDirLock(lockPath) {
3938
5175
  let contents;
3939
5176
  try {
@@ -3987,7 +5224,7 @@ begin
3987
5224
  end $$;
3988
5225
  `;
3989
5226
  async function createPgliteEngine(dataDir) {
3990
- const releaseLock = await acquireDataDirLock(dataDir);
5227
+ const releaseLock = await acquireDataDirLock(dataDir, "PGlite");
3991
5228
  let PGlite, extensions;
3992
5229
  const standaloneAssets = getStandaloneAssets();
3993
5230
  let cleanupStandaloneBundles = async () => {};
@@ -4111,63 +5348,6 @@ async function removePreparedBundles(cleanup) {
4111
5348
  console.error("Unable to remove temporary PGlite extension bundles:", error);
4112
5349
  }
4113
5350
  }
4114
- async function acquireDataDirLock(dataDir) {
4115
- if (!dataDir || dataDir.includes("://"))
4116
- return async () => {};
4117
- const absoluteDataDir = resolve(dataDir);
4118
- const lockPath = `${absoluteDataDir}.supacloud-lite.lock`;
4119
- await mkdir(dirname(absoluteDataDir), { recursive: true, mode: 448 });
4120
- const nonce = crypto.randomUUID();
4121
- const handle = await createDataDirLock(absoluteDataDir, lockPath, nonce);
4122
- let released = false;
4123
- return async () => {
4124
- if (released)
4125
- return;
4126
- released = true;
4127
- await handle?.close();
4128
- const owner = await readDataDirLockOwner(lockPath);
4129
- if (owner?.nonce !== nonce)
4130
- return;
4131
- await unlink2(lockPath).catch((error) => {
4132
- if (error.code !== "ENOENT")
4133
- throw error;
4134
- });
4135
- };
4136
- }
4137
- async function createDataDirLock(absoluteDataDir, lockPath, nonce) {
4138
- for (let attempt = 0;attempt < 3; attempt++) {
4139
- try {
4140
- return await writeDataDirLock(lockPath, nonce);
4141
- } catch (error) {
4142
- if (error.code !== "EEXIST")
4143
- throw error;
4144
- const lockState = await recoverStaleDataDirLock(lockPath);
4145
- if (lockState.kind === "active")
4146
- throw lockInUseError(absoluteDataDir, lockState.owner.pid);
4147
- if (lockState.kind === "unreadable")
4148
- throw unreadableLockError(lockPath);
4149
- }
4150
- }
4151
- throw unreadableLockError(lockPath);
4152
- }
4153
- async function writeDataDirLock(lockPath, nonce) {
4154
- const handle = await open(lockPath, "wx", 384);
4155
- try {
4156
- await handle.writeFile(`${JSON.stringify({ pid: process.pid, nonce, createdAt: new Date().toISOString() })}
4157
- `);
4158
- return handle;
4159
- } catch (error) {
4160
- await handle.close().catch(() => {});
4161
- await unlink2(lockPath).catch(() => {});
4162
- throw error;
4163
- }
4164
- }
4165
- function lockInUseError(dataDir, pid) {
4166
- return new Error(`PGlite data directory is already in use: ${dataDir} (pid ${pid})`);
4167
- }
4168
- function unreadableLockError(lockPath) {
4169
- return new Error(`PGlite data directory has an unreadable lock: ${lockPath}. Confirm no SupaCloud Lite process is using it, then remove the lock manually.`);
4170
- }
4171
5351
 
4172
5352
  // src/runtime/db/database.ts
4173
5353
  var DEFAULT_SEARCH_PATH_SQL = `set search_path to "$user", public, extensions`;
@@ -4185,20 +5365,30 @@ class Database {
4185
5365
  }
4186
5366
  static async create(dataDirOrEngine, opts) {
4187
5367
  const engine = dataDirOrEngine && typeof dataDirOrEngine === "object" ? dataDirOrEngine : await createPgliteEngine(dataDirOrEngine);
4188
- if (engine.minimalBootstrap) {
4189
- await engine.exec(MINIMAL_BOOTSTRAP_SQL);
4190
- } else {
4191
- await engine.exec(BOOTSTRAP_SQL);
4192
- await engine.exec(PGMQ_SQL);
4193
- await engine.exec(CRON_SQL);
4194
- await engine.exec(NET_SQL);
4195
- await engine.exec(EXT_COMPAT_SQL);
4196
- await engine.exec(VAULT_SQL);
4197
- if (opts?.vaultKey) {
4198
- await engine.query(`select set_config('app.settings.vault_key', $1, false)`, [opts.vaultKey]);
5368
+ try {
5369
+ if (engine.minimalBootstrap) {
5370
+ await engine.exec(MINIMAL_BOOTSTRAP_SQL);
5371
+ } else {
5372
+ await engine.exec(BOOTSTRAP_SQL);
5373
+ await engine.exec(PGMQ_SQL);
5374
+ await engine.exec(WORKFLOWS_SQL);
5375
+ await engine.exec(CRON_SQL);
5376
+ await engine.exec(NET_SQL);
5377
+ await engine.exec(EXT_COMPAT_SQL);
5378
+ await engine.exec(VAULT_SQL);
5379
+ if (opts?.vaultKey) {
5380
+ await engine.query(`select set_config('app.settings.vault_key', $1, false)`, [opts.vaultKey]);
5381
+ }
5382
+ }
5383
+ return new Database(engine);
5384
+ } catch (error) {
5385
+ try {
5386
+ await engine.close();
5387
+ } catch (cleanupError) {
5388
+ throw new AggregateError([error, cleanupError], "database bootstrap and cleanup failed");
4199
5389
  }
5390
+ throw error;
4200
5391
  }
4201
- return new Database(engine);
4202
5392
  }
4203
5393
  query(sql, params) {
4204
5394
  return this.engine.query(sql, params);
@@ -5937,6 +7127,25 @@ function parsePrefer(header) {
5937
7127
  }
5938
7128
  return prefer;
5939
7129
  }
7130
+ function applyRequestRange(query, request) {
7131
+ const range = request.headers.get("range");
7132
+ if (!range || query.limits.has("") || query.offsets.has(""))
7133
+ return;
7134
+ const unit = request.headers.get("range-unit");
7135
+ if (unit !== null && unit.toLowerCase() !== "items")
7136
+ throw new ParseError(`unsupported range unit: ${unit}`);
7137
+ const match = range.match(/^(\d+)-(\d*)$/);
7138
+ if (!match)
7139
+ throw new ParseError(`invalid range: ${range}`);
7140
+ const start = Number(match[1]);
7141
+ const end = match[2] ? Number(match[2]) : undefined;
7142
+ if (!Number.isSafeInteger(start) || end !== undefined && (!Number.isSafeInteger(end) || end < start)) {
7143
+ throw new ParseError(`invalid range: ${range}`);
7144
+ }
7145
+ query.offsets.set("", start);
7146
+ if (end !== undefined)
7147
+ query.limits.set("", end - start + 1);
7148
+ }
5940
7149
  var OBJECT_MEDIA = "application/vnd.pgrst.object+json";
5941
7150
  var CSV_MEDIA = "text/csv";
5942
7151
  var PLAN_MEDIA = "application/vnd.pgrst.plan";
@@ -6013,6 +7222,8 @@ class RestHandler {
6013
7222
  const wantsObject = accept.includes(OBJECT_MEDIA);
6014
7223
  const wantsCsv = accept.includes(CSV_MEDIA);
6015
7224
  const q = parseQuery(url.searchParams);
7225
+ if (method === "GET" || method === "HEAD")
7226
+ applyRequestRange(q, req);
6016
7227
  if (this.maxRows !== undefined && (method === "GET" || method === "HEAD")) {
6017
7228
  const requested = q.limits.get("");
6018
7229
  q.limits.set("", requested === undefined ? this.maxRows : Math.min(requested, this.maxRows));
@@ -6055,10 +7266,11 @@ class RestHandler {
6055
7266
  }
6056
7267
  return { rows: res.rows[0].body, count: count2 };
6057
7268
  });
7269
+ const offset = q.offsets.get("") ?? 0;
6058
7270
  return this.dataResponse(rows, {
6059
- status: 200,
7271
+ status: count !== null && (offset > 0 || rows.length < count) ? 206 : 200,
6060
7272
  count,
6061
- offset: q.offsets.get("") ?? 0,
7273
+ offset,
6062
7274
  wantsObject,
6063
7275
  wantsCsv,
6064
7276
  head: method === "HEAD"
@@ -6489,8 +7701,10 @@ function applyResize(image, metadata, options) {
6489
7701
  const proportionalWidth = Math.max(1, Math.round(metadata.width * options.height / metadata.height));
6490
7702
  image.resize(proportionalWidth);
6491
7703
  }
6492
- async function transformImage(bytes, options) {
6493
- if (bytes.byteLength > MAX_TRANSFORM_BYTES) {
7704
+ async function transformImage(source, options, knownSourceSize) {
7705
+ const actualSourceSize = source instanceof Uint8Array ? source.byteLength : await Promise.resolve(source.size);
7706
+ const sourceSize = Math.max(knownSourceSize ?? 0, actualSourceSize);
7707
+ if (sourceSize > MAX_TRANSFORM_BYTES) {
6494
7708
  return {
6495
7709
  ok: false,
6496
7710
  status: 413,
@@ -6498,9 +7712,10 @@ async function transformImage(bytes, options) {
6498
7712
  message: "The source image exceeds the 25MB transformation limit"
6499
7713
  };
6500
7714
  }
7715
+ const image = new Bun.Image(source, { maxPixels: MAX_TRANSFORM_PIXELS });
6501
7716
  let metadata;
6502
7717
  try {
6503
- metadata = await new Bun.Image(bytes, { maxPixels: MAX_TRANSFORM_PIXELS }).metadata();
7718
+ metadata = await image.metadata();
6504
7719
  } catch (error) {
6505
7720
  return mapImageError(error);
6506
7721
  }
@@ -6517,7 +7732,6 @@ async function transformImage(bytes, options) {
6517
7732
  message: `The source format ${outputFormat} is not supported by this runtime`
6518
7733
  };
6519
7734
  }
6520
- const image = new Bun.Image(bytes, { maxPixels: MAX_TRANSFORM_PIXELS });
6521
7735
  applyResize(image, metadata, options);
6522
7736
  if (options.format === "jpeg" || options.format === "origin" && outputFormat === "jpeg" && options.quality !== undefined) {
6523
7737
  image.jpeg(options.quality === undefined ? undefined : { quality: options.quality });
@@ -6527,12 +7741,127 @@ async function transformImage(bytes, options) {
6527
7741
  image.webp(options.quality === undefined ? undefined : { quality: options.quality });
6528
7742
  }
6529
7743
  try {
6530
- return { ok: true, bytes: await image.bytes(), contentType };
7744
+ const bytes = await image.bytes();
7745
+ if (bytes.byteLength > MAX_TRANSFORM_BYTES) {
7746
+ return {
7747
+ ok: false,
7748
+ status: 413,
7749
+ error: "ImageTooLarge",
7750
+ message: "The transformed image exceeds the 25MB transformation limit"
7751
+ };
7752
+ }
7753
+ return { ok: true, bytes, contentType };
6531
7754
  } catch (error) {
6532
7755
  return mapImageError(error);
6533
7756
  }
6534
7757
  }
6535
7758
 
7759
+ // src/runtime/storage/image-transform-cache.ts
7760
+ var DEFAULT_MAX_CACHE_BYTES = 64 * 1024 * 1024;
7761
+ var DEFAULT_MAX_CACHE_ENTRIES = 128;
7762
+ var OBJECT_VERSION_PREFIX = "v2-";
7763
+
7764
+ class Semaphore {
7765
+ limit;
7766
+ active = 0;
7767
+ waiters = [];
7768
+ constructor(limit) {
7769
+ this.limit = limit;
7770
+ }
7771
+ async run(operation) {
7772
+ await this.acquire();
7773
+ try {
7774
+ return await operation();
7775
+ } finally {
7776
+ this.release();
7777
+ }
7778
+ }
7779
+ async acquire() {
7780
+ if (this.active < this.limit) {
7781
+ this.active += 1;
7782
+ return;
7783
+ }
7784
+ await new Promise((resolve2) => this.waiters.push(resolve2));
7785
+ }
7786
+ release() {
7787
+ const next = this.waiters.shift();
7788
+ if (next) {
7789
+ next();
7790
+ return;
7791
+ }
7792
+ this.active -= 1;
7793
+ }
7794
+ }
7795
+ var globalImageTransformSemaphore = new Semaphore(1);
7796
+ function imageTransformCacheKey(version, options) {
7797
+ if (!version?.startsWith(OBJECT_VERSION_PREFIX))
7798
+ return null;
7799
+ return [
7800
+ version,
7801
+ options.width ?? "",
7802
+ options.height ?? "",
7803
+ options.resize,
7804
+ options.quality ?? "",
7805
+ options.format
7806
+ ].join("\x00");
7807
+ }
7808
+
7809
+ class ImageTransformCache {
7810
+ maxBytes;
7811
+ maxEntries;
7812
+ entries = new Map;
7813
+ inFlight = new Map;
7814
+ cachedBytes = 0;
7815
+ constructor(maxBytes = DEFAULT_MAX_CACHE_BYTES, maxEntries = DEFAULT_MAX_CACHE_ENTRIES) {
7816
+ this.maxBytes = maxBytes;
7817
+ this.maxEntries = maxEntries;
7818
+ }
7819
+ async getOrTransform(key, operation) {
7820
+ if (key === null)
7821
+ return globalImageTransformSemaphore.run(operation);
7822
+ const cached = this.entries.get(key);
7823
+ if (cached) {
7824
+ this.entries.delete(key);
7825
+ this.entries.set(key, cached);
7826
+ return cached.transform;
7827
+ }
7828
+ const pending = this.inFlight.get(key);
7829
+ if (pending)
7830
+ return pending;
7831
+ const transform = globalImageTransformSemaphore.run(operation);
7832
+ this.inFlight.set(key, transform);
7833
+ try {
7834
+ const transformResult = await transform;
7835
+ if (transformResult.ok)
7836
+ this.store(key, transformResult);
7837
+ return transformResult;
7838
+ } finally {
7839
+ if (this.inFlight.get(key) === transform)
7840
+ this.inFlight.delete(key);
7841
+ }
7842
+ }
7843
+ store(key, transform) {
7844
+ const size = transform.bytes.byteLength;
7845
+ if (size > this.maxBytes || this.maxEntries === 0)
7846
+ return;
7847
+ const previous = this.entries.get(key);
7848
+ if (previous) {
7849
+ this.cachedBytes -= previous.size;
7850
+ this.entries.delete(key);
7851
+ }
7852
+ this.entries.set(key, { transform, size });
7853
+ this.cachedBytes += size;
7854
+ while (this.entries.size > this.maxEntries || this.cachedBytes > this.maxBytes) {
7855
+ const oldestKey = this.entries.keys().next().value;
7856
+ if (oldestKey === undefined)
7857
+ break;
7858
+ const oldest = this.entries.get(oldestKey);
7859
+ this.entries.delete(oldestKey);
7860
+ this.cachedBytes -= oldest.size;
7861
+ }
7862
+ }
7863
+ }
7864
+
6536
7865
  // src/runtime/storage/handler.ts
6537
7866
  var MAX_SIGNED_URL_EXPIRY = 7 * 24 * 60 * 60;
6538
7867
  function clampExpiry(expiresIn) {
@@ -6611,7 +7940,7 @@ var MAX_COMPLETED_TUS_UPLOADS = 64;
6611
7940
  var COMPLETED_TUS_RETENTION_MS = 60 * 60 * 1000;
6612
7941
  var PREFLIGHT_ROLLBACK = Symbol("storage-preflight-rollback");
6613
7942
  var INTERNAL_STORAGE_BUCKET = ".supacloud-lite";
6614
- var OBJECT_VERSION_PREFIX = "v2-";
7943
+ var OBJECT_VERSION_PREFIX2 = "v2-";
6615
7944
 
6616
7945
  class StorageHandler {
6617
7946
  db;
@@ -6619,6 +7948,7 @@ class StorageHandler {
6619
7948
  config;
6620
7949
  tusUploads = new Map;
6621
7950
  mutationTail = Promise.resolve();
7951
+ imageTransforms = new ImageTransformCache;
6622
7952
  constructor(db, driver, config) {
6623
7953
  this.db = db;
6624
7954
  this.driver = driver;
@@ -6661,15 +7991,21 @@ class StorageHandler {
6661
7991
  const bucket2 = parts[3];
6662
7992
  const key2 = parts.slice(4).join("/");
6663
7993
  if (kind === "public" && (method === "GET" || method === "HEAD")) {
6664
- const source = await this.downloadPublic(bucket2, key2, false);
7994
+ const source = await this.loadPublicObject(bucket2, key2);
7995
+ if (source instanceof Response)
7996
+ return source;
6665
7997
  return await this.transformImageResponse(source, url, method === "HEAD");
6666
7998
  }
6667
7999
  if (kind === "authenticated" && (method === "GET" || method === "HEAD")) {
6668
- const source = await this.download(ctx, bucket2, key2, false);
8000
+ const source = await this.loadAuthenticatedObject(ctx, bucket2, key2);
8001
+ if (source instanceof Response)
8002
+ return source;
6669
8003
  return await this.transformImageResponse(source, url, method === "HEAD");
6670
8004
  }
6671
8005
  if (kind === "sign" && method === "GET") {
6672
- const source = await this.redeemSignedUrl(url, bucket2, key2);
8006
+ const source = await this.loadSignedObject(url, bucket2, key2);
8007
+ if (source instanceof Response)
8008
+ return source;
6673
8009
  return await this.transformImageResponse(source, url, false);
6674
8010
  }
6675
8011
  return storageError(404, "not_found", `unknown render endpoint: ${rest}`);
@@ -6882,21 +8218,31 @@ class StorageHandler {
6882
8218
  throw e;
6883
8219
  }
6884
8220
  }
6885
- async transformImageResponse(source, url, head) {
6886
- if (!source.ok)
6887
- return source;
8221
+ async transformImageResponse(row, url, head) {
6888
8222
  const parsed = parseImageTransform(url.searchParams);
6889
8223
  if (!parsed.ok)
6890
8224
  return storageError(parsed.status, parsed.error, parsed.message);
6891
- const result = await transformImage(new Uint8Array(await source.arrayBuffer()), parsed.value);
8225
+ let result;
8226
+ try {
8227
+ result = await this.imageTransforms.getOrTransform(imageTransformCacheKey(row.version, parsed.value), async () => {
8228
+ const source = await this.readObjectSource(row);
8229
+ if (source === null)
8230
+ throw new StorageObjectMissingError;
8231
+ return transformImage(source, parsed.value, objectSize(row));
8232
+ });
8233
+ } catch (error) {
8234
+ if (error instanceof StorageObjectMissingError) {
8235
+ return storageError(404, "not_found", "Object not found");
8236
+ }
8237
+ throw error;
8238
+ }
6892
8239
  if (!result.ok)
6893
8240
  return storageError(result.status, result.error, result.message);
6894
- const headers = new Headers(source.headers);
8241
+ const headers = objectHeaders(row, result.bytes.length);
6895
8242
  headers.delete("content-disposition");
6896
8243
  headers.delete("etag");
6897
8244
  headers.set("content-type", result.contentType);
6898
- headers.set("content-length", String(result.bytes.length));
6899
- return new Response(head ? null : result.bytes, { status: source.status, headers });
8245
+ return new Response(head ? null : result.bytes, { status: 200, headers });
6900
8246
  }
6901
8247
  async persistObject(ctx, bucketId, key, bytes, contentType, cacheControl, upsert) {
6902
8248
  const metadata = objectMetadata(bytes.length, contentType, cacheControl);
@@ -7166,13 +8512,25 @@ class StorageHandler {
7166
8512
  return null;
7167
8513
  }
7168
8514
  async download(ctx, bucketId, key, head) {
8515
+ const row = await this.loadAuthenticatedObject(ctx, bucketId, key);
8516
+ if (row instanceof Response)
8517
+ return row;
8518
+ return this.serveObject(row, head);
8519
+ }
8520
+ async loadAuthenticatedObject(ctx, bucketId, key) {
7169
8521
  const res = await this.db.withContext(ctx, (q) => q(`select * from storage.objects where bucket_id = $1 and name = $2`, [bucketId, key]));
7170
8522
  const row = res.rows[0];
7171
8523
  if (!row)
7172
8524
  return storageError(404, "not_found", "Object not found");
7173
- return this.serveObject(row, head);
8525
+ return row;
7174
8526
  }
7175
8527
  async downloadPublic(bucketId, key, head) {
8528
+ const row = await this.loadPublicObject(bucketId, key);
8529
+ if (row instanceof Response)
8530
+ return row;
8531
+ return this.serveObject(row, head);
8532
+ }
8533
+ async loadPublicObject(bucketId, key) {
7176
8534
  const bucket = await this.loadBucket(bucketId);
7177
8535
  if (!bucket?.public)
7178
8536
  return storageError(400, "not_found", "Bucket is not public");
@@ -7183,24 +8541,16 @@ class StorageHandler {
7183
8541
  const row = res.rows[0];
7184
8542
  if (!row)
7185
8543
  return storageError(404, "not_found", "Object not found");
7186
- return this.serveObject(row, head);
8544
+ return row;
7187
8545
  }
7188
8546
  async serveObject(row, head) {
7189
8547
  const bytes = await this.readObjectBytes(row);
7190
8548
  if (bytes === null)
7191
8549
  return storageError(404, "not_found", "Object not found");
7192
- const meta = row.metadata ?? {};
7193
- const contentType = String(meta.mimetype ?? "application/octet-stream");
7194
- const headers = {
7195
- "content-type": contentType,
7196
- "content-length": String(bytes.length),
7197
- "cache-control": String(meta.cacheControl ?? "no-cache"),
7198
- etag: String(meta.eTag ?? '""'),
7199
- "last-modified": new Date(String(meta.lastModified ?? Date.now())).toUTCString(),
7200
- "x-content-type-options": "nosniff"
7201
- };
8550
+ const contentType = String(row.metadata?.mimetype ?? "application/octet-stream");
8551
+ const headers = objectHeaders(row, bytes.length);
7202
8552
  if (isRenderableActiveType(contentType))
7203
- headers["content-disposition"] = "attachment";
8553
+ headers.set("content-disposition", "attachment");
7204
8554
  return new Response(head ? null : bytes, { status: 200, headers });
7205
8555
  }
7206
8556
  async removeObjects(req, ctx, bucketId) {
@@ -7311,9 +8661,11 @@ class StorageHandler {
7311
8661
  throw error;
7312
8662
  }
7313
8663
  async readObjectBytes(row) {
7314
- if (isVersionedObjectVersion(row.version))
7315
- return this.driver.get(objectVersionKey(row.version));
7316
- return this.driver.get(legacyObjectKey(row));
8664
+ return this.driver.get(storageKey(row));
8665
+ }
8666
+ async readObjectSource(row) {
8667
+ const key = storageKey(row);
8668
+ return this.driver.getBlob ? this.driver.getBlob(key) : this.driver.get(key);
7317
8669
  }
7318
8670
  async cleanupObjectRows(rows) {
7319
8671
  const keys = rows.flatMap((row) => [
@@ -7416,6 +8768,12 @@ class StorageHandler {
7416
8768
  return json3(200, out);
7417
8769
  }
7418
8770
  async redeemSignedUrl(url, bucketId, key) {
8771
+ const row = await this.loadSignedObject(url, bucketId, key);
8772
+ if (row instanceof Response)
8773
+ return row;
8774
+ return this.serveObject(row, false);
8775
+ }
8776
+ async loadSignedObject(url, bucketId, key) {
7419
8777
  const token = url.searchParams.get("token") ?? "";
7420
8778
  const claims = await verifyJwt(token, this.config.jwtSecret);
7421
8779
  if (!claims || claims.url !== `${bucketId}/${key}` || claims.type !== "download") {
@@ -7428,7 +8786,7 @@ class StorageHandler {
7428
8786
  const row = res.rows[0];
7429
8787
  if (!row)
7430
8788
  return storageError(404, "not_found", "Object not found");
7431
- return this.serveObject(row, false);
8789
+ return row;
7432
8790
  }
7433
8791
  async signUploadUrl(ctx, bucketId, key) {
7434
8792
  const keyErr = invalidObjectKey(key);
@@ -7486,6 +8844,9 @@ function objectJson(r) {
7486
8844
 
7487
8845
  class StorageValidationError extends Error {
7488
8846
  }
8847
+
8848
+ class StorageObjectMissingError extends Error {
8849
+ }
7489
8850
  function parseSizeLimit(v) {
7490
8851
  if (v === null || v === undefined || v === "")
7491
8852
  return null;
@@ -7508,14 +8869,32 @@ function objectMetadata(size, contentType, cacheControl) {
7508
8869
  httpStatusCode: 200
7509
8870
  };
7510
8871
  }
8872
+ function objectHeaders(row, contentLength) {
8873
+ const metadata = row.metadata ?? {};
8874
+ return new Headers({
8875
+ "content-type": String(metadata.mimetype ?? "application/octet-stream"),
8876
+ "content-length": String(contentLength),
8877
+ "cache-control": String(metadata.cacheControl ?? "no-cache"),
8878
+ etag: String(metadata.eTag ?? '""'),
8879
+ "last-modified": new Date(String(metadata.lastModified ?? Date.now())).toUTCString(),
8880
+ "x-content-type-options": "nosniff"
8881
+ });
8882
+ }
8883
+ function objectSize(row) {
8884
+ const size = Number(row.metadata?.size);
8885
+ return Number.isFinite(size) && size >= 0 ? size : undefined;
8886
+ }
7511
8887
  function objectVersionKey(version) {
7512
8888
  return `.supacloud-lite/objects/${version}`;
7513
8889
  }
8890
+ function storageKey(row) {
8891
+ return isVersionedObjectVersion(row.version) ? objectVersionKey(row.version) : legacyObjectKey(row);
8892
+ }
7514
8893
  function createObjectVersion() {
7515
- return `${OBJECT_VERSION_PREFIX}${crypto.randomUUID()}`;
8894
+ return `${OBJECT_VERSION_PREFIX2}${crypto.randomUUID()}`;
7516
8895
  }
7517
8896
  function isVersionedObjectVersion(version) {
7518
- return version?.startsWith(OBJECT_VERSION_PREFIX) ?? false;
8897
+ return version?.startsWith(OBJECT_VERSION_PREFIX2) ?? false;
7519
8898
  }
7520
8899
  function isInternalStorageBucket(bucketId) {
7521
8900
  return bucketId === INTERNAL_STORAGE_BUCKET || bucketId.startsWith(`${INTERNAL_STORAGE_BUCKET}/`);
@@ -8767,6 +10146,21 @@ class S3StorageDriver {
8767
10146
  throw error;
8768
10147
  }
8769
10148
  }
10149
+ async getBlob(key) {
10150
+ const file = this.client.file(this.objectKey(key));
10151
+ if (!await file.exists())
10152
+ return null;
10153
+ if (typeof file.arrayBuffer === "function")
10154
+ return file;
10155
+ try {
10156
+ const bytes = Uint8Array.from(await file.bytes());
10157
+ return new Blob([bytes.buffer]);
10158
+ } catch (error) {
10159
+ if (isNotFoundError(error))
10160
+ return null;
10161
+ throw error;
10162
+ }
10163
+ }
8770
10164
  async delete(key) {
8771
10165
  await this.client.file(this.objectKey(key)).delete();
8772
10166
  }
@@ -8816,6 +10210,10 @@ class FsStorageDriver {
8816
10210
  throw e;
8817
10211
  }
8818
10212
  }
10213
+ async getBlob(key) {
10214
+ const file = Bun.file(this.resolve(key));
10215
+ return await file.exists() ? file : null;
10216
+ }
8819
10217
  async delete(key) {
8820
10218
  await rm(this.resolve(key), { force: true });
8821
10219
  }
@@ -8870,20 +10268,787 @@ async function serveBun(backend, opts = {}) {
8870
10268
  }
8871
10269
  };
8872
10270
  }
10271
+ // src/runtime/node/native/engine.ts
10272
+ import { execFileSync, spawn } from "child_process";
10273
+ import { createHash as createHash2, randomBytes as randomBytes2 } from "crypto";
10274
+ import { appendFileSync, chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync } from "fs";
10275
+ import { writeFile as writeFile2 } from "fs/promises";
10276
+ import { homedir, tmpdir } from "os";
10277
+ import { join as join2 } from "path";
10278
+ import { extract as extractTar } from "tar";
10279
+
10280
+ // src/runtime/node/native/wire.ts
10281
+ import { createConnection } from "net";
10282
+ import { createHash, createHmac, pbkdf2Sync, randomBytes } from "crypto";
10283
+
10284
+ class PgWireError extends Error {
10285
+ code;
10286
+ detail;
10287
+ hint;
10288
+ severity;
10289
+ constructor(fields) {
10290
+ super(fields.get("M") ?? "postgres error");
10291
+ this.code = fields.get("C");
10292
+ this.detail = fields.get("D");
10293
+ this.hint = fields.get("H");
10294
+ this.severity = fields.get("S");
10295
+ }
10296
+ }
10297
+
10298
+ class PgWireClient {
10299
+ socket;
10300
+ buffer = Buffer.alloc(0);
10301
+ pending = null;
10302
+ queue = Promise.resolve();
10303
+ closed = false;
10304
+ onNotification = null;
10305
+ static async connect(opts) {
10306
+ const client = new PgWireClient;
10307
+ await client.open(opts);
10308
+ return client;
10309
+ }
10310
+ open(opts) {
10311
+ return new Promise((resolve2, reject) => {
10312
+ this.socket = opts.socketPath ? createConnection(opts.socketPath) : createConnection(opts.port ?? 5432, opts.host ?? "127.0.0.1");
10313
+ this.socket.on("error", (e) => {
10314
+ if (this.pending)
10315
+ this.pending.reject(e);
10316
+ reject(e);
10317
+ });
10318
+ this.socket.on("close", () => {
10319
+ this.closed = true;
10320
+ this.pending?.reject(new Error("connection closed"));
10321
+ });
10322
+ this.socket.on("connect", () => {
10323
+ const params = `user\x00${opts.user}\x00database\x00${opts.database}\x00client_encoding\x00UTF8\x00\x00`;
10324
+ const body = Buffer.from(params, "utf8");
10325
+ const msg = Buffer.alloc(8 + body.length);
10326
+ msg.writeInt32BE(8 + body.length, 0);
10327
+ msg.writeInt32BE(196608, 4);
10328
+ body.copy(msg, 8);
10329
+ this.socket.write(msg);
10330
+ });
10331
+ let clientNonce = "";
10332
+ let clientFirstBare = "";
10333
+ let serverSignature = "";
10334
+ const needPassword = () => {
10335
+ if (opts.password == null) {
10336
+ reject(new Error("the server requested a password but none was provided"));
10337
+ return false;
10338
+ }
10339
+ return true;
10340
+ };
10341
+ const startupHandler = (chunk) => {
10342
+ this.buffer = Buffer.concat([this.buffer, chunk]);
10343
+ let msg;
10344
+ while ((msg = this.nextMessage()) !== null) {
10345
+ const [type, payload] = msg;
10346
+ if (type === 82) {
10347
+ const code = payload.readInt32BE(0);
10348
+ if (code === 0) {} else if (code === 3) {
10349
+ if (!needPassword())
10350
+ return;
10351
+ this.socket.write(message(112, cstring(opts.password)));
10352
+ } else if (code === 5) {
10353
+ if (!needPassword())
10354
+ return;
10355
+ const salt = payload.subarray(4, 8);
10356
+ const inner = md5Hex(Buffer.from(opts.password + opts.user, "utf8"));
10357
+ const token = "md5" + md5Hex(Buffer.concat([Buffer.from(inner, "utf8"), salt]));
10358
+ this.socket.write(message(112, cstring(token)));
10359
+ } else if (code === 10) {
10360
+ if (!needPassword())
10361
+ return;
10362
+ const mechs = payload.subarray(4).toString("utf8").split("\x00").filter(Boolean);
10363
+ if (!mechs.includes("SCRAM-SHA-256")) {
10364
+ reject(new Error(`no supported SASL mechanism (server offered: ${mechs.join(", ")})`));
10365
+ return;
10366
+ }
10367
+ clientNonce = randomBytes(18).toString("base64");
10368
+ clientFirstBare = `n=,r=${clientNonce}`;
10369
+ const initial = Buffer.from(`n,,${clientFirstBare}`, "utf8");
10370
+ this.socket.write(message(112, Buffer.concat([cstring("SCRAM-SHA-256"), int32(initial.length), initial])));
10371
+ } else if (code === 11) {
10372
+ const serverFirst = payload.subarray(4).toString("utf8");
10373
+ const attrs = scramAttrs(serverFirst);
10374
+ if (!attrs.r?.startsWith(clientNonce)) {
10375
+ reject(new Error("SCRAM: server nonce does not extend client nonce"));
10376
+ return;
10377
+ }
10378
+ const salt = Buffer.from(attrs.s, "base64");
10379
+ const iterations = parseInt(attrs.i, 10);
10380
+ const saltedPassword = pbkdf2Sync(opts.password, salt, iterations, 32, "sha256");
10381
+ const clientKey = hmac(saltedPassword, "Client Key");
10382
+ const storedKey = sha256(clientKey);
10383
+ const finalNoProof = `c=biws,r=${attrs.r}`;
10384
+ const authMessage = `${clientFirstBare},${serverFirst},${finalNoProof}`;
10385
+ const clientSignature = hmac(storedKey, authMessage);
10386
+ const proof = xorBuffers(clientKey, clientSignature);
10387
+ serverSignature = hmac(hmac(saltedPassword, "Server Key"), authMessage).toString("base64");
10388
+ const clientFinal = `${finalNoProof},p=${proof.toString("base64")}`;
10389
+ this.socket.write(message(112, Buffer.from(clientFinal, "utf8")));
10390
+ } else if (code === 12) {
10391
+ const v = scramAttrs(payload.subarray(4).toString("utf8")).v;
10392
+ if (v && serverSignature && v !== serverSignature) {
10393
+ reject(new Error("SCRAM: server signature verification failed"));
10394
+ return;
10395
+ }
10396
+ } else {
10397
+ reject(new Error(`unsupported auth method ${code}`));
10398
+ return;
10399
+ }
10400
+ } else if (type === 69) {
10401
+ reject(new PgWireError(parseErrorFields(payload)));
10402
+ return;
10403
+ } else if (type === 90) {
10404
+ this.socket.off("data", startupHandler);
10405
+ this.socket.on("data", (c) => {
10406
+ this.buffer = Buffer.concat([this.buffer, c]);
10407
+ this.processMessages();
10408
+ });
10409
+ resolve2();
10410
+ return;
10411
+ }
10412
+ }
10413
+ };
10414
+ this.socket.on("data", startupHandler);
10415
+ });
10416
+ }
10417
+ run(send) {
10418
+ const op = this.queue.then(() => new Promise((resolve2, reject) => {
10419
+ if (this.closed)
10420
+ return reject(new Error("connection closed"));
10421
+ this.pending = { resolve: resolve2, reject, results: [], columns: [], error: null };
10422
+ send();
10423
+ }));
10424
+ this.queue = op.catch(() => {});
10425
+ return op;
10426
+ }
10427
+ async exec(sql) {
10428
+ return this.run(() => this.socket.write(message(81, cstring(sql))));
10429
+ }
10430
+ async query(sql, params = []) {
10431
+ const results = await this.run(() => {
10432
+ const parse = message(80, Buffer.concat([cstring(""), cstring(sql), int16(0)]));
10433
+ const paramBufs = [int16(0), int16(params.length)];
10434
+ for (const p of params) {
10435
+ if (p === null || p === undefined) {
10436
+ paramBufs.push(int32(-1));
10437
+ } else {
10438
+ const b = Buffer.from(String(p), "utf8");
10439
+ paramBufs.push(int32(b.length), b);
10440
+ }
10441
+ }
10442
+ paramBufs.push(int16(0));
10443
+ const bind = message(66, Buffer.concat([cstring(""), cstring(""), ...paramBufs]));
10444
+ const describe = message(68, Buffer.concat([Buffer.from("P"), cstring("")]));
10445
+ const execute = message(69, Buffer.concat([cstring(""), int32(0)]));
10446
+ const sync = message(83, Buffer.alloc(0));
10447
+ this.socket.write(Buffer.concat([parse, bind, describe, execute, sync]));
10448
+ });
10449
+ return results[0] ?? { rows: [] };
10450
+ }
10451
+ close() {
10452
+ return new Promise((resolve2) => {
10453
+ if (this.closed)
10454
+ return resolve2();
10455
+ this.socket.write(message(88, Buffer.alloc(0)));
10456
+ this.socket.end(() => resolve2());
10457
+ });
10458
+ }
10459
+ nextMessage() {
10460
+ if (this.buffer.length < 5)
10461
+ return null;
10462
+ const type = this.buffer[0];
10463
+ const length = this.buffer.readInt32BE(1);
10464
+ if (this.buffer.length < 1 + length)
10465
+ return null;
10466
+ const payload = this.buffer.subarray(5, 1 + length);
10467
+ this.buffer = this.buffer.subarray(1 + length);
10468
+ return [type, Buffer.from(payload)];
10469
+ }
10470
+ processMessages() {
10471
+ let msg;
10472
+ while ((msg = this.nextMessage()) !== null) {
10473
+ const [type, payload] = msg;
10474
+ const p = this.pending;
10475
+ switch (type) {
10476
+ case 84: {
10477
+ if (!p)
10478
+ break;
10479
+ const count = payload.readInt16BE(0);
10480
+ let off = 2;
10481
+ const columns = [];
10482
+ for (let i = 0;i < count; i++) {
10483
+ const end = payload.indexOf(0, off);
10484
+ const name = payload.toString("utf8", off, end);
10485
+ off = end + 1;
10486
+ const typeOid = payload.readInt32BE(off + 6);
10487
+ off += 18;
10488
+ columns.push({ name, typeOid });
10489
+ }
10490
+ p.columns = columns;
10491
+ break;
10492
+ }
10493
+ case 68: {
10494
+ if (!p)
10495
+ break;
10496
+ const count = payload.readInt16BE(0);
10497
+ let off = 2;
10498
+ const row = {};
10499
+ for (let i = 0;i < count; i++) {
10500
+ const len = payload.readInt32BE(off);
10501
+ off += 4;
10502
+ let value = null;
10503
+ if (len >= 0) {
10504
+ value = decodeValue(payload.toString("utf8", off, off + len), p.columns[i]?.typeOid ?? 25);
10505
+ off += len;
10506
+ }
10507
+ row[p.columns[i]?.name ?? `col${i}`] = value;
10508
+ }
10509
+ if (p.results.length === 0)
10510
+ p.results.push({ rows: [] });
10511
+ p.results[p.results.length - 1].rows.push(row);
10512
+ break;
10513
+ }
10514
+ case 67: {
10515
+ if (!p)
10516
+ break;
10517
+ const tag = payload.toString("utf8", 0, payload.length - 1);
10518
+ const parts = tag.split(" ");
10519
+ const affected = parseInt(parts[parts.length - 1], 10);
10520
+ if (p.results.length === 0)
10521
+ p.results.push({ rows: [] });
10522
+ const current = p.results[p.results.length - 1];
10523
+ if (!Number.isNaN(affected))
10524
+ current.affectedRows = affected;
10525
+ p.results.push({ rows: [] });
10526
+ p.columns = [];
10527
+ break;
10528
+ }
10529
+ case 69: {
10530
+ if (p)
10531
+ p.error = new PgWireError(parseErrorFields(payload));
10532
+ break;
10533
+ }
10534
+ case 65: {
10535
+ payload.readInt32BE(0);
10536
+ const channelEnd = payload.indexOf(0, 4);
10537
+ const channel = payload.toString("utf8", 4, channelEnd);
10538
+ const payloadEnd = payload.indexOf(0, channelEnd + 1);
10539
+ const body = payload.toString("utf8", channelEnd + 1, payloadEnd);
10540
+ this.onNotification?.(channel, body);
10541
+ break;
10542
+ }
10543
+ case 90: {
10544
+ if (!p)
10545
+ break;
10546
+ this.pending = null;
10547
+ if (p.error)
10548
+ p.reject(p.error);
10549
+ else {
10550
+ const results = p.results.filter((r, i) => i < p.results.length - 1 || r.rows.length > 0 || r.affectedRows !== undefined);
10551
+ p.resolve(results.length > 0 ? results : [{ rows: [] }]);
10552
+ }
10553
+ break;
10554
+ }
10555
+ }
10556
+ }
10557
+ }
10558
+ }
10559
+ var hmac = (key, data) => createHmac("sha256", key).update(data, "utf8").digest();
10560
+ var sha256 = (b) => createHash("sha256").update(b).digest();
10561
+ var md5Hex = (b) => createHash("md5").update(b).digest("hex");
10562
+ function xorBuffers(a, b) {
10563
+ const out = Buffer.alloc(a.length);
10564
+ for (let i = 0;i < a.length; i++)
10565
+ out[i] = a[i] ^ b[i];
10566
+ return out;
10567
+ }
10568
+ function scramAttrs(s) {
10569
+ const out = {};
10570
+ for (const part of s.split(",")) {
10571
+ const eq = part.indexOf("=");
10572
+ if (eq > 0)
10573
+ out[part.slice(0, eq)] = part.slice(eq + 1);
10574
+ }
10575
+ return out;
10576
+ }
10577
+ function message(type, body) {
10578
+ const out = Buffer.alloc(5 + body.length);
10579
+ out[0] = type;
10580
+ out.writeInt32BE(4 + body.length, 1);
10581
+ body.copy(out, 5);
10582
+ return out;
10583
+ }
10584
+ var cstring = (s) => Buffer.from(s + "\x00", "utf8");
10585
+ var int16 = (n) => {
10586
+ const b = Buffer.alloc(2);
10587
+ b.writeInt16BE(n);
10588
+ return b;
10589
+ };
10590
+ var int32 = (n) => {
10591
+ const b = Buffer.alloc(4);
10592
+ b.writeInt32BE(n);
10593
+ return b;
10594
+ };
10595
+ function parseErrorFields(payload) {
10596
+ const fields = new Map;
10597
+ let off = 0;
10598
+ while (off < payload.length && payload[off] !== 0) {
10599
+ const key = String.fromCharCode(payload[off]);
10600
+ const end = payload.indexOf(0, off + 1);
10601
+ fields.set(key, payload.toString("utf8", off + 1, end));
10602
+ off = end + 1;
10603
+ }
10604
+ return fields;
10605
+ }
10606
+ function decodeValue(text, oid) {
10607
+ switch (oid) {
10608
+ case 16:
10609
+ return text === "t";
10610
+ case 20: {
10611
+ const n = Number(text);
10612
+ return Number.isSafeInteger(n) ? n : text;
10613
+ }
10614
+ case 21:
10615
+ case 23:
10616
+ case 26:
10617
+ return Number(text);
10618
+ case 700:
10619
+ case 701:
10620
+ return Number(text);
10621
+ case 114:
10622
+ case 3802:
10623
+ return JSON.parse(text);
10624
+ case 1114:
10625
+ return new Date(text.replace(" ", "T") + "Z");
10626
+ case 1184: {
10627
+ let iso3 = text.replace(" ", "T");
10628
+ if (/[+-]\d\d$/.test(iso3))
10629
+ iso3 += ":00";
10630
+ return new Date(iso3);
10631
+ }
10632
+ case 1000:
10633
+ return parsePgArray(text).map((v) => v === "t");
10634
+ case 1007:
10635
+ return parsePgArray(text).map((v) => v === null ? null : Number(v));
10636
+ case 1016:
10637
+ return parsePgArray(text).map((v) => {
10638
+ if (v === null)
10639
+ return null;
10640
+ const n = Number(v);
10641
+ return Number.isSafeInteger(n) ? n : v;
10642
+ });
10643
+ case 1003:
10644
+ case 1009:
10645
+ case 1015:
10646
+ return parsePgArray(text);
10647
+ default:
10648
+ return text;
10649
+ }
10650
+ }
10651
+ function parsePgArray(text) {
10652
+ const out = [];
10653
+ if (text.length < 2)
10654
+ return out;
10655
+ let i = 1;
10656
+ while (i < text.length - 1) {
10657
+ if (text[i] === ",") {
10658
+ i++;
10659
+ continue;
10660
+ }
10661
+ if (text[i] === '"') {
10662
+ let value = "";
10663
+ i++;
10664
+ while (text[i] !== '"') {
10665
+ if (text[i] === "\\")
10666
+ i++;
10667
+ value += text[i++];
10668
+ }
10669
+ i++;
10670
+ out.push(value);
10671
+ } else {
10672
+ let value = "";
10673
+ while (i < text.length - 1 && text[i] !== ",")
10674
+ value += text[i++];
10675
+ out.push(value === "NULL" ? null : value);
10676
+ }
10677
+ }
10678
+ return out;
10679
+ }
10680
+
10681
+ // src/runtime/db/engine.ts
10682
+ class Mutex {
10683
+ tail = Promise.resolve();
10684
+ async lock() {
10685
+ let release;
10686
+ const next = new Promise((r) => release = r);
10687
+ const prev = this.tail;
10688
+ this.tail = this.tail.then(() => next);
10689
+ await prev;
10690
+ return release;
10691
+ }
10692
+ async run(fn) {
10693
+ const release = await this.lock();
10694
+ try {
10695
+ return await fn();
10696
+ } finally {
10697
+ release();
10698
+ }
10699
+ }
10700
+ }
10701
+
10702
+ // src/runtime/node/native/wire-engine.ts
10703
+ async function buildWireEngine(options) {
10704
+ const queryClient = await options.connect();
10705
+ let listenerClient;
10706
+ try {
10707
+ listenerClient = await options.connect();
10708
+ } catch (error) {
10709
+ await queryClient.close().catch(() => {});
10710
+ throw error;
10711
+ }
10712
+ const queryMutex = new Mutex;
10713
+ const listenerMutex = new Mutex;
10714
+ const listeners = new Map;
10715
+ listenerClient.onNotification = (channel, payload) => {
10716
+ for (const listener of listeners.get(channel) ?? [])
10717
+ listener(payload);
10718
+ };
10719
+ const transactionClient = {
10720
+ async query(sql, params) {
10721
+ const queryResult = await queryClient.query(sql, normalizeParams(params));
10722
+ return { rows: queryResult.rows, affectedRows: queryResult.affectedRows };
10723
+ },
10724
+ async exec(sql) {
10725
+ await queryClient.exec(sql);
10726
+ }
10727
+ };
10728
+ let closePromise = null;
10729
+ return {
10730
+ query(sql, params) {
10731
+ return queryMutex.run(() => transactionClient.query(sql, params));
10732
+ },
10733
+ exec(sql) {
10734
+ return queryMutex.run(() => transactionClient.exec(sql));
10735
+ },
10736
+ transaction(callback) {
10737
+ return queryMutex.run(async () => {
10738
+ await queryClient.exec("begin");
10739
+ try {
10740
+ const response = await callback(transactionClient);
10741
+ await queryClient.exec("commit");
10742
+ return response;
10743
+ } catch (error) {
10744
+ await queryClient.exec("rollback").catch(() => {});
10745
+ throw error;
10746
+ }
10747
+ });
10748
+ },
10749
+ async listen(channel, listener) {
10750
+ return listenerMutex.run(async () => {
10751
+ let channelListeners = listeners.get(channel);
10752
+ if (!channelListeners) {
10753
+ channelListeners = new Set;
10754
+ await listenerClient.exec(`listen "${channel.replaceAll('"', '""')}"`);
10755
+ listeners.set(channel, channelListeners);
10756
+ }
10757
+ channelListeners.add(listener);
10758
+ return () => {
10759
+ channelListeners.delete(listener);
10760
+ };
10761
+ });
10762
+ },
10763
+ close() {
10764
+ closePromise ??= closeWireEngine(queryClient, listenerClient, options.onClose);
10765
+ return closePromise;
10766
+ }
10767
+ };
10768
+ }
10769
+ async function closeWireEngine(queryClient, listenerClient, onClose) {
10770
+ const closeResults = await Promise.allSettled([queryClient.close(), listenerClient.close()]);
10771
+ let engineCleanupError;
10772
+ try {
10773
+ await onClose?.();
10774
+ } catch (error) {
10775
+ engineCleanupError = error;
10776
+ }
10777
+ const connectionErrors = closeResults.flatMap((closeResult) => closeResult.status === "rejected" ? [closeResult.reason] : []);
10778
+ if (engineCleanupError !== undefined)
10779
+ connectionErrors.push(engineCleanupError);
10780
+ if (connectionErrors.length > 0)
10781
+ throw new AggregateError(connectionErrors, "native database cleanup failed");
10782
+ }
10783
+ function normalizeParams(params) {
10784
+ return params?.map((parameter) => {
10785
+ if (parameter === null || parameter === undefined)
10786
+ return null;
10787
+ if (Array.isArray(parameter))
10788
+ return toPgArrayLiteral(parameter);
10789
+ if (parameter instanceof Date)
10790
+ return parameter.toISOString();
10791
+ if (typeof parameter === "object")
10792
+ return JSON.stringify(parameter);
10793
+ return parameter;
10794
+ });
10795
+ }
10796
+ function toPgArrayLiteral(array) {
10797
+ const encoded = array.map((element) => {
10798
+ if (element === null || element === undefined)
10799
+ return "NULL";
10800
+ if (Array.isArray(element))
10801
+ return toPgArrayLiteral(element);
10802
+ if (typeof element === "number" || typeof element === "boolean")
10803
+ return String(element);
10804
+ const text = typeof element === "object" ? JSON.stringify(element) : String(element);
10805
+ return `"${text.replaceAll("\\", "\\\\").replaceAll('"', "\\\"")}"`;
10806
+ });
10807
+ return `{${encoded.join(",")}}`;
10808
+ }
10809
+
10810
+ // src/runtime/node/native/engine.ts
10811
+ var DEFAULT_PG_VERSION = "17.7.0";
10812
+ var NATIVE_POSTGRES_MAJOR = DEFAULT_PG_VERSION.split(".")[0];
10813
+ function isNativeEngineSupported() {
10814
+ return (process.platform === "darwin" || process.platform === "linux") && (process.arch === "arm64" || process.arch === "x64") && (process.platform !== "linux" || isGlibcLinux());
10815
+ }
10816
+ function isGlibcLinux() {
10817
+ try {
10818
+ const version = execFileSync("ldd", ["--version"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
10819
+ return /glibc|gnu libc/i.test(version);
10820
+ } catch {
10821
+ return false;
10822
+ }
10823
+ }
10824
+ function target() {
10825
+ const arch = process.arch === "arm64" ? "aarch64" : process.arch === "x64" ? "x86_64" : null;
10826
+ if (!arch)
10827
+ throw new Error(`unsupported architecture for native engine: ${process.arch}`);
10828
+ if (process.platform === "darwin")
10829
+ return `${arch}-apple-darwin`;
10830
+ if (process.platform === "linux")
10831
+ return `${arch}-unknown-linux-gnu`;
10832
+ throw new Error(`unsupported platform for native engine: ${process.platform} (use the default PGlite engine)`);
10833
+ }
10834
+ function isCompleteInstall(dir) {
10835
+ return existsSync(join2(dir, "bin", "postgres")) && existsSync(join2(dir, "share", "postgres.bki"));
10836
+ }
10837
+ var PINNED_SHA256 = {
10838
+ "postgresql-17.7.0-x86_64-unknown-linux-gnu": "66ad03281a43624f955c8e16ac975cb0ab751e7edf8ba35308e3b08dd7d065c3",
10839
+ "postgresql-17.7.0-aarch64-unknown-linux-gnu": "89cc2f089880cc8e5e6b7a29387829ec4e4779427855bc0b9fa187c8fce33c8b",
10840
+ "postgresql-17.7.0-x86_64-apple-darwin": "0dd8c25173524bad4ae8ef6b970da1ac40f4c1f231150c416ccb8cd06feff8f2",
10841
+ "postgresql-17.7.0-aarch64-apple-darwin": "727ac08d20a704014a0d51eb3300aa0c8e292c1cf0a1c99d4f4b1002e1420220"
10842
+ };
10843
+ async function verifyTarball(tarball, key, url) {
10844
+ const actual = createHash2("sha256").update(readFileSync(tarball)).digest("hex");
10845
+ const pinned = PINNED_SHA256[key];
10846
+ if (pinned) {
10847
+ if (actual !== pinned) {
10848
+ throw new Error(`postgres binary checksum mismatch for ${key}: expected ${pinned}, got ${actual}`);
10849
+ }
10850
+ return;
10851
+ }
10852
+ const res = await fetchRelease(`${url}.sha256`);
10853
+ if (!res.ok)
10854
+ throw new Error(`could not fetch checksum for ${key}: HTTP ${res.status}`);
10855
+ const expected = (await res.text()).trim().split(/\s+/)[0].toLowerCase();
10856
+ if (!/^[0-9a-f]{64}$/.test(expected))
10857
+ throw new Error(`malformed published checksum for ${key}`);
10858
+ if (actual !== expected) {
10859
+ throw new Error(`postgres binary checksum mismatch for ${key}: expected ${expected}, got ${actual}`);
10860
+ }
10861
+ }
10862
+ async function ensurePostgres(version = DEFAULT_PG_VERSION, cacheDir, log) {
10863
+ const t = target();
10864
+ const root = cacheDir ?? join2(homedir(), ".cache", "supacloud-lite");
10865
+ const dir = join2(root, `postgresql-${version}-${t}`);
10866
+ if (isCompleteInstall(dir))
10867
+ return dir;
10868
+ const url = `https://github.com/theseus-rs/postgresql-binaries/releases/download/${version}/postgresql-${version}-${t}.tar.gz`;
10869
+ mkdirSync(root, { recursive: true });
10870
+ const uniq = `${process.pid}-${randomBytes2(6).toString("hex")}`;
10871
+ const tarball = join2(root, `pg-${version}-${uniq}.tar.gz`);
10872
+ const tmpDir = join2(root, `.tmp-${version}-${t}-${uniq}`);
10873
+ try {
10874
+ if (isCompleteInstall(dir))
10875
+ return dir;
10876
+ log?.(`downloading postgres ${version} (${t})\u2026`);
10877
+ const res = await fetchRelease(url);
10878
+ if (!res.ok)
10879
+ throw new Error(`failed to download ${url}: HTTP ${res.status}`);
10880
+ await writeFile2(tarball, Buffer.from(await res.arrayBuffer()));
10881
+ await verifyTarball(tarball, `postgresql-${version}-${t}`, url);
10882
+ mkdirSync(tmpDir, { recursive: true });
10883
+ await extractTar({ cwd: tmpDir, file: tarball, gzip: true, preserveOwner: false, strict: true, strip: 1 });
10884
+ if (!isCompleteInstall(tmpDir))
10885
+ throw new Error("postgres archive extracted incompletely");
10886
+ try {
10887
+ renameSync(tmpDir, dir);
10888
+ } catch {
10889
+ if (!isCompleteInstall(dir)) {
10890
+ rmSync(dir, { recursive: true, force: true });
10891
+ renameSync(tmpDir, dir);
10892
+ }
10893
+ }
10894
+ log?.(`postgres installed to ${dir}`);
10895
+ return dir;
10896
+ } finally {
10897
+ rmSync(tarball, { force: true });
10898
+ rmSync(tmpDir, { recursive: true, force: true });
10899
+ }
10900
+ }
10901
+ async function fetchRelease(url) {
10902
+ let lastError;
10903
+ for (let attempt = 1;attempt <= 3; attempt++) {
10904
+ try {
10905
+ const response = await fetch(url);
10906
+ if (response.ok || response.status < 500)
10907
+ return response;
10908
+ lastError = new Error(`failed to download ${url}: HTTP ${response.status}`);
10909
+ } catch (error) {
10910
+ lastError = error;
10911
+ }
10912
+ if (attempt < 3)
10913
+ await new Promise((resolve2) => setTimeout(resolve2, attempt * 500));
10914
+ }
10915
+ throw lastError instanceof Error ? lastError : new Error(`failed to download ${url}`);
10916
+ }
10917
+ var TUNED_CONF = `
10918
+ # supacloud-lite: memory-lean settings for an embedded, single-app Postgres
10919
+ listen_addresses = ''
10920
+ shared_buffers = 16MB
10921
+ dynamic_shared_memory_type = posix
10922
+ max_connections = 10
10923
+ wal_level = minimal
10924
+ max_wal_senders = 0
10925
+ logging_collector = off
10926
+ `;
10927
+ async function createNativeEngine(opts) {
10928
+ const releaseLock = await acquireDataDirLock(opts.dataDir, "native PostgreSQL");
10929
+ let socketDirectory;
10930
+ let postgres;
10931
+ let removeExitHandler;
10932
+ try {
10933
+ const installDir = await ensurePostgres(opts.version, opts.cacheDir, opts.log);
10934
+ const bin = (name) => join2(installDir, "bin", name);
10935
+ if (!existsSync(join2(opts.dataDir, "PG_VERSION"))) {
10936
+ mkdirSync(opts.dataDir, { recursive: true });
10937
+ try {
10938
+ execFileSync(bin("initdb"), ["-U", "postgres", "-A", "trust", "-E", "UTF8", "-D", opts.dataDir], {
10939
+ stdio: "pipe"
10940
+ });
10941
+ } catch (error) {
10942
+ const stderr = error.stderr?.toString() ?? "";
10943
+ throw new Error(`initdb failed:
10944
+ ${stderr || error.message}`);
10945
+ }
10946
+ appendFileSync(join2(opts.dataDir, "postgresql.conf"), TUNED_CONF);
10947
+ }
10948
+ removeStalePidFile(join2(opts.dataDir, "postmaster.pid"));
10949
+ socketDirectory = mkdtempSync(join2(tmpdir(), "scl-"));
10950
+ chmodSync(socketDirectory, 448);
10951
+ postgres = spawn(bin("postgres"), ["-D", opts.dataDir, "-k", socketDirectory, "-c", "timezone=UTC"], {
10952
+ stdio: ["ignore", "ignore", "pipe"],
10953
+ detached: false
10954
+ });
10955
+ let postgresExited = false;
10956
+ let postgresStderr = "";
10957
+ postgres.stderr?.on("data", (chunk) => {
10958
+ postgresStderr = (postgresStderr + chunk.toString()).slice(-4000);
10959
+ });
10960
+ postgres.on("exit", () => postgresExited = true);
10961
+ const killPostgres = () => {
10962
+ if (!postgresExited)
10963
+ postgres?.kill("SIGTERM");
10964
+ };
10965
+ process.once("exit", killPostgres);
10966
+ removeExitHandler = () => process.off("exit", killPostgres);
10967
+ const socketPath = join2(socketDirectory, ".s.PGSQL.5432");
10968
+ const connect = async () => {
10969
+ const deadline = Date.now() + 20000;
10970
+ while (Date.now() <= deadline) {
10971
+ try {
10972
+ return await PgWireClient.connect({ socketPath, user: "postgres", database: "postgres" });
10973
+ } catch (error) {
10974
+ if (postgresExited) {
10975
+ const detail = postgresStderr.trim();
10976
+ throw new Error(`embedded postgres failed to start${detail ? `:
10977
+ ${detail}` : " (no output)"}
10978
+
10979
+ ` + `data dir: ${opts.dataDir}
10980
+ ` + "If a previous run is still holding it, stop it; or delete the data dir to start fresh.");
10981
+ }
10982
+ await new Promise((resolve2) => setTimeout(resolve2, 150));
10983
+ }
10984
+ }
10985
+ throw new Error(`timed out waiting for embedded postgres at ${socketPath}`);
10986
+ };
10987
+ return await buildWireEngine({
10988
+ connect,
10989
+ onClose: async () => {
10990
+ removeExitHandler?.();
10991
+ await stopPostgres(postgres, () => postgresExited);
10992
+ rmSync(socketDirectory, { recursive: true, force: true });
10993
+ await releaseLock();
10994
+ }
10995
+ });
10996
+ } catch (error) {
10997
+ removeExitHandler?.();
10998
+ if (postgres)
10999
+ await stopPostgres(postgres, () => postgres.exitCode !== null);
11000
+ if (socketDirectory)
11001
+ rmSync(socketDirectory, { recursive: true, force: true });
11002
+ await releaseLock();
11003
+ throw error;
11004
+ }
11005
+ }
11006
+ async function stopPostgres(postgres, hasExited) {
11007
+ if (hasExited())
11008
+ return;
11009
+ postgres.kill("SIGINT");
11010
+ await new Promise((resolve2) => {
11011
+ const killTimeout = setTimeout(() => {
11012
+ postgres.kill("SIGKILL");
11013
+ resolve2();
11014
+ }, 5000);
11015
+ postgres.once("exit", () => {
11016
+ clearTimeout(killTimeout);
11017
+ resolve2();
11018
+ });
11019
+ });
11020
+ }
11021
+ function removeStalePidFile(pidPath) {
11022
+ if (!existsSync(pidPath))
11023
+ return;
11024
+ try {
11025
+ const pid = Number.parseInt(readFileSync(pidPath, "utf8").split(`
11026
+ `)[0]?.trim() ?? "", 10);
11027
+ if (!pid) {
11028
+ rmSync(pidPath, { force: true });
11029
+ return;
11030
+ }
11031
+ try {
11032
+ process.kill(pid, 0);
11033
+ } catch {
11034
+ rmSync(pidPath, { force: true });
11035
+ }
11036
+ } catch {}
11037
+ }
8873
11038
  // src/project-runtime.ts
8874
- import { chmod, link, lstat, mkdir as mkdir4, readFile as readFile6, realpath as realpath2, unlink as unlink3, writeFile as writeFile3 } from "fs/promises";
8875
- import { dirname as dirname4, isAbsolute, join as join6, parse, relative, resolve as resolve2 } from "path";
11039
+ import { chmod, link, lstat, mkdir as mkdir4, readFile as readFile6, realpath as realpath2, unlink as unlink2, writeFile as writeFile4 } from "fs/promises";
11040
+ import { dirname as dirname4, isAbsolute, join as join7, parse, relative, resolve as resolve2 } from "path";
8876
11041
 
8877
11042
  // src/runtime/node/config-toml.ts
8878
- import { readFileSync } from "fs";
8879
- import { join as join2 } from "path";
11043
+ import { readFileSync as readFileSync2 } from "fs";
11044
+ import { join as join3 } from "path";
8880
11045
  function emptyTable() {
8881
11046
  return { values: new Map, children: new Map };
8882
11047
  }
8883
11048
  function loadConfigToml(projectDir, env = process.env) {
8884
11049
  let text;
8885
11050
  try {
8886
- text = readFileSync(join2(projectDir, "supabase", "config.toml"), "utf8");
11051
+ text = readFileSync2(join3(projectDir, "supabase", "config.toml"), "utf8");
8887
11052
  } catch {
8888
11053
  return emptyTable();
8889
11054
  }
@@ -9249,15 +11414,15 @@ function readFunctions(root) {
9249
11414
 
9250
11415
  // src/runtime/node/load-functions.ts
9251
11416
  import { readdir, readFile as readFile4, realpath, rm as rm3, stat } from "fs/promises";
9252
- import { dirname as dirname3, join as join4 } from "path";
11417
+ import { dirname as dirname3, join as join5 } from "path";
9253
11418
  import { pathToFileURL } from "url";
9254
11419
 
9255
11420
  // src/runtime/node/bundle-function.ts
9256
- import { createHash } from "crypto";
9257
- import { mkdir as mkdir3, readFile as readFile3, rm as rm2, writeFile as writeFile2 } from "fs/promises";
9258
- import { existsSync } from "fs";
9259
- import { tmpdir } from "os";
9260
- import { join as join3 } from "path";
11421
+ import { createHash as createHash3 } from "crypto";
11422
+ import { mkdir as mkdir3, readFile as readFile3, rm as rm2, writeFile as writeFile3 } from "fs/promises";
11423
+ import { existsSync as existsSync2 } from "fs";
11424
+ import { tmpdir as tmpdir2 } from "os";
11425
+ import { join as join4 } from "path";
9261
11426
  function rewriteRemoteSpecifier(spec) {
9262
11427
  if (spec.startsWith("npm:"))
9263
11428
  return `https://esm.sh/${spec.slice(4)}`;
@@ -9265,18 +11430,18 @@ function rewriteRemoteSpecifier(spec) {
9265
11430
  return `https://esm.sh/jsr/${spec.slice(4)}`;
9266
11431
  return spec;
9267
11432
  }
9268
- var HTTP_CACHE = join3(tmpdir(), "supacloud-lite-fn-http");
11433
+ var HTTP_CACHE = join4(tmpdir2(), "supacloud-lite-fn-http");
9269
11434
  async function fetchModule(url) {
9270
- const key = createHash("sha256").update(url).digest("hex");
9271
- const cached = join3(HTTP_CACHE, key);
9272
- if (existsSync(cached))
11435
+ const key = createHash3("sha256").update(url).digest("hex");
11436
+ const cached = join4(HTTP_CACHE, key);
11437
+ if (existsSync2(cached))
9273
11438
  return readFile3(cached, "utf8");
9274
11439
  const res = await fetch(url, { redirect: "follow" });
9275
11440
  if (!res.ok)
9276
11441
  throw new Error(`failed to fetch ${url}: HTTP ${res.status}`);
9277
11442
  const body = await res.text();
9278
11443
  await mkdir3(HTTP_CACHE, { recursive: true });
9279
- await writeFile2(cached, body);
11444
+ await writeFile3(cached, body);
9280
11445
  return body;
9281
11446
  }
9282
11447
  function remotePlugin() {
@@ -9299,7 +11464,7 @@ function remotePlugin() {
9299
11464
  };
9300
11465
  }
9301
11466
  async function bundleFunction(entryPath, name) {
9302
- const outDir = join3(tmpdir(), "supacloud-lite-fn-bundle", name);
11467
+ const outDir = join4(tmpdir2(), "supacloud-lite-fn-bundle", name);
9303
11468
  await mkdir3(outDir, { recursive: true });
9304
11469
  try {
9305
11470
  const buildOutput = await Bun.build({
@@ -9329,7 +11494,7 @@ async function bundleFunction(entryPath, name) {
9329
11494
  async function loadFunctionEnv(projectDir) {
9330
11495
  let text;
9331
11496
  try {
9332
- text = await readFile4(join4(projectDir, "supabase", "functions", ".env"), "utf8");
11497
+ text = await readFile4(join5(projectDir, "supabase", "functions", ".env"), "utf8");
9333
11498
  } catch {
9334
11499
  return {};
9335
11500
  }
@@ -9368,7 +11533,7 @@ async function loadFunctions2(projectDir, options = {}) {
9368
11533
  }
9369
11534
  async function loadFunctionsUnlocked(projectDir, options) {
9370
11535
  const functions = new Map;
9371
- const root = join4(projectDir, "supabase", "functions");
11536
+ const root = join5(projectDir, "supabase", "functions");
9372
11537
  let entries = [];
9373
11538
  try {
9374
11539
  entries = await readdir(root);
@@ -9381,10 +11546,10 @@ async function loadFunctionsUnlocked(projectDir, options) {
9381
11546
  continue;
9382
11547
  if (options[name]?.enabled === false)
9383
11548
  continue;
9384
- const dir = join4(root, name);
11549
+ const dir = join5(root, name);
9385
11550
  if (!(await stat(dir)).isDirectory())
9386
11551
  continue;
9387
- const candidates = options[name]?.entrypoint ? [join4(projectDir, options[name].entrypoint)] : ["index.ts", "index.tsx", "index.js", "index.mjs"].map((f) => join4(dir, f));
11552
+ const candidates = options[name]?.entrypoint ? [join5(projectDir, options[name].entrypoint)] : ["index.ts", "index.tsx", "index.js", "index.mjs"].map((f) => join5(dir, f));
9388
11553
  for (const path of candidates) {
9389
11554
  try {
9390
11555
  await stat(path);
@@ -9431,9 +11596,9 @@ async function loadFunctionsUnlocked(projectDir, options) {
9431
11596
 
9432
11597
  // src/runtime/node/project.ts
9433
11598
  import { readdir as readdir2, readFile as readFile5 } from "fs/promises";
9434
- import { join as join5 } from "path";
11599
+ import { join as join6 } from "path";
9435
11600
  async function loadSupabaseProject(projectDir, seed = {}) {
9436
- const migrationsDir = join5(projectDir, "supabase", "migrations");
11601
+ const migrationsDir = join6(projectDir, "supabase", "migrations");
9437
11602
  const migrations = [];
9438
11603
  let entries = [];
9439
11604
  try {
@@ -9445,19 +11610,19 @@ async function loadSupabaseProject(projectDir, seed = {}) {
9445
11610
  for (const entry of entries.sort()) {
9446
11611
  if (!entry.endsWith(".sql"))
9447
11612
  continue;
9448
- const sql = await readFile5(join5(migrationsDir, entry), "utf8");
11613
+ const sql = await readFile5(join6(migrationsDir, entry), "utf8");
9449
11614
  migrations.push({ name: entry.replace(/\.sql$/, ""), sql });
9450
11615
  }
9451
11616
  let seedSql;
9452
11617
  if (seed.enabled !== false) {
9453
11618
  const parts = [];
9454
- const supabaseDir = join5(projectDir, "supabase");
11619
+ const supabaseDir = join6(projectDir, "supabase");
9455
11620
  for (const configuredPath of seed.paths ?? ["seed.sql"]) {
9456
11621
  const pattern = configuredPath.replace(/^\.\//, "");
9457
11622
  const matches = /[*?[\]{}]/.test(pattern) ? [...new Bun.Glob(pattern).scanSync({ cwd: supabaseDir, onlyFiles: true })].sort() : [pattern];
9458
11623
  for (const relativePath of matches) {
9459
11624
  try {
9460
- parts.push(await readFile5(join5(supabaseDir, relativePath), "utf8"));
11625
+ parts.push(await readFile5(join6(supabaseDir, relativePath), "utf8"));
9461
11626
  } catch (error) {
9462
11627
  if (!isNotFound(error))
9463
11628
  throw error;
@@ -9478,14 +11643,16 @@ function isNotFound(error) {
9478
11643
  function resolveProjectPaths(options = {}) {
9479
11644
  const projectDir = resolve2(options.projectDir ?? process.cwd());
9480
11645
  const stateDir = resolvePath(projectDir, options.stateDir ?? process.env.SUPACLOUD_LITE_STATE_DIR ?? ".supacloud-lite");
9481
- const dataDir = options.memory ? undefined : resolvePath(projectDir, options.dataDir ?? process.env.SUPACLOUD_LITE_DATA_DIR ?? join6(stateDir, "db"));
9482
- const storageDir = resolvePath(projectDir, options.storageDir ?? process.env.SUPACLOUD_LITE_STORAGE_DIR ?? join6(stateDir, "storage"));
11646
+ const databaseEngine = resolveDatabaseEngine(options.engine, options.memory);
11647
+ const dataDir = options.memory ? undefined : resolvePath(projectDir, options.dataDir ?? process.env.SUPACLOUD_LITE_DATA_DIR ?? join7(stateDir, databaseEngine === "native" ? "pgdata" : "db"));
11648
+ const storageDir = resolvePath(projectDir, options.storageDir ?? process.env.SUPACLOUD_LITE_STORAGE_DIR ?? join7(stateDir, "storage"));
9483
11649
  return {
9484
11650
  projectDir,
9485
11651
  stateDir,
9486
11652
  dataDir,
9487
11653
  storageDir,
9488
- secretsFile: join6(stateDir, "secrets.json")
11654
+ secretsFile: join7(stateDir, "secrets.json"),
11655
+ databaseEngine
9489
11656
  };
9490
11657
  }
9491
11658
  async function ensureProjectSecrets(paths) {
@@ -9508,7 +11675,7 @@ async function ensureProjectSecrets(paths) {
9508
11675
  };
9509
11676
  const temporaryFile = `${paths.secretsFile}.${crypto.randomUUID()}.tmp`;
9510
11677
  try {
9511
- await writeFile3(temporaryFile, `${JSON.stringify(candidate, null, 2)}
11678
+ await writeFile4(temporaryFile, `${JSON.stringify(candidate, null, 2)}
9512
11679
  `, { mode: 384, flag: "wx" });
9513
11680
  await link(temporaryFile, paths.secretsFile);
9514
11681
  stored = candidate;
@@ -9517,7 +11684,7 @@ async function ensureProjectSecrets(paths) {
9517
11684
  throw error2;
9518
11685
  stored = validateSecrets(JSON.parse(await readFile6(paths.secretsFile, "utf8")));
9519
11686
  } finally {
9520
- await unlink3(temporaryFile).catch((error2) => {
11687
+ await unlink2(temporaryFile).catch((error2) => {
9521
11688
  if (error2.code !== "ENOENT")
9522
11689
  throw error2;
9523
11690
  });
@@ -9549,42 +11716,58 @@ async function createProjectBackend(options = {}) {
9549
11716
  const webhooks = options.includeWebhooks === false ? [] : await loadWebhooks(paths.projectDir);
9550
11717
  const configuredStorageBackend = options.storageDriver ? "fs" : resolveStorageBackend(options.storageBackend);
9551
11718
  const storageBackend = options.storageDriver ? "custom" : configuredStorageBackend;
11719
+ const databaseEngine = paths.databaseEngine;
9552
11720
  if (paths.dataDir) {
9553
11721
  await mkdir4(paths.dataDir, { recursive: true, mode: 448 });
9554
11722
  await chmod(paths.dataDir, 448);
9555
11723
  }
9556
11724
  await mkdir4(paths.storageDir, { recursive: true, mode: 448 });
9557
11725
  await chmod(paths.storageDir, 448);
9558
- const backend = await createBackend({
9559
- dataDir: paths.dataDir,
9560
- jwtSecret: secrets.jwtSecret,
9561
- vaultKey: secrets.vaultKey,
9562
- apiUrl: url,
9563
- siteUrl: options.siteUrl ?? process.env.SUPACLOUD_LITE_SITE_URL ?? config.auth.siteUrl ?? url,
9564
- host,
9565
- jwtExpiry: config.auth.jwtExpiry,
9566
- uriAllowList: config.auth.uriAllowList,
9567
- authEnabled: config.auth.enabled,
9568
- authSettings: config.auth.settings,
9569
- authRateLimits: config.auth.rateLimits,
9570
- sessionTimeboxSeconds: config.auth.sessionTimeboxSeconds,
9571
- sessionInactivitySeconds: config.auth.sessionInactivitySeconds,
9572
- oauthProviders: config.auth.oauthProviders,
9573
- smsSender: options.smsSender,
9574
- dbSchemas: config.api.schemas,
9575
- maxRows: config.api.maxRows,
9576
- storageFileSizeLimit: config.storage.fileSizeLimit,
9577
- buckets: config.storage.buckets,
9578
- migrations: options.applyMigrations === false ? [] : project.migrations,
9579
- seedSql: options.applyMigrations === false || options.includeSeed === false ? undefined : project.seedSql,
9580
- functions,
9581
- functionVerifyJwt: Object.fromEntries(Object.entries(config.functions).map(([name, functionOptions]) => [name, functionOptions.verifyJwt !== false])),
9582
- functionEnv,
9583
- webhooks,
9584
- startRuntimeServices: options.startRuntimeServices,
9585
- storageDriver: options.storageDriver ?? createStorageDriver(configuredStorageBackend, paths.storageDir, options.s3),
9586
- log: options.log
9587
- });
11726
+ const storageDriver = options.storageDriver ?? createStorageDriver(configuredStorageBackend, paths.storageDir, options.s3);
11727
+ const engine = databaseEngine === "native" ? await createNativeEngine({ dataDir: paths.dataDir, log: options.log }) : undefined;
11728
+ let backend;
11729
+ try {
11730
+ backend = await createBackend({
11731
+ engine,
11732
+ dataDir: databaseEngine === "pglite" ? paths.dataDir : undefined,
11733
+ jwtSecret: secrets.jwtSecret,
11734
+ vaultKey: secrets.vaultKey,
11735
+ apiUrl: url,
11736
+ siteUrl: options.siteUrl ?? process.env.SUPACLOUD_LITE_SITE_URL ?? config.auth.siteUrl ?? url,
11737
+ host,
11738
+ jwtExpiry: config.auth.jwtExpiry,
11739
+ uriAllowList: config.auth.uriAllowList,
11740
+ authEnabled: config.auth.enabled,
11741
+ authSettings: config.auth.settings,
11742
+ authRateLimits: config.auth.rateLimits,
11743
+ sessionTimeboxSeconds: config.auth.sessionTimeboxSeconds,
11744
+ sessionInactivitySeconds: config.auth.sessionInactivitySeconds,
11745
+ oauthProviders: config.auth.oauthProviders,
11746
+ smsSender: options.smsSender,
11747
+ dbSchemas: config.api.schemas,
11748
+ maxRows: config.api.maxRows,
11749
+ storageFileSizeLimit: config.storage.fileSizeLimit,
11750
+ buckets: config.storage.buckets,
11751
+ migrations: options.applyMigrations === false ? [] : project.migrations,
11752
+ seedSql: options.applyMigrations === false || options.includeSeed === false ? undefined : project.seedSql,
11753
+ functions,
11754
+ functionVerifyJwt: Object.fromEntries(Object.entries(config.functions).map(([name, functionOptions]) => [name, functionOptions.verifyJwt !== false])),
11755
+ functionEnv,
11756
+ webhooks,
11757
+ startRuntimeServices: options.startRuntimeServices,
11758
+ storageDriver,
11759
+ log: options.log
11760
+ });
11761
+ } catch (error) {
11762
+ if (engine) {
11763
+ try {
11764
+ await engine.close();
11765
+ } catch (cleanupError) {
11766
+ throw new AggregateError([error, cleanupError], "project database startup cleanup failed");
11767
+ }
11768
+ }
11769
+ throw error;
11770
+ }
9588
11771
  return {
9589
11772
  backend,
9590
11773
  config,
@@ -9595,9 +11778,22 @@ async function createProjectBackend(options = {}) {
9595
11778
  migrationCount: project.migrations.length,
9596
11779
  functionNames: [...functions.keys()],
9597
11780
  webhookCount: webhooks.length,
9598
- storageBackend
11781
+ storageBackend,
11782
+ databaseEngine
9599
11783
  };
9600
11784
  }
11785
+ function resolveDatabaseEngine(value, memory = false) {
11786
+ const configured = value ?? process.env.SUPACLOUD_LITE_ENGINE ?? "pglite";
11787
+ if (configured !== "pglite" && configured !== "native") {
11788
+ throw new Error(`unsupported SUPACLOUD_LITE_ENGINE: ${configured}`);
11789
+ }
11790
+ if (configured === "native" && memory)
11791
+ throw new Error("--memory is only supported by the pglite engine");
11792
+ if (configured === "native" && !isNativeEngineSupported()) {
11793
+ throw new Error(`native PostgreSQL requires macOS or glibc Linux on x64/arm64; ` + `${process.platform}/${process.arch} must use --engine pglite`);
11794
+ }
11795
+ return configured;
11796
+ }
9601
11797
  function resolveStorageBackend(value) {
9602
11798
  const configured = value ?? process.env.SUPACLOUD_LITE_STORAGE_BACKEND ?? "fs";
9603
11799
  if (configured === "fs" || configured === "memory" || configured === "s3")
@@ -9652,7 +11848,7 @@ async function startProjectServer(options = {}) {
9652
11848
  }
9653
11849
  async function loadWebhooks(projectDir) {
9654
11850
  try {
9655
- const parsed = JSON.parse(await readFile6(join6(projectDir, "supabase", "webhooks.json"), "utf8"));
11851
+ const parsed = JSON.parse(await readFile6(join7(projectDir, "supabase", "webhooks.json"), "utf8"));
9656
11852
  return Array.isArray(parsed) ? parsed : [];
9657
11853
  } catch (error) {
9658
11854
  if (error.code === "ENOENT")
@@ -9702,9 +11898,9 @@ async function findEphemeralPort(host = "127.0.0.1") {
9702
11898
  return port;
9703
11899
  }
9704
11900
  // src/snapshot.ts
9705
- import { chmod as chmod2, copyFile, lstat as lstat2, mkdir as mkdir5, mkdtemp, readdir as readdir3, readFile as readFile7, rename as rename2, rm as rm4, writeFile as writeFile4 } from "fs/promises";
9706
- import { dirname as dirname5, join as join7, parse as parse2, relative as relative2, resolve as resolve3, sep as sep2 } from "path";
9707
- import { create as createTar, extract as extractTar } from "tar";
11901
+ import { chmod as chmod2, copyFile, lstat as lstat2, mkdir as mkdir5, mkdtemp, readdir as readdir3, readFile as readFile7, rename as rename2, rm as rm4, writeFile as writeFile5 } from "fs/promises";
11902
+ import { dirname as dirname5, join as join8, parse as parse2, relative as relative2, resolve as resolve3, sep as sep2 } from "path";
11903
+ import { create as createTar, extract as extractTar2 } from "tar";
9708
11904
  var SNAPSHOT_FORMAT = "supacloud-lite-snapshot";
9709
11905
  var SNAPSHOT_VERSION = 1;
9710
11906
  async function createSnapshot(options) {
@@ -9719,21 +11915,27 @@ async function createSnapshot(options) {
9719
11915
  storageBackend: options.storageBackend,
9720
11916
  includesDatabase: Boolean(paths.dataDir),
9721
11917
  includesLocalStorage: options.storageBackend === "fs",
9722
- includesSecrets: true
11918
+ includesSecrets: true,
11919
+ databaseEngine: paths.databaseEngine,
11920
+ ...paths.databaseEngine === "native" ? {
11921
+ platform: process.platform,
11922
+ architecture: process.arch,
11923
+ postgresMajor: await readPostgresMajor(paths.dataDir)
11924
+ } : {}
9723
11925
  };
9724
11926
  const output = resolve3(options.output);
9725
11927
  if (await existingInfo(output))
9726
11928
  throw new Error(`snapshot output already exists: ${output}`);
9727
11929
  await mkdir5(dirname5(output), { recursive: true });
9728
- const stagingRoot = await mkdtemp(join7(dirname5(output), ".supacloud-lite-snapshot-"));
11930
+ const stagingRoot = await mkdtemp(join8(dirname5(output), ".supacloud-lite-snapshot-"));
9729
11931
  try {
9730
- await writeFile4(join7(stagingRoot, "manifest.json"), `${JSON.stringify(manifest, null, 2)}
11932
+ await writeFile5(join8(stagingRoot, "manifest.json"), `${JSON.stringify(manifest, null, 2)}
9731
11933
  `);
9732
- await stageFile(paths.secretsFile, join7(stagingRoot, "secrets.json"));
11934
+ await stageFile(paths.secretsFile, join8(stagingRoot, "secrets.json"));
9733
11935
  if (paths.dataDir)
9734
- await stageDirectory(paths.dataDir, join7(stagingRoot, "database"));
11936
+ await stageDirectory(paths.dataDir, join8(stagingRoot, "database"));
9735
11937
  if (options.storageBackend === "fs")
9736
- await stageDirectory(paths.storageDir, join7(stagingRoot, "storage"));
11938
+ await stageDirectory(paths.storageDir, join8(stagingRoot, "storage"));
9737
11939
  const entries = ["manifest.json", "secrets.json"];
9738
11940
  if (paths.dataDir)
9739
11941
  entries.push("database");
@@ -9754,13 +11956,13 @@ async function restoreSnapshot(options) {
9754
11956
  const paths = normalizePaths(options.paths);
9755
11957
  await assertSnapshotPaths(paths, { requireSecrets: false, allowMissingState: true });
9756
11958
  await assertNoDataDirectoryLock(paths);
9757
- const stagingRoot = await mkdtemp(join7(dirname5(paths.stateDir), ".supacloud-lite-restore-"));
9758
- const payloadRoot = join7(stagingRoot, "payload");
11959
+ const stagingRoot = await mkdtemp(join8(dirname5(paths.stateDir), ".supacloud-lite-restore-"));
11960
+ const payloadRoot = join8(stagingRoot, "payload");
9759
11961
  const rollbackId = crypto.randomUUID();
9760
11962
  const rollbackPaths = [];
9761
11963
  try {
9762
11964
  await mkdir5(payloadRoot, { recursive: true });
9763
- await extractTar({
11965
+ await extractTar2({
9764
11966
  cwd: payloadRoot,
9765
11967
  file: resolve3(options.input),
9766
11968
  preserveOwner: false,
@@ -9785,6 +11987,7 @@ async function restoreSnapshot(options) {
9785
11987
  if (manifest.storageBackend !== options.storageBackend) {
9786
11988
  throw new Error(`snapshot storage backend is ${manifest.storageBackend}, but the target uses ${options.storageBackend}; ` + "restore with the matching --storage-backend value");
9787
11989
  }
11990
+ assertDatabaseSnapshotCompatible(manifest, paths);
9788
11991
  if (manifest.includesDatabase !== Boolean(paths.dataDir)) {
9789
11992
  throw new Error("snapshot database mode does not match the target; do not restore a persistent snapshot into --memory");
9790
11993
  }
@@ -9793,27 +11996,27 @@ async function restoreSnapshot(options) {
9793
11996
  }
9794
11997
  await assertSnapshotPayload(payloadRoot, manifest);
9795
11998
  if (manifest.includesDatabase)
9796
- await mkdir5(join7(payloadRoot, "database"), { recursive: true });
11999
+ await mkdir5(join8(payloadRoot, "database"), { recursive: true });
9797
12000
  if (manifest.includesLocalStorage)
9798
- await mkdir5(join7(payloadRoot, "storage"), { recursive: true });
12001
+ await mkdir5(join8(payloadRoot, "storage"), { recursive: true });
9799
12002
  await assertRestoreTargets(paths, manifest, options.force === true);
9800
- const stateStage = join7(stagingRoot, "state");
12003
+ const stateStage = join8(stagingRoot, "state");
9801
12004
  await mkdir5(stateStage, { recursive: true });
9802
- await copyEntry(join7(payloadRoot, "secrets.json"), join7(stateStage, "secrets.json"));
12005
+ await copyEntry(join8(payloadRoot, "secrets.json"), join8(stateStage, "secrets.json"));
9803
12006
  if (paths.dataDir && isWithin(paths.stateDir, paths.dataDir)) {
9804
- await copyEntry(join7(payloadRoot, "database"), join7(stateStage, relative2(paths.stateDir, paths.dataDir)));
12007
+ await copyEntry(join8(payloadRoot, "database"), join8(stateStage, relative2(paths.stateDir, paths.dataDir)));
9805
12008
  }
9806
12009
  if (options.storageBackend === "fs" && isWithin(paths.stateDir, paths.storageDir)) {
9807
- await copyEntry(join7(payloadRoot, "storage"), join7(stateStage, relative2(paths.stateDir, paths.storageDir)));
12010
+ await copyEntry(join8(payloadRoot, "storage"), join8(stateStage, relative2(paths.stateDir, paths.storageDir)));
9808
12011
  }
9809
12012
  const swaps = [];
9810
12013
  try {
9811
12014
  await applyDirectorySwap(stateStage, paths.stateDir, options.force === true, rollbackId, swaps);
9812
12015
  if (paths.dataDir && !isWithin(paths.stateDir, paths.dataDir)) {
9813
- await applyDirectorySwap(join7(payloadRoot, "database"), paths.dataDir, options.force === true, rollbackId, swaps);
12016
+ await applyDirectorySwap(join8(payloadRoot, "database"), paths.dataDir, options.force === true, rollbackId, swaps);
9814
12017
  }
9815
12018
  if (options.storageBackend === "fs" && !isWithin(paths.stateDir, paths.storageDir)) {
9816
- await applyDirectorySwap(join7(payloadRoot, "storage"), paths.storageDir, options.force === true, rollbackId, swaps);
12019
+ await applyDirectorySwap(join8(payloadRoot, "storage"), paths.storageDir, options.force === true, rollbackId, swaps);
9817
12020
  }
9818
12021
  } catch (error) {
9819
12022
  await rollbackDirectorySwaps(swaps);
@@ -9849,7 +12052,7 @@ async function assertSnapshotPaths(paths, options = {}) {
9849
12052
  try {
9850
12053
  if (paths.stateDir === parse2(paths.stateDir).root)
9851
12054
  throw new Error("snapshot state directory must not be the filesystem root");
9852
- if (paths.secretsFile !== join7(paths.stateDir, "secrets.json"))
12055
+ if (paths.secretsFile !== join8(paths.stateDir, "secrets.json"))
9853
12056
  throw new Error("snapshot secrets path must be inside the state directory");
9854
12057
  const stateInfo = await lstat2(paths.stateDir);
9855
12058
  if (!stateInfo.isDirectory() || stateInfo.isSymbolicLink())
@@ -9907,10 +12110,10 @@ async function stageDirectory(root, destination) {
9907
12110
  throw error;
9908
12111
  }
9909
12112
  await mkdir5(destination, { recursive: true });
9910
- const walk = async (current, target) => {
12113
+ const walk = async (current, target2) => {
9911
12114
  for (const entry of await readdir3(current, { withFileTypes: true })) {
9912
- const fullPath = join7(current, entry.name);
9913
- const targetPath = join7(target, entry.name);
12115
+ const fullPath = join8(current, entry.name);
12116
+ const targetPath = join8(target2, entry.name);
9914
12117
  if (entry.isSymbolicLink())
9915
12118
  throw new Error(`snapshot refuses symbolic link: ${fullPath}`);
9916
12119
  if (entry.isDirectory()) {
@@ -9925,14 +12128,14 @@ async function stageDirectory(root, destination) {
9925
12128
  };
9926
12129
  await walk(root, destination);
9927
12130
  }
9928
- async function stageFile(source, target) {
9929
- await mkdir5(dirname5(target), { recursive: true });
9930
- await copyFile(source, target);
12131
+ async function stageFile(source, target2) {
12132
+ await mkdir5(dirname5(target2), { recursive: true });
12133
+ await copyFile(source, target2);
9931
12134
  }
9932
12135
  async function readManifest(payloadRoot) {
9933
12136
  let parsed;
9934
12137
  try {
9935
- parsed = JSON.parse(await readFile7(join7(payloadRoot, "manifest.json"), "utf8"));
12138
+ parsed = JSON.parse(await readFile7(join8(payloadRoot, "manifest.json"), "utf8"));
9936
12139
  } catch (error) {
9937
12140
  throw new Error(`invalid snapshot manifest: ${error instanceof Error ? error.message : String(error)}`);
9938
12141
  }
@@ -9944,13 +12147,38 @@ function isSnapshotManifest(value) {
9944
12147
  if (!value || typeof value !== "object")
9945
12148
  return false;
9946
12149
  const candidate = value;
9947
- 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;
12150
+ 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 && (candidate.databaseEngine === undefined || candidate.databaseEngine === "pglite" || candidate.databaseEngine === "native") && (candidate.platform === undefined || typeof candidate.platform === "string") && (candidate.architecture === undefined || typeof candidate.architecture === "string") && (candidate.postgresMajor === undefined || typeof candidate.postgresMajor === "string");
12151
+ }
12152
+ function assertDatabaseSnapshotCompatible(manifest, paths) {
12153
+ const sourceEngine = manifest.databaseEngine ?? "pglite";
12154
+ if (sourceEngine !== paths.databaseEngine) {
12155
+ throw new Error(`snapshot database engine is ${sourceEngine}, but the target uses ${paths.databaseEngine}`);
12156
+ }
12157
+ if (sourceEngine !== "native")
12158
+ return;
12159
+ if (manifest.platform !== process.platform || manifest.architecture !== process.arch) {
12160
+ throw new Error(`native PostgreSQL snapshots require the same platform and architecture; ` + `source is ${manifest.platform ?? "unknown"}/${manifest.architecture ?? "unknown"}, ` + `target is ${process.platform}/${process.arch}`);
12161
+ }
12162
+ if (manifest.postgresMajor !== NATIVE_POSTGRES_MAJOR) {
12163
+ throw new Error(`native PostgreSQL snapshot major is ${manifest.postgresMajor ?? "unknown"}, ` + `but this Lite build uses ${NATIVE_POSTGRES_MAJOR}`);
12164
+ }
12165
+ }
12166
+ async function readPostgresMajor(dataDir) {
12167
+ if (!dataDir)
12168
+ return;
12169
+ try {
12170
+ return (await readFile7(join8(dataDir, "PG_VERSION"), "utf8")).trim();
12171
+ } catch (error) {
12172
+ if (error.code === "ENOENT")
12173
+ return;
12174
+ throw error;
12175
+ }
9948
12176
  }
9949
12177
  async function assertSnapshotPayload(payloadRoot, manifest) {
9950
12178
  const required = ["manifest.json", "secrets.json"];
9951
12179
  for (const path of required) {
9952
12180
  try {
9953
- await lstat2(join7(payloadRoot, path));
12181
+ await lstat2(join8(payloadRoot, path));
9954
12182
  } catch {
9955
12183
  throw new Error(`snapshot is missing required payload: ${path}`);
9956
12184
  }
@@ -9973,9 +12201,9 @@ async function assertRestoreTargets(paths, manifest, force) {
9973
12201
  if (manifest.includesLocalStorage && !isWithin(paths.stateDir, paths.storageDir))
9974
12202
  targets.push(paths.storageDir);
9975
12203
  if (!force) {
9976
- for (const target of targets) {
9977
- if (await directoryHasEntries(target))
9978
- throw new Error(`restore target is not empty: ${target}; pass --force to replace it`);
12204
+ for (const target2 of targets) {
12205
+ if (await directoryHasEntries(target2))
12206
+ throw new Error(`restore target is not empty: ${target2}; pass --force to replace it`);
9979
12207
  }
9980
12208
  }
9981
12209
  }
@@ -9988,28 +12216,28 @@ async function directoryHasEntries(path) {
9988
12216
  throw error;
9989
12217
  }
9990
12218
  }
9991
- async function applyDirectorySwap(source, target, force, rollbackId, swaps) {
9992
- const targetInfo = await existingInfo(target);
12219
+ async function applyDirectorySwap(source, target2, force, rollbackId, swaps) {
12220
+ const targetInfo = await existingInfo(target2);
9993
12221
  if (targetInfo && !targetInfo.isDirectory())
9994
- throw new Error(`restore target is not a directory: ${target}`);
9995
- const swap = { target };
12222
+ throw new Error(`restore target is not a directory: ${target2}`);
12223
+ const swap = { target: target2 };
9996
12224
  if (targetInfo) {
9997
12225
  if (!force) {
9998
- if (await directoryHasEntries(target))
9999
- throw new Error(`restore target is not empty: ${target}; pass --force to replace it`);
10000
- await rm4(target, { recursive: true, force: true });
12226
+ if (await directoryHasEntries(target2))
12227
+ throw new Error(`restore target is not empty: ${target2}; pass --force to replace it`);
12228
+ await rm4(target2, { recursive: true, force: true });
10001
12229
  } else {
10002
- swap.rollbackPath = join7(dirname5(target), `.${target.split(sep2).pop() ?? "state"}.restore-${rollbackId}`);
10003
- await rename2(target, swap.rollbackPath);
12230
+ swap.rollbackPath = join8(dirname5(target2), `.${target2.split(sep2).pop() ?? "state"}.restore-${rollbackId}`);
12231
+ await rename2(target2, swap.rollbackPath);
10004
12232
  }
10005
12233
  }
10006
12234
  try {
10007
- await mkdir5(dirname5(target), { recursive: true });
10008
- await rename2(source, target);
12235
+ await mkdir5(dirname5(target2), { recursive: true });
12236
+ await rename2(source, target2);
10009
12237
  swaps.push(swap);
10010
12238
  } catch (error) {
10011
12239
  if (swap.rollbackPath)
10012
- await rename2(swap.rollbackPath, target).catch(() => {});
12240
+ await rename2(swap.rollbackPath, target2).catch(() => {});
10013
12241
  throw error;
10014
12242
  }
10015
12243
  }
@@ -10029,17 +12257,17 @@ async function existingInfo(path) {
10029
12257
  throw error;
10030
12258
  }
10031
12259
  }
10032
- async function copyEntry(source, target) {
12260
+ async function copyEntry(source, target2) {
10033
12261
  const info = await lstat2(source);
10034
12262
  if (info.isSymbolicLink())
10035
12263
  throw new Error(`snapshot refuses symbolic link: ${source}`);
10036
12264
  if (info.isDirectory()) {
10037
- await mkdir5(target, { recursive: true });
12265
+ await mkdir5(target2, { recursive: true });
10038
12266
  for (const entry of await readdir3(source))
10039
- await copyEntry(join7(source, entry), join7(target, entry));
12267
+ await copyEntry(join8(source, entry), join8(target2, entry));
10040
12268
  } else if (info.isFile()) {
10041
- await mkdir5(dirname5(target), { recursive: true });
10042
- await Bun.write(target, Bun.file(source));
12269
+ await mkdir5(dirname5(target2), { recursive: true });
12270
+ await Bun.write(target2, Bun.file(source));
10043
12271
  } else
10044
12272
  throw new Error(`snapshot refuses unsupported filesystem entry: ${source}`);
10045
12273
  }
@@ -10050,7 +12278,7 @@ async function hardenRestoredTree(root) {
10050
12278
  if (info.isDirectory()) {
10051
12279
  await chmod2(root, 448);
10052
12280
  for (const entry of await readdir3(root))
10053
- await hardenRestoredTree(join7(root, entry));
12281
+ await hardenRestoredTree(join8(root, entry));
10054
12282
  return;
10055
12283
  }
10056
12284
  if (info.isFile()) {
@@ -10061,7 +12289,7 @@ async function hardenRestoredTree(root) {
10061
12289
  }
10062
12290
  async function assertNoSymlinks(root) {
10063
12291
  for (const entry of await readdir3(root, { withFileTypes: true })) {
10064
- const fullPath = join7(root, entry.name);
12292
+ const fullPath = join8(root, entry.name);
10065
12293
  if (entry.isSymbolicLink())
10066
12294
  throw new Error(`snapshot refuses symbolic link in archive: ${fullPath}`);
10067
12295
  if (entry.isDirectory())
@@ -10086,14 +12314,18 @@ export {
10086
12314
  restoreSnapshot,
10087
12315
  resolveStorageBackend,
10088
12316
  resolveProjectPaths,
12317
+ resolveDatabaseEngine,
10089
12318
  mintProjectKeys,
12319
+ isNativeEngineSupported,
10090
12320
  inspectDb,
10091
12321
  generateTypes,
10092
12322
  ensureProjectSecrets,
12323
+ ensurePostgres,
10093
12324
  decodeJwt,
10094
12325
  createSnapshot,
10095
12326
  createProjectBackend,
10096
12327
  createPgliteEngine,
12328
+ createNativeEngine,
10097
12329
  createBackend as createLiteBackend,
10098
12330
  SUPACLOUD_LITE_VERSION,
10099
12331
  S3StorageDriver,