@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.
- package/CHANGELOG.md +20 -0
- package/LICENSES/POSTGRESQL-17-COPYRIGHT.txt +23 -0
- package/LICENSES/THESEUS-POSTGRESQL-LICENSE.txt +7 -0
- package/THIRD_PARTY_NOTICES.md +11 -1
- package/dist/cli.js +2674 -375
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2537 -305
- package/dist/project-runtime.d.ts +5 -0
- package/dist/project-runtime.d.ts.map +1 -1
- package/dist/runtime/auth/handler.d.ts +5 -2
- package/dist/runtime/auth/handler.d.ts.map +1 -1
- package/dist/runtime/db/data-dir-lock.d.ts +2 -0
- package/dist/runtime/db/data-dir-lock.d.ts.map +1 -1
- package/dist/runtime/db/database.d.ts.map +1 -1
- package/dist/runtime/db/emulated.d.ts +2 -1
- package/dist/runtime/db/emulated.d.ts.map +1 -1
- package/dist/runtime/db/pglite-engine.d.ts.map +1 -1
- package/dist/runtime/node/fs-driver.d.ts +2 -0
- package/dist/runtime/node/fs-driver.d.ts.map +1 -1
- package/dist/runtime/node/native/engine.d.ts +23 -0
- package/dist/runtime/node/native/engine.d.ts.map +1 -0
- package/dist/runtime/node/native/wire-engine.d.ts +9 -0
- package/dist/runtime/node/native/wire-engine.d.ts.map +1 -0
- package/dist/runtime/node/native/wire.d.ts +56 -0
- package/dist/runtime/node/native/wire.d.ts.map +1 -0
- package/dist/runtime/rest/handler.d.ts.map +1 -1
- package/dist/runtime/storage/handler.d.ts +5 -0
- package/dist/runtime/storage/handler.d.ts.map +1 -1
- package/dist/runtime/storage/image-transform-cache.d.ts +13 -0
- package/dist/runtime/storage/image-transform-cache.d.ts.map +1 -0
- package/dist/runtime/storage/image-transform.d.ts +1 -1
- package/dist/runtime/storage/image-transform.d.ts.map +1 -1
- package/dist/runtime/storage/s3-driver.d.ts +2 -0
- package/dist/runtime/storage/s3-driver.d.ts.map +1 -1
- package/dist/runtime/types.d.ts +2 -0
- package/dist/runtime/types.d.ts.map +1 -1
- package/dist/snapshot.d.ts +5 -1
- package/dist/snapshot.d.ts.map +1 -1
- 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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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((
|
|
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
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
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.
|
|
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
|
|
1868
|
-
|
|
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,
|
|
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.
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
3554
|
-
|
|
3555
|
-
|
|
3556
|
-
|
|
3557
|
-
|
|
3558
|
-
|
|
3559
|
-
|
|
3560
|
-
|
|
3561
|
-
|
|
3562
|
-
|
|
3563
|
-
|
|
3564
|
-
|
|
3565
|
-
|
|
3566
|
-
|
|
3567
|
-
|
|
3568
|
-
|
|
3569
|
-
|
|
3570
|
-
|
|
3571
|
-
|
|
3572
|
-
|
|
3573
|
-
|
|
3574
|
-
|
|
3575
|
-
|
|
3576
|
-
|
|
3577
|
-
|
|
3578
|
-
|
|
3579
|
-
|
|
3580
|
-
|
|
3581
|
-
|
|
3582
|
-
|
|
3583
|
-
|
|
3584
|
-
|
|
3585
|
-
|
|
3586
|
-
|
|
3587
|
-
|
|
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
|
-
|
|
4189
|
-
|
|
4190
|
-
|
|
4191
|
-
|
|
4192
|
-
|
|
4193
|
-
|
|
4194
|
-
|
|
4195
|
-
|
|
4196
|
-
|
|
4197
|
-
|
|
4198
|
-
await engine.
|
|
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
|
|
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(
|
|
6493
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
7193
|
-
const
|
|
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
|
|
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
|
-
|
|
7315
|
-
|
|
7316
|
-
|
|
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
|
|
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 `${
|
|
8894
|
+
return `${OBJECT_VERSION_PREFIX2}${crypto.randomUUID()}`;
|
|
7516
8895
|
}
|
|
7517
8896
|
function isVersionedObjectVersion(version) {
|
|
7518
|
-
return version?.startsWith(
|
|
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
|
|
8875
|
-
import { dirname as dirname4, isAbsolute, join as
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
9258
|
-
import { existsSync } from "fs";
|
|
9259
|
-
import { tmpdir } from "os";
|
|
9260
|
-
import { join as
|
|
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 =
|
|
11433
|
+
var HTTP_CACHE = join4(tmpdir2(), "supacloud-lite-fn-http");
|
|
9269
11434
|
async function fetchModule(url) {
|
|
9270
|
-
const key =
|
|
9271
|
-
const cached =
|
|
9272
|
-
if (
|
|
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
|
|
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 =
|
|
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(
|
|
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 =
|
|
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 =
|
|
11549
|
+
const dir = join5(root, name);
|
|
9385
11550
|
if (!(await stat(dir)).isDirectory())
|
|
9386
11551
|
continue;
|
|
9387
|
-
const candidates = options[name]?.entrypoint ? [
|
|
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
|
|
11599
|
+
import { join as join6 } from "path";
|
|
9435
11600
|
async function loadSupabaseProject(projectDir, seed = {}) {
|
|
9436
|
-
const migrationsDir =
|
|
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(
|
|
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 =
|
|
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(
|
|
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
|
|
9482
|
-
const
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
9559
|
-
|
|
9560
|
-
|
|
9561
|
-
|
|
9562
|
-
|
|
9563
|
-
|
|
9564
|
-
|
|
9565
|
-
|
|
9566
|
-
|
|
9567
|
-
|
|
9568
|
-
|
|
9569
|
-
|
|
9570
|
-
|
|
9571
|
-
|
|
9572
|
-
|
|
9573
|
-
|
|
9574
|
-
|
|
9575
|
-
|
|
9576
|
-
|
|
9577
|
-
|
|
9578
|
-
|
|
9579
|
-
|
|
9580
|
-
|
|
9581
|
-
|
|
9582
|
-
|
|
9583
|
-
|
|
9584
|
-
|
|
9585
|
-
|
|
9586
|
-
|
|
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(
|
|
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
|
|
9706
|
-
import { dirname as dirname5, join as
|
|
9707
|
-
import { create as createTar, extract as
|
|
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(
|
|
11930
|
+
const stagingRoot = await mkdtemp(join8(dirname5(output), ".supacloud-lite-snapshot-"));
|
|
9729
11931
|
try {
|
|
9730
|
-
await
|
|
11932
|
+
await writeFile5(join8(stagingRoot, "manifest.json"), `${JSON.stringify(manifest, null, 2)}
|
|
9731
11933
|
`);
|
|
9732
|
-
await stageFile(paths.secretsFile,
|
|
11934
|
+
await stageFile(paths.secretsFile, join8(stagingRoot, "secrets.json"));
|
|
9733
11935
|
if (paths.dataDir)
|
|
9734
|
-
await stageDirectory(paths.dataDir,
|
|
11936
|
+
await stageDirectory(paths.dataDir, join8(stagingRoot, "database"));
|
|
9735
11937
|
if (options.storageBackend === "fs")
|
|
9736
|
-
await stageDirectory(paths.storageDir,
|
|
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(
|
|
9758
|
-
const payloadRoot =
|
|
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
|
|
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(
|
|
11999
|
+
await mkdir5(join8(payloadRoot, "database"), { recursive: true });
|
|
9797
12000
|
if (manifest.includesLocalStorage)
|
|
9798
|
-
await mkdir5(
|
|
12001
|
+
await mkdir5(join8(payloadRoot, "storage"), { recursive: true });
|
|
9799
12002
|
await assertRestoreTargets(paths, manifest, options.force === true);
|
|
9800
|
-
const stateStage =
|
|
12003
|
+
const stateStage = join8(stagingRoot, "state");
|
|
9801
12004
|
await mkdir5(stateStage, { recursive: true });
|
|
9802
|
-
await copyEntry(
|
|
12005
|
+
await copyEntry(join8(payloadRoot, "secrets.json"), join8(stateStage, "secrets.json"));
|
|
9803
12006
|
if (paths.dataDir && isWithin(paths.stateDir, paths.dataDir)) {
|
|
9804
|
-
await copyEntry(
|
|
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(
|
|
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(
|
|
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(
|
|
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 !==
|
|
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,
|
|
12113
|
+
const walk = async (current, target2) => {
|
|
9911
12114
|
for (const entry of await readdir3(current, { withFileTypes: true })) {
|
|
9912
|
-
const fullPath =
|
|
9913
|
-
const targetPath =
|
|
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,
|
|
9929
|
-
await mkdir5(dirname5(
|
|
9930
|
-
await copyFile(source,
|
|
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(
|
|
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(
|
|
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
|
|
9977
|
-
if (await directoryHasEntries(
|
|
9978
|
-
throw new Error(`restore target is not empty: ${
|
|
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,
|
|
9992
|
-
const targetInfo = await existingInfo(
|
|
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: ${
|
|
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(
|
|
9999
|
-
throw new Error(`restore target is not empty: ${
|
|
10000
|
-
await rm4(
|
|
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 =
|
|
10003
|
-
await rename2(
|
|
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(
|
|
10008
|
-
await rename2(source,
|
|
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,
|
|
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,
|
|
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(
|
|
12265
|
+
await mkdir5(target2, { recursive: true });
|
|
10038
12266
|
for (const entry of await readdir3(source))
|
|
10039
|
-
await copyEntry(
|
|
12267
|
+
await copyEntry(join8(source, entry), join8(target2, entry));
|
|
10040
12268
|
} else if (info.isFile()) {
|
|
10041
|
-
await mkdir5(dirname5(
|
|
10042
|
-
await Bun.write(
|
|
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(
|
|
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 =
|
|
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,
|