@supacloud/lite 0.7.2 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -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 +2592 -347
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2463 -285
- package/dist/project-runtime.d.ts +5 -0
- package/dist/project-runtime.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/bundle-function.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/load-functions.d.ts +3 -2
- package/dist/runtime/node/load-functions.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.0",
|
|
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",
|
|
@@ -3550,41 +3554,1158 @@ $pgmq$;
|
|
|
3550
3554
|
revoke all on all functions in schema pgmq from public, anon, authenticated;
|
|
3551
3555
|
grant execute on all functions in schema pgmq to service_role;
|
|
3552
3556
|
|
|
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
|
-
|
|
3557
|
+
-- supacloud:sql-module:pgmq-public:start
|
|
3558
|
+
DO $pgmq_extension$
|
|
3559
|
+
BEGIN
|
|
3560
|
+
IF to_regprocedure('pgmq.send(text,jsonb,integer)') IS NULL THEN
|
|
3561
|
+
EXECUTE 'CREATE EXTENSION IF NOT EXISTS pgmq';
|
|
3562
|
+
END IF;
|
|
3563
|
+
END
|
|
3564
|
+
$pgmq_extension$;
|
|
3565
|
+
|
|
3566
|
+
CREATE SCHEMA IF NOT EXISTS pgmq_public;
|
|
3567
|
+
GRANT USAGE ON SCHEMA pgmq_public TO anon, authenticated, service_role;
|
|
3568
|
+
|
|
3569
|
+
CREATE OR REPLACE FUNCTION pgmq_public.require_public_queue(queue_name text)
|
|
3570
|
+
RETURNS text
|
|
3571
|
+
LANGUAGE plpgsql
|
|
3572
|
+
IMMUTABLE
|
|
3573
|
+
SET search_path = ''
|
|
3574
|
+
AS $$
|
|
3575
|
+
DECLARE
|
|
3576
|
+
normalized_queue_name text := lower(btrim(queue_name));
|
|
3577
|
+
BEGIN
|
|
3578
|
+
IF normalized_queue_name IS NULL
|
|
3579
|
+
OR left(normalized_queue_name, char_length('supacloud_internal_')) = 'supacloud_internal_' THEN
|
|
3580
|
+
RAISE EXCEPTION 'SUPACLOUD_QUEUE_NAME_RESERVED' USING ERRCODE = '42501';
|
|
3581
|
+
END IF;
|
|
3582
|
+
RETURN normalized_queue_name;
|
|
3583
|
+
END;
|
|
3584
|
+
$$;
|
|
3585
|
+
|
|
3586
|
+
CREATE OR REPLACE FUNCTION pgmq_public.send(queue_name text, message jsonb, sleep_seconds integer DEFAULT 0)
|
|
3587
|
+
RETURNS SETOF bigint
|
|
3588
|
+
LANGUAGE sql
|
|
3589
|
+
VOLATILE
|
|
3590
|
+
SECURITY DEFINER
|
|
3591
|
+
SET search_path = ''
|
|
3592
|
+
AS $$ SELECT * FROM pgmq.send(pgmq_public.require_public_queue(queue_name), message, sleep_seconds); $$;
|
|
3593
|
+
|
|
3594
|
+
CREATE OR REPLACE FUNCTION pgmq_public.send_batch(queue_name text, messages jsonb[], sleep_seconds integer DEFAULT 0)
|
|
3595
|
+
RETURNS SETOF bigint
|
|
3596
|
+
LANGUAGE sql
|
|
3597
|
+
VOLATILE
|
|
3598
|
+
SECURITY DEFINER
|
|
3599
|
+
SET search_path = ''
|
|
3600
|
+
AS $$ SELECT * FROM pgmq.send_batch(pgmq_public.require_public_queue(queue_name), messages, sleep_seconds); $$;
|
|
3601
|
+
|
|
3602
|
+
CREATE OR REPLACE FUNCTION pgmq_public.read(queue_name text, sleep_seconds integer, n integer)
|
|
3603
|
+
RETURNS SETOF pgmq.message_record
|
|
3604
|
+
LANGUAGE sql
|
|
3605
|
+
VOLATILE
|
|
3606
|
+
SECURITY DEFINER
|
|
3607
|
+
SET search_path = ''
|
|
3608
|
+
AS $$ SELECT * FROM pgmq.read(pgmq_public.require_public_queue(queue_name), sleep_seconds, n); $$;
|
|
3609
|
+
|
|
3610
|
+
CREATE OR REPLACE FUNCTION pgmq_public.pop(queue_name text)
|
|
3611
|
+
RETURNS SETOF pgmq.message_record
|
|
3612
|
+
LANGUAGE sql
|
|
3613
|
+
VOLATILE
|
|
3614
|
+
SECURITY DEFINER
|
|
3615
|
+
SET search_path = ''
|
|
3616
|
+
AS $$ SELECT * FROM pgmq.pop(pgmq_public.require_public_queue(queue_name)); $$;
|
|
3617
|
+
|
|
3618
|
+
CREATE OR REPLACE FUNCTION pgmq_public.archive(queue_name text, message_id bigint)
|
|
3619
|
+
RETURNS boolean
|
|
3620
|
+
LANGUAGE sql
|
|
3621
|
+
VOLATILE
|
|
3622
|
+
SECURITY DEFINER
|
|
3623
|
+
SET search_path = ''
|
|
3624
|
+
AS $$ SELECT pgmq.archive(pgmq_public.require_public_queue(queue_name), message_id); $$;
|
|
3625
|
+
|
|
3626
|
+
CREATE OR REPLACE FUNCTION pgmq_public."delete"(queue_name text, message_id bigint)
|
|
3627
|
+
RETURNS boolean
|
|
3628
|
+
LANGUAGE sql
|
|
3629
|
+
VOLATILE
|
|
3630
|
+
SECURITY DEFINER
|
|
3631
|
+
SET search_path = ''
|
|
3632
|
+
AS $$ SELECT pgmq.delete(pgmq_public.require_public_queue(queue_name), message_id); $$;
|
|
3633
|
+
|
|
3634
|
+
REVOKE ALL ON ALL FUNCTIONS IN SCHEMA pgmq_public FROM PUBLIC;
|
|
3635
|
+
GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA pgmq_public TO anon, authenticated, service_role;
|
|
3636
|
+
-- supacloud:sql-module:pgmq-public:end
|
|
3637
|
+
`;
|
|
3638
|
+
var WORKFLOWS_SQL = `
|
|
3639
|
+
-- supacloud:sql-module:workflows-public:start
|
|
3640
|
+
DO $pgmq_extension$
|
|
3641
|
+
BEGIN
|
|
3642
|
+
IF to_regprocedure('pgmq.send(text,jsonb,integer)') IS NULL THEN
|
|
3643
|
+
EXECUTE 'CREATE EXTENSION IF NOT EXISTS pgmq';
|
|
3644
|
+
END IF;
|
|
3645
|
+
END
|
|
3646
|
+
$pgmq_extension$;
|
|
3647
|
+
CREATE SCHEMA IF NOT EXISTS supacloud_workflows;
|
|
3648
|
+
REVOKE ALL ON SCHEMA supacloud_workflows FROM PUBLIC, anon, authenticated;
|
|
3649
|
+
GRANT USAGE ON SCHEMA supacloud_workflows TO service_role;
|
|
3650
|
+
|
|
3651
|
+
SELECT pgmq.create('supacloud_internal_workflows');
|
|
3652
|
+
|
|
3653
|
+
CREATE TABLE IF NOT EXISTS supacloud_workflows.runs (
|
|
3654
|
+
id uuid PRIMARY KEY,
|
|
3655
|
+
workflow_name text NOT NULL
|
|
3656
|
+
CHECK (workflow_name ~ '^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$'),
|
|
3657
|
+
workflow_version text NOT NULL
|
|
3658
|
+
CHECK (char_length(workflow_version) BETWEEN 1 AND 80),
|
|
3659
|
+
status text NOT NULL DEFAULT 'queued'
|
|
3660
|
+
CHECK (status IN ('queued', 'running', 'completed', 'failed', 'cancelled')),
|
|
3661
|
+
input jsonb NOT NULL DEFAULT '{}'::jsonb
|
|
3662
|
+
CHECK (jsonb_typeof(input) = 'object'),
|
|
3663
|
+
output jsonb NOT NULL DEFAULT '{}'::jsonb
|
|
3664
|
+
CHECK (jsonb_typeof(output) = 'object'),
|
|
3665
|
+
error_message text NOT NULL DEFAULT ''
|
|
3666
|
+
CHECK (char_length(error_message) <= 4000),
|
|
3667
|
+
row_version bigint NOT NULL DEFAULT 1 CHECK (row_version > 0),
|
|
3668
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
3669
|
+
started_at timestamptz,
|
|
3670
|
+
completed_at timestamptz,
|
|
3671
|
+
updated_at timestamptz NOT NULL DEFAULT now()
|
|
3672
|
+
);
|
|
3673
|
+
|
|
3674
|
+
CREATE TABLE IF NOT EXISTS supacloud_workflows.steps (
|
|
3675
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
3676
|
+
run_id uuid NOT NULL REFERENCES supacloud_workflows.runs(id) ON DELETE CASCADE,
|
|
3677
|
+
step_key text NOT NULL
|
|
3678
|
+
CHECK (step_key ~ '^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$'),
|
|
3679
|
+
status text NOT NULL DEFAULT 'queued'
|
|
3680
|
+
CHECK (status IN ('queued', 'running', 'completed', 'failed', 'dead_lettered', 'cancelled')),
|
|
3681
|
+
input jsonb NOT NULL DEFAULT '{}'::jsonb
|
|
3682
|
+
CHECK (jsonb_typeof(input) = 'object'),
|
|
3683
|
+
output jsonb NOT NULL DEFAULT '{}'::jsonb
|
|
3684
|
+
CHECK (jsonb_typeof(output) = 'object'),
|
|
3685
|
+
error_message text NOT NULL DEFAULT ''
|
|
3686
|
+
CHECK (char_length(error_message) <= 4000),
|
|
3687
|
+
attempts integer NOT NULL DEFAULT 0 CHECK (attempts >= 0),
|
|
3688
|
+
max_attempts integer NOT NULL DEFAULT 3 CHECK (max_attempts BETWEEN 1 AND 100),
|
|
3689
|
+
retry_delay_seconds integer NOT NULL DEFAULT 0
|
|
3690
|
+
CHECK (retry_delay_seconds BETWEEN 0 AND 86400),
|
|
3691
|
+
queue_message_id bigint NOT NULL UNIQUE,
|
|
3692
|
+
claimed_by text,
|
|
3693
|
+
claimed_at timestamptz,
|
|
3694
|
+
completed_at timestamptz,
|
|
3695
|
+
next_step_key text,
|
|
3696
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
3697
|
+
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
3698
|
+
UNIQUE (run_id, step_key)
|
|
3699
|
+
);
|
|
3700
|
+
|
|
3701
|
+
ALTER TABLE supacloud_workflows.steps
|
|
3702
|
+
ADD COLUMN IF NOT EXISTS retry_delay_seconds integer NOT NULL DEFAULT 0
|
|
3703
|
+
CHECK (retry_delay_seconds BETWEEN 0 AND 86400);
|
|
3704
|
+
|
|
3705
|
+
CREATE UNIQUE INDEX IF NOT EXISTS supacloud_workflows_one_active_step_idx
|
|
3706
|
+
ON supacloud_workflows.steps (run_id)
|
|
3707
|
+
WHERE status IN ('queued', 'running');
|
|
3708
|
+
|
|
3709
|
+
CREATE INDEX IF NOT EXISTS supacloud_workflows_runs_status_idx
|
|
3710
|
+
ON supacloud_workflows.runs (status, updated_at DESC, id);
|
|
3711
|
+
|
|
3712
|
+
CREATE INDEX IF NOT EXISTS supacloud_workflows_steps_run_idx
|
|
3713
|
+
ON supacloud_workflows.steps (run_id, created_at, id);
|
|
3714
|
+
|
|
3715
|
+
CREATE TABLE IF NOT EXISTS supacloud_workflows.events (
|
|
3716
|
+
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
3717
|
+
run_id uuid NOT NULL REFERENCES supacloud_workflows.runs(id) ON DELETE CASCADE,
|
|
3718
|
+
step_id uuid REFERENCES supacloud_workflows.steps(id) ON DELETE CASCADE,
|
|
3719
|
+
event_type text NOT NULL
|
|
3720
|
+
CHECK (event_type IN (
|
|
3721
|
+
'run_started', 'step_claimed', 'step_retried', 'step_completed',
|
|
3722
|
+
'step_failed', 'step_dead_lettered', 'run_completed', 'run_cancelled'
|
|
3723
|
+
)),
|
|
3724
|
+
attempt integer CHECK (attempt IS NULL OR attempt > 0),
|
|
3725
|
+
details jsonb NOT NULL DEFAULT '{}'::jsonb
|
|
3726
|
+
CHECK (jsonb_typeof(details) = 'object'),
|
|
3727
|
+
created_at timestamptz NOT NULL DEFAULT now()
|
|
3728
|
+
);
|
|
3729
|
+
|
|
3730
|
+
CREATE INDEX IF NOT EXISTS supacloud_workflows_events_run_idx
|
|
3731
|
+
ON supacloud_workflows.events (run_id, id);
|
|
3732
|
+
|
|
3733
|
+
CREATE UNIQUE INDEX IF NOT EXISTS supacloud_workflows_retry_receipt_idx
|
|
3734
|
+
ON supacloud_workflows.events (step_id, attempt)
|
|
3735
|
+
WHERE event_type IN ('step_retried', 'step_dead_lettered')
|
|
3736
|
+
AND details ->> 'operation' = 'retry';
|
|
3737
|
+
|
|
3738
|
+
CREATE OR REPLACE FUNCTION supacloud_workflows.snapshot(
|
|
3739
|
+
p_run_id uuid,
|
|
3740
|
+
p_idempotent boolean DEFAULT false
|
|
3741
|
+
) RETURNS jsonb
|
|
3742
|
+
LANGUAGE sql STABLE SECURITY DEFINER SET search_path = '' AS $$
|
|
3743
|
+
SELECT jsonb_build_object(
|
|
3744
|
+
'runId', run.id,
|
|
3745
|
+
'workflowName', run.workflow_name,
|
|
3746
|
+
'workflowVersion', run.workflow_version,
|
|
3747
|
+
'status', run.status,
|
|
3748
|
+
'input', run.input,
|
|
3749
|
+
'output', run.output,
|
|
3750
|
+
'errorMessage', run.error_message,
|
|
3751
|
+
'rowVersion', run.row_version::text,
|
|
3752
|
+
'createdAt', run.created_at,
|
|
3753
|
+
'startedAt', run.started_at,
|
|
3754
|
+
'completedAt', run.completed_at,
|
|
3755
|
+
'updatedAt', run.updated_at,
|
|
3756
|
+
'idempotent', p_idempotent,
|
|
3757
|
+
'steps', coalesce((
|
|
3758
|
+
SELECT jsonb_agg(jsonb_build_object(
|
|
3759
|
+
'stepId', step.id,
|
|
3760
|
+
'stepKey', step.step_key,
|
|
3761
|
+
'status', step.status,
|
|
3762
|
+
'input', step.input,
|
|
3763
|
+
'output', step.output,
|
|
3764
|
+
'errorMessage', step.error_message,
|
|
3765
|
+
'attempts', step.attempts,
|
|
3766
|
+
'maxAttempts', step.max_attempts,
|
|
3767
|
+
'retryDelaySeconds', step.retry_delay_seconds,
|
|
3768
|
+
'queueMessageId', step.queue_message_id::text,
|
|
3769
|
+
'claimedBy', step.claimed_by,
|
|
3770
|
+
'claimedAt', step.claimed_at,
|
|
3771
|
+
'completedAt', step.completed_at,
|
|
3772
|
+
'nextStepKey', step.next_step_key,
|
|
3773
|
+
'createdAt', step.created_at,
|
|
3774
|
+
'updatedAt', step.updated_at
|
|
3775
|
+
) ORDER BY step.created_at, step.id)
|
|
3776
|
+
FROM supacloud_workflows.steps step
|
|
3777
|
+
WHERE step.run_id = run.id
|
|
3778
|
+
), '[]'::jsonb)
|
|
3779
|
+
)
|
|
3780
|
+
FROM supacloud_workflows.runs run
|
|
3781
|
+
WHERE run.id = p_run_id
|
|
3782
|
+
$$;
|
|
3783
|
+
|
|
3784
|
+
CREATE OR REPLACE FUNCTION supacloud_workflows.enqueue_step(
|
|
3785
|
+
p_run_id uuid,
|
|
3786
|
+
p_step_key text,
|
|
3787
|
+
p_input jsonb,
|
|
3788
|
+
p_max_attempts integer
|
|
3789
|
+
) RETURNS supacloud_workflows.steps
|
|
3790
|
+
LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
|
|
3791
|
+
DECLARE
|
|
3792
|
+
normalized_step_key text := nullif(btrim(p_step_key), '');
|
|
3793
|
+
step_id uuid := gen_random_uuid();
|
|
3794
|
+
message_id bigint;
|
|
3795
|
+
created_step supacloud_workflows.steps%ROWTYPE;
|
|
3796
|
+
BEGIN
|
|
3797
|
+
IF normalized_step_key IS NULL
|
|
3798
|
+
OR normalized_step_key !~ '^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$'
|
|
3799
|
+
OR jsonb_typeof(p_input) IS DISTINCT FROM 'object'
|
|
3800
|
+
OR p_max_attempts NOT BETWEEN 1 AND 100 THEN
|
|
3801
|
+
RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_STEP_INVALID' USING ERRCODE = '22023';
|
|
3802
|
+
END IF;
|
|
3803
|
+
|
|
3804
|
+
IF NOT EXISTS (
|
|
3805
|
+
SELECT 1 FROM supacloud_workflows.runs run
|
|
3806
|
+
WHERE run.id = p_run_id AND run.status IN ('queued', 'running')
|
|
3807
|
+
) THEN
|
|
3808
|
+
RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_RUN_NOT_ACTIVE' USING ERRCODE = '55000';
|
|
3809
|
+
END IF;
|
|
3810
|
+
|
|
3811
|
+
SELECT queued_id INTO message_id
|
|
3812
|
+
FROM pgmq.send(
|
|
3813
|
+
'supacloud_internal_workflows',
|
|
3814
|
+
jsonb_build_object('run_id', p_run_id, 'step_id', step_id),
|
|
3815
|
+
0
|
|
3816
|
+
) AS queued_id;
|
|
3817
|
+
|
|
3818
|
+
INSERT INTO supacloud_workflows.steps (
|
|
3819
|
+
id, run_id, step_key, input, max_attempts, queue_message_id
|
|
3820
|
+
) VALUES (
|
|
3821
|
+
step_id, p_run_id, normalized_step_key, p_input, p_max_attempts, message_id
|
|
3822
|
+
) RETURNING * INTO created_step;
|
|
3823
|
+
|
|
3824
|
+
RETURN created_step;
|
|
3825
|
+
END;
|
|
3826
|
+
$$;
|
|
3827
|
+
|
|
3828
|
+
-- Clean-code exception: private transitions keep typed PostgreSQL arguments and
|
|
3829
|
+
-- the complete lock/queue/ledger/event mutation in one transaction. The public
|
|
3830
|
+
-- contract already uses one JSON request; revisit if a private routine gains a
|
|
3831
|
+
-- second caller or any transition can be decomposed without weakening atomicity.
|
|
3832
|
+
CREATE OR REPLACE FUNCTION supacloud_workflows.start_run(
|
|
3833
|
+
p_run_id uuid,
|
|
3834
|
+
p_workflow_name text,
|
|
3835
|
+
p_workflow_version text,
|
|
3836
|
+
p_first_step_key text,
|
|
3837
|
+
p_input jsonb,
|
|
3838
|
+
p_max_attempts integer
|
|
3839
|
+
) RETURNS jsonb
|
|
3840
|
+
LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
|
|
3841
|
+
DECLARE
|
|
3842
|
+
normalized_name text := nullif(btrim(p_workflow_name), '');
|
|
3843
|
+
normalized_version text := nullif(btrim(p_workflow_version), '');
|
|
3844
|
+
normalized_first_step_key text := nullif(btrim(p_first_step_key), '');
|
|
3845
|
+
existing_run supacloud_workflows.runs%ROWTYPE;
|
|
3846
|
+
existing_step supacloud_workflows.steps%ROWTYPE;
|
|
3847
|
+
first_step supacloud_workflows.steps%ROWTYPE;
|
|
3848
|
+
BEGIN
|
|
3849
|
+
IF p_run_id IS NULL
|
|
3850
|
+
OR normalized_name IS NULL
|
|
3851
|
+
OR normalized_name !~ '^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$'
|
|
3852
|
+
OR normalized_version IS NULL
|
|
3853
|
+
OR char_length(normalized_version) > 80
|
|
3854
|
+
OR normalized_first_step_key IS NULL
|
|
3855
|
+
OR normalized_first_step_key !~ '^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$'
|
|
3856
|
+
OR jsonb_typeof(p_input) IS DISTINCT FROM 'object'
|
|
3857
|
+
OR p_max_attempts NOT BETWEEN 1 AND 100 THEN
|
|
3858
|
+
RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_START_INVALID' USING ERRCODE = '22023';
|
|
3859
|
+
END IF;
|
|
3860
|
+
|
|
3861
|
+
PERFORM pg_advisory_xact_lock(hashtextextended(p_run_id::text, 0));
|
|
3862
|
+
SELECT * INTO existing_run FROM supacloud_workflows.runs WHERE id = p_run_id;
|
|
3863
|
+
IF FOUND THEN
|
|
3864
|
+
SELECT * INTO existing_step
|
|
3865
|
+
FROM supacloud_workflows.steps
|
|
3866
|
+
WHERE run_id = p_run_id
|
|
3867
|
+
ORDER BY created_at, id
|
|
3868
|
+
LIMIT 1;
|
|
3869
|
+
IF NOT FOUND THEN
|
|
3870
|
+
RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_IDEMPOTENCY_CONFLICT' USING ERRCODE = '23505';
|
|
3871
|
+
END IF;
|
|
3872
|
+
IF existing_run.workflow_name <> normalized_name
|
|
3873
|
+
OR existing_run.workflow_version <> normalized_version
|
|
3874
|
+
OR existing_run.input <> p_input
|
|
3875
|
+
OR existing_step.step_key <> normalized_first_step_key
|
|
3876
|
+
OR existing_step.input <> p_input
|
|
3877
|
+
OR existing_step.max_attempts <> p_max_attempts THEN
|
|
3878
|
+
RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_IDEMPOTENCY_CONFLICT' USING ERRCODE = '23505';
|
|
3879
|
+
END IF;
|
|
3880
|
+
RETURN supacloud_workflows.snapshot(p_run_id, true);
|
|
3881
|
+
END IF;
|
|
3882
|
+
|
|
3883
|
+
INSERT INTO supacloud_workflows.runs (
|
|
3884
|
+
id, workflow_name, workflow_version, input
|
|
3885
|
+
) VALUES (
|
|
3886
|
+
p_run_id, normalized_name, normalized_version, p_input
|
|
3887
|
+
);
|
|
3888
|
+
first_step := supacloud_workflows.enqueue_step(
|
|
3889
|
+
p_run_id, normalized_first_step_key, p_input, p_max_attempts
|
|
3890
|
+
);
|
|
3891
|
+
INSERT INTO supacloud_workflows.events (run_id, step_id, event_type)
|
|
3892
|
+
VALUES (p_run_id, first_step.id, 'run_started');
|
|
3893
|
+
RETURN supacloud_workflows.snapshot(p_run_id, false);
|
|
3894
|
+
END;
|
|
3895
|
+
$$;
|
|
3896
|
+
|
|
3897
|
+
CREATE OR REPLACE FUNCTION supacloud_workflows.claim_step(
|
|
3898
|
+
p_worker_id text,
|
|
3899
|
+
p_visibility_timeout_seconds integer
|
|
3900
|
+
) RETURNS jsonb
|
|
3901
|
+
LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
|
|
3902
|
+
DECLARE
|
|
3903
|
+
normalized_worker_id text := nullif(btrim(p_worker_id), '');
|
|
3904
|
+
queued_message pgmq.message_record;
|
|
3905
|
+
message_run_id text;
|
|
3906
|
+
message_step_id text;
|
|
3907
|
+
candidate_run_id uuid;
|
|
3908
|
+
claimed_step supacloud_workflows.steps%ROWTYPE;
|
|
3909
|
+
claimed_run supacloud_workflows.runs%ROWTYPE;
|
|
3910
|
+
BEGIN
|
|
3911
|
+
IF normalized_worker_id IS NULL
|
|
3912
|
+
OR char_length(normalized_worker_id) > 200
|
|
3913
|
+
OR p_visibility_timeout_seconds NOT BETWEEN 15 AND 3600 THEN
|
|
3914
|
+
RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_CLAIM_INVALID' USING ERRCODE = '22023';
|
|
3915
|
+
END IF;
|
|
3916
|
+
|
|
3917
|
+
SELECT * INTO queued_message
|
|
3918
|
+
FROM pgmq.read('supacloud_internal_workflows', p_visibility_timeout_seconds, 1)
|
|
3919
|
+
LIMIT 1;
|
|
3920
|
+
IF NOT FOUND THEN RETURN NULL; END IF;
|
|
3921
|
+
|
|
3922
|
+
message_run_id := queued_message.message ->> 'run_id';
|
|
3923
|
+
message_step_id := queued_message.message ->> 'step_id';
|
|
3924
|
+
IF jsonb_typeof(queued_message.message) IS DISTINCT FROM 'object'
|
|
3925
|
+
OR message_run_id IS NULL
|
|
3926
|
+
OR message_step_id IS NULL
|
|
3927
|
+
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}$'
|
|
3928
|
+
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
|
|
3929
|
+
PERFORM pgmq.archive('supacloud_internal_workflows', queued_message.msg_id);
|
|
3930
|
+
RETURN jsonb_build_object(
|
|
3931
|
+
'status', 'discarded',
|
|
3932
|
+
'reason', 'invalid_message',
|
|
3933
|
+
'messageId', queued_message.msg_id::text
|
|
3934
|
+
);
|
|
3935
|
+
END IF;
|
|
3936
|
+
|
|
3937
|
+
SELECT step.run_id INTO candidate_run_id
|
|
3938
|
+
FROM supacloud_workflows.steps step
|
|
3939
|
+
WHERE step.id::text = lower(message_step_id)
|
|
3940
|
+
AND step.run_id::text = lower(message_run_id)
|
|
3941
|
+
AND step.queue_message_id = queued_message.msg_id;
|
|
3942
|
+
IF NOT FOUND THEN
|
|
3943
|
+
PERFORM pgmq.archive('supacloud_internal_workflows', queued_message.msg_id);
|
|
3944
|
+
RETURN jsonb_build_object(
|
|
3945
|
+
'status', 'discarded',
|
|
3946
|
+
'reason', 'orphaned_message',
|
|
3947
|
+
'messageId', queued_message.msg_id::text
|
|
3948
|
+
);
|
|
3949
|
+
END IF;
|
|
3950
|
+
|
|
3951
|
+
IF NOT pg_try_advisory_xact_lock(hashtextextended(candidate_run_id::text, 0)) THEN
|
|
3952
|
+
RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_CLAIM_RETRY' USING ERRCODE = '40001';
|
|
3953
|
+
END IF;
|
|
3954
|
+
SELECT * INTO claimed_step
|
|
3955
|
+
FROM supacloud_workflows.steps
|
|
3956
|
+
WHERE id::text = lower(message_step_id)
|
|
3957
|
+
AND run_id = candidate_run_id
|
|
3958
|
+
AND queue_message_id = queued_message.msg_id
|
|
3959
|
+
FOR UPDATE;
|
|
3960
|
+
IF NOT FOUND THEN
|
|
3961
|
+
PERFORM pgmq.archive('supacloud_internal_workflows', queued_message.msg_id);
|
|
3962
|
+
RETURN jsonb_build_object(
|
|
3963
|
+
'status', 'discarded',
|
|
3964
|
+
'reason', 'orphaned_message',
|
|
3965
|
+
'messageId', queued_message.msg_id::text
|
|
3966
|
+
);
|
|
3967
|
+
END IF;
|
|
3968
|
+
|
|
3969
|
+
SELECT * INTO claimed_run
|
|
3970
|
+
FROM supacloud_workflows.runs
|
|
3971
|
+
WHERE id = claimed_step.run_id
|
|
3972
|
+
FOR UPDATE;
|
|
3973
|
+
IF claimed_run.status NOT IN ('queued', 'running')
|
|
3974
|
+
OR claimed_step.status NOT IN ('queued', 'running') THEN
|
|
3975
|
+
PERFORM pgmq.archive('supacloud_internal_workflows', queued_message.msg_id);
|
|
3976
|
+
RETURN jsonb_build_object(
|
|
3977
|
+
'status', 'discarded',
|
|
3978
|
+
'reason', 'step_not_claimable',
|
|
3979
|
+
'runId', claimed_step.run_id,
|
|
3980
|
+
'stepId', claimed_step.id,
|
|
3981
|
+
'messageId', queued_message.msg_id::text
|
|
3982
|
+
);
|
|
3983
|
+
END IF;
|
|
3984
|
+
|
|
3985
|
+
IF queued_message.read_ct > claimed_step.max_attempts THEN
|
|
3986
|
+
UPDATE supacloud_workflows.steps
|
|
3987
|
+
SET status = 'dead_lettered', attempts = queued_message.read_ct,
|
|
3988
|
+
error_message = 'maximum attempts exceeded', completed_at = now(), updated_at = now()
|
|
3989
|
+
WHERE id = claimed_step.id;
|
|
3990
|
+
UPDATE supacloud_workflows.runs
|
|
3991
|
+
SET status = 'failed', error_message = 'maximum attempts exceeded',
|
|
3992
|
+
completed_at = now(), updated_at = now(), row_version = row_version + 1
|
|
3993
|
+
WHERE id = claimed_step.run_id;
|
|
3994
|
+
PERFORM pgmq.archive('supacloud_internal_workflows', queued_message.msg_id);
|
|
3995
|
+
INSERT INTO supacloud_workflows.events (
|
|
3996
|
+
run_id, step_id, event_type, attempt, details
|
|
3997
|
+
) VALUES (
|
|
3998
|
+
claimed_step.run_id, claimed_step.id, 'step_dead_lettered', queued_message.read_ct,
|
|
3999
|
+
jsonb_build_object('errorMessage', 'maximum attempts exceeded')
|
|
4000
|
+
);
|
|
4001
|
+
RETURN jsonb_build_object(
|
|
4002
|
+
'status', 'dead_lettered',
|
|
4003
|
+
'runId', claimed_step.run_id,
|
|
4004
|
+
'stepId', claimed_step.id,
|
|
4005
|
+
'stepKey', claimed_step.step_key,
|
|
4006
|
+
'messageId', queued_message.msg_id::text,
|
|
4007
|
+
'attempt', queued_message.read_ct,
|
|
4008
|
+
'maxAttempts', claimed_step.max_attempts
|
|
4009
|
+
);
|
|
4010
|
+
END IF;
|
|
4011
|
+
|
|
4012
|
+
UPDATE supacloud_workflows.steps
|
|
4013
|
+
SET status = 'running', attempts = queued_message.read_ct,
|
|
4014
|
+
retry_delay_seconds = 0, claimed_by = normalized_worker_id,
|
|
4015
|
+
claimed_at = now(), updated_at = now()
|
|
4016
|
+
WHERE id = claimed_step.id;
|
|
4017
|
+
UPDATE supacloud_workflows.runs
|
|
4018
|
+
SET status = 'running', started_at = coalesce(started_at, now()),
|
|
4019
|
+
updated_at = now(), row_version = row_version + 1
|
|
4020
|
+
WHERE id = claimed_step.run_id;
|
|
4021
|
+
INSERT INTO supacloud_workflows.events (run_id, step_id, event_type, attempt, details)
|
|
4022
|
+
VALUES (
|
|
4023
|
+
claimed_step.run_id, claimed_step.id, 'step_claimed', queued_message.read_ct,
|
|
4024
|
+
jsonb_build_object('workerId', normalized_worker_id)
|
|
4025
|
+
);
|
|
4026
|
+
|
|
4027
|
+
RETURN jsonb_build_object(
|
|
4028
|
+
'status', 'claimed',
|
|
4029
|
+
'runId', claimed_step.run_id,
|
|
4030
|
+
'workflowName', claimed_run.workflow_name,
|
|
4031
|
+
'workflowVersion', claimed_run.workflow_version,
|
|
4032
|
+
'stepId', claimed_step.id,
|
|
4033
|
+
'stepKey', claimed_step.step_key,
|
|
4034
|
+
'input', claimed_step.input,
|
|
4035
|
+
'messageId', queued_message.msg_id::text,
|
|
4036
|
+
'attempt', queued_message.read_ct,
|
|
4037
|
+
'maxAttempts', claimed_step.max_attempts,
|
|
4038
|
+
'workerId', normalized_worker_id
|
|
4039
|
+
);
|
|
4040
|
+
END;
|
|
4041
|
+
$$;
|
|
4042
|
+
|
|
4043
|
+
CREATE OR REPLACE FUNCTION supacloud_workflows.lock_step(
|
|
4044
|
+
p_step_id uuid
|
|
4045
|
+
) RETURNS supacloud_workflows.steps
|
|
4046
|
+
LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
|
|
4047
|
+
DECLARE
|
|
4048
|
+
candidate_run_id uuid;
|
|
4049
|
+
active_step supacloud_workflows.steps%ROWTYPE;
|
|
4050
|
+
BEGIN
|
|
4051
|
+
SELECT step.run_id INTO candidate_run_id
|
|
4052
|
+
FROM supacloud_workflows.steps step
|
|
4053
|
+
WHERE step.id = p_step_id;
|
|
4054
|
+
IF NOT FOUND THEN
|
|
4055
|
+
RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_STEP_NOT_FOUND' USING ERRCODE = 'P0002';
|
|
4056
|
+
END IF;
|
|
4057
|
+
PERFORM pg_advisory_xact_lock(hashtextextended(candidate_run_id::text, 0));
|
|
4058
|
+
SELECT * INTO active_step
|
|
4059
|
+
FROM supacloud_workflows.steps
|
|
4060
|
+
WHERE id = p_step_id
|
|
4061
|
+
FOR UPDATE;
|
|
4062
|
+
IF NOT FOUND THEN
|
|
4063
|
+
RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_STEP_NOT_FOUND' USING ERRCODE = 'P0002';
|
|
4064
|
+
END IF;
|
|
4065
|
+
RETURN active_step;
|
|
4066
|
+
END;
|
|
4067
|
+
$$;
|
|
4068
|
+
|
|
4069
|
+
CREATE OR REPLACE FUNCTION supacloud_workflows.lock_step_attempt(
|
|
4070
|
+
p_step_id uuid,
|
|
4071
|
+
p_message_id bigint,
|
|
4072
|
+
p_attempt integer,
|
|
4073
|
+
p_worker_id text
|
|
4074
|
+
) RETURNS supacloud_workflows.steps
|
|
4075
|
+
LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
|
|
4076
|
+
DECLARE
|
|
4077
|
+
active_step supacloud_workflows.steps%ROWTYPE;
|
|
4078
|
+
normalized_worker_id text := nullif(btrim(p_worker_id), '');
|
|
4079
|
+
BEGIN
|
|
4080
|
+
active_step := supacloud_workflows.lock_step(p_step_id);
|
|
4081
|
+
IF normalized_worker_id IS NULL
|
|
4082
|
+
OR p_message_id IS NULL
|
|
4083
|
+
OR p_attempt IS NULL
|
|
4084
|
+
OR active_step.queue_message_id IS DISTINCT FROM p_message_id
|
|
4085
|
+
OR active_step.attempts IS DISTINCT FROM p_attempt
|
|
4086
|
+
OR active_step.claimed_by IS DISTINCT FROM normalized_worker_id THEN
|
|
4087
|
+
RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_STALE_ATTEMPT' USING ERRCODE = '40001';
|
|
4088
|
+
END IF;
|
|
4089
|
+
RETURN active_step;
|
|
4090
|
+
END;
|
|
4091
|
+
$$;
|
|
4092
|
+
|
|
4093
|
+
CREATE OR REPLACE FUNCTION supacloud_workflows.advance_step(
|
|
4094
|
+
p_step_id uuid,
|
|
4095
|
+
p_message_id bigint,
|
|
4096
|
+
p_attempt integer,
|
|
4097
|
+
p_worker_id text,
|
|
4098
|
+
p_output jsonb,
|
|
4099
|
+
p_next_step_key text,
|
|
4100
|
+
p_next_input jsonb,
|
|
4101
|
+
p_next_max_attempts integer
|
|
4102
|
+
) RETURNS jsonb
|
|
4103
|
+
LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
|
|
4104
|
+
DECLARE
|
|
4105
|
+
current_step supacloud_workflows.steps%ROWTYPE;
|
|
4106
|
+
next_step supacloud_workflows.steps%ROWTYPE;
|
|
4107
|
+
normalized_next_step_key text := nullif(btrim(p_next_step_key), '');
|
|
4108
|
+
archived boolean;
|
|
4109
|
+
BEGIN
|
|
4110
|
+
IF jsonb_typeof(p_output) IS DISTINCT FROM 'object'
|
|
4111
|
+
OR jsonb_typeof(p_next_input) IS DISTINCT FROM 'object'
|
|
4112
|
+
OR normalized_next_step_key IS NULL
|
|
4113
|
+
OR normalized_next_step_key !~ '^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$'
|
|
4114
|
+
OR p_next_max_attempts NOT BETWEEN 1 AND 100 THEN
|
|
4115
|
+
RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_ADVANCE_INVALID' USING ERRCODE = '22023';
|
|
4116
|
+
END IF;
|
|
4117
|
+
current_step := supacloud_workflows.lock_step_attempt(
|
|
4118
|
+
p_step_id, p_message_id, p_attempt, p_worker_id
|
|
4119
|
+
);
|
|
4120
|
+
|
|
4121
|
+
IF current_step.status = 'completed' THEN
|
|
4122
|
+
SELECT * INTO next_step
|
|
4123
|
+
FROM supacloud_workflows.steps
|
|
4124
|
+
WHERE run_id = current_step.run_id AND step_key = normalized_next_step_key;
|
|
4125
|
+
IF NOT FOUND
|
|
4126
|
+
OR current_step.output IS DISTINCT FROM p_output
|
|
4127
|
+
OR current_step.next_step_key IS DISTINCT FROM normalized_next_step_key
|
|
4128
|
+
OR next_step.input IS DISTINCT FROM p_next_input
|
|
4129
|
+
OR next_step.max_attempts IS DISTINCT FROM p_next_max_attempts THEN
|
|
4130
|
+
RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_IDEMPOTENCY_CONFLICT' USING ERRCODE = '23505';
|
|
4131
|
+
END IF;
|
|
4132
|
+
RETURN supacloud_workflows.snapshot(current_step.run_id, true);
|
|
4133
|
+
END IF;
|
|
4134
|
+
IF current_step.status <> 'running' THEN
|
|
4135
|
+
RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_STALE_ATTEMPT' USING ERRCODE = '40001';
|
|
4136
|
+
END IF;
|
|
4137
|
+
|
|
4138
|
+
SELECT pgmq.archive('supacloud_internal_workflows', p_message_id) INTO archived;
|
|
4139
|
+
IF archived IS DISTINCT FROM true THEN
|
|
4140
|
+
RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_QUEUE_MESSAGE_MISSING' USING ERRCODE = '55000';
|
|
4141
|
+
END IF;
|
|
4142
|
+
UPDATE supacloud_workflows.steps
|
|
4143
|
+
SET status = 'completed', output = p_output, error_message = '',
|
|
4144
|
+
completed_at = now(), next_step_key = normalized_next_step_key, updated_at = now()
|
|
4145
|
+
WHERE id = current_step.id;
|
|
4146
|
+
INSERT INTO supacloud_workflows.events (
|
|
4147
|
+
run_id, step_id, event_type, attempt, details
|
|
4148
|
+
) VALUES (
|
|
4149
|
+
current_step.run_id, current_step.id, 'step_completed', p_attempt,
|
|
4150
|
+
jsonb_build_object('nextStepKey', normalized_next_step_key)
|
|
4151
|
+
);
|
|
4152
|
+
next_step := supacloud_workflows.enqueue_step(
|
|
4153
|
+
current_step.run_id, normalized_next_step_key, p_next_input, p_next_max_attempts
|
|
4154
|
+
);
|
|
4155
|
+
RETURN supacloud_workflows.snapshot(current_step.run_id, false);
|
|
4156
|
+
END;
|
|
4157
|
+
$$;
|
|
4158
|
+
|
|
4159
|
+
CREATE OR REPLACE FUNCTION supacloud_workflows.complete_run(
|
|
4160
|
+
p_step_id uuid,
|
|
4161
|
+
p_message_id bigint,
|
|
4162
|
+
p_attempt integer,
|
|
4163
|
+
p_worker_id text,
|
|
4164
|
+
p_step_output jsonb,
|
|
4165
|
+
p_run_output jsonb
|
|
4166
|
+
) RETURNS jsonb
|
|
4167
|
+
LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
|
|
4168
|
+
DECLARE
|
|
4169
|
+
current_step supacloud_workflows.steps%ROWTYPE;
|
|
4170
|
+
current_run supacloud_workflows.runs%ROWTYPE;
|
|
4171
|
+
archived boolean;
|
|
4172
|
+
BEGIN
|
|
4173
|
+
IF jsonb_typeof(p_step_output) IS DISTINCT FROM 'object'
|
|
4174
|
+
OR jsonb_typeof(p_run_output) IS DISTINCT FROM 'object' THEN
|
|
4175
|
+
RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_COMPLETION_INVALID' USING ERRCODE = '22023';
|
|
4176
|
+
END IF;
|
|
4177
|
+
current_step := supacloud_workflows.lock_step_attempt(
|
|
4178
|
+
p_step_id, p_message_id, p_attempt, p_worker_id
|
|
4179
|
+
);
|
|
4180
|
+
SELECT * INTO current_run
|
|
4181
|
+
FROM supacloud_workflows.runs
|
|
4182
|
+
WHERE id = current_step.run_id
|
|
4183
|
+
FOR UPDATE;
|
|
4184
|
+
|
|
4185
|
+
IF current_step.status = 'completed' THEN
|
|
4186
|
+
IF current_step.next_step_key IS NOT NULL
|
|
4187
|
+
OR current_step.output IS DISTINCT FROM p_step_output
|
|
4188
|
+
OR current_run.status IS DISTINCT FROM 'completed'
|
|
4189
|
+
OR current_run.output IS DISTINCT FROM p_run_output THEN
|
|
4190
|
+
RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_IDEMPOTENCY_CONFLICT' USING ERRCODE = '23505';
|
|
4191
|
+
END IF;
|
|
4192
|
+
RETURN supacloud_workflows.snapshot(current_step.run_id, true);
|
|
4193
|
+
END IF;
|
|
4194
|
+
IF current_step.status <> 'running' THEN
|
|
4195
|
+
RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_STALE_ATTEMPT' USING ERRCODE = '40001';
|
|
4196
|
+
END IF;
|
|
4197
|
+
IF current_run.status <> 'running' THEN
|
|
4198
|
+
RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_RUN_NOT_ACTIVE' USING ERRCODE = '55000';
|
|
4199
|
+
END IF;
|
|
4200
|
+
|
|
4201
|
+
SELECT pgmq.archive('supacloud_internal_workflows', p_message_id) INTO archived;
|
|
4202
|
+
IF archived IS DISTINCT FROM true THEN
|
|
4203
|
+
RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_QUEUE_MESSAGE_MISSING' USING ERRCODE = '55000';
|
|
4204
|
+
END IF;
|
|
4205
|
+
UPDATE supacloud_workflows.steps
|
|
4206
|
+
SET status = 'completed', output = p_step_output, error_message = '',
|
|
4207
|
+
completed_at = now(), updated_at = now()
|
|
4208
|
+
WHERE id = current_step.id;
|
|
4209
|
+
UPDATE supacloud_workflows.runs
|
|
4210
|
+
SET status = 'completed', output = p_run_output, error_message = '',
|
|
4211
|
+
completed_at = now(), updated_at = now(), row_version = row_version + 1
|
|
4212
|
+
WHERE id = current_step.run_id AND status = 'running';
|
|
4213
|
+
INSERT INTO supacloud_workflows.events (run_id, step_id, event_type, attempt)
|
|
4214
|
+
VALUES (current_step.run_id, current_step.id, 'step_completed', p_attempt);
|
|
4215
|
+
INSERT INTO supacloud_workflows.events (run_id, step_id, event_type, attempt)
|
|
4216
|
+
VALUES (current_step.run_id, current_step.id, 'run_completed', p_attempt);
|
|
4217
|
+
RETURN supacloud_workflows.snapshot(current_step.run_id, false);
|
|
4218
|
+
END;
|
|
4219
|
+
$$;
|
|
4220
|
+
|
|
4221
|
+
CREATE OR REPLACE FUNCTION supacloud_workflows.retry_step(
|
|
4222
|
+
p_step_id uuid,
|
|
4223
|
+
p_message_id bigint,
|
|
4224
|
+
p_attempt integer,
|
|
4225
|
+
p_worker_id text,
|
|
4226
|
+
p_error_message text,
|
|
4227
|
+
p_delay_seconds integer
|
|
4228
|
+
) RETURNS jsonb
|
|
4229
|
+
LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
|
|
4230
|
+
DECLARE
|
|
4231
|
+
current_step supacloud_workflows.steps%ROWTYPE;
|
|
4232
|
+
normalized_error text := nullif(btrim(p_error_message), '');
|
|
4233
|
+
normalized_worker_id text := nullif(btrim(p_worker_id), '');
|
|
4234
|
+
retry_receipt jsonb;
|
|
4235
|
+
queue_message_updated boolean;
|
|
4236
|
+
archived boolean;
|
|
4237
|
+
BEGIN
|
|
4238
|
+
IF normalized_error IS NULL OR char_length(normalized_error) > 4000
|
|
4239
|
+
OR p_delay_seconds NOT BETWEEN 0 AND 86400 THEN
|
|
4240
|
+
RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_RETRY_INVALID' USING ERRCODE = '22023';
|
|
4241
|
+
END IF;
|
|
4242
|
+
current_step := supacloud_workflows.lock_step(p_step_id);
|
|
4243
|
+
SELECT event.details INTO retry_receipt
|
|
4244
|
+
FROM supacloud_workflows.events event
|
|
4245
|
+
WHERE event.step_id = current_step.id
|
|
4246
|
+
AND event.attempt = p_attempt
|
|
4247
|
+
AND event.event_type IN ('step_retried', 'step_dead_lettered')
|
|
4248
|
+
AND event.details ->> 'operation' = 'retry'
|
|
4249
|
+
ORDER BY event.id DESC
|
|
4250
|
+
LIMIT 1;
|
|
4251
|
+
IF FOUND THEN
|
|
4252
|
+
IF retry_receipt ->> 'messageId' IS DISTINCT FROM p_message_id::text
|
|
4253
|
+
OR retry_receipt ->> 'workerId' IS DISTINCT FROM normalized_worker_id
|
|
4254
|
+
OR retry_receipt ->> 'errorMessage' IS DISTINCT FROM normalized_error
|
|
4255
|
+
OR (retry_receipt ->> 'delaySeconds')::integer IS DISTINCT FROM p_delay_seconds THEN
|
|
4256
|
+
RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_IDEMPOTENCY_CONFLICT' USING ERRCODE = '23505';
|
|
4257
|
+
END IF;
|
|
4258
|
+
RETURN supacloud_workflows.snapshot(current_step.run_id, true);
|
|
4259
|
+
END IF;
|
|
4260
|
+
IF normalized_worker_id IS NULL
|
|
4261
|
+
OR p_message_id IS NULL
|
|
4262
|
+
OR p_attempt IS NULL
|
|
4263
|
+
OR current_step.queue_message_id IS DISTINCT FROM p_message_id
|
|
4264
|
+
OR current_step.attempts IS DISTINCT FROM p_attempt
|
|
4265
|
+
OR current_step.claimed_by IS DISTINCT FROM normalized_worker_id
|
|
4266
|
+
OR current_step.status <> 'running' THEN
|
|
4267
|
+
RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_STALE_ATTEMPT' USING ERRCODE = '40001';
|
|
4268
|
+
END IF;
|
|
4269
|
+
|
|
4270
|
+
IF current_step.attempts >= current_step.max_attempts THEN
|
|
4271
|
+
SELECT pgmq.archive('supacloud_internal_workflows', p_message_id) INTO archived;
|
|
4272
|
+
IF archived IS DISTINCT FROM true THEN
|
|
4273
|
+
RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_QUEUE_MESSAGE_MISSING' USING ERRCODE = '55000';
|
|
4274
|
+
END IF;
|
|
4275
|
+
UPDATE supacloud_workflows.steps
|
|
4276
|
+
SET status = 'dead_lettered', error_message = normalized_error,
|
|
4277
|
+
completed_at = now(), updated_at = now()
|
|
4278
|
+
WHERE id = current_step.id;
|
|
4279
|
+
UPDATE supacloud_workflows.runs
|
|
4280
|
+
SET status = 'failed', error_message = normalized_error,
|
|
4281
|
+
completed_at = now(), updated_at = now(), row_version = row_version + 1
|
|
4282
|
+
WHERE id = current_step.run_id AND status = 'running';
|
|
4283
|
+
IF NOT FOUND THEN
|
|
4284
|
+
RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_RUN_NOT_ACTIVE' USING ERRCODE = '55000';
|
|
4285
|
+
END IF;
|
|
4286
|
+
INSERT INTO supacloud_workflows.events (
|
|
4287
|
+
run_id, step_id, event_type, attempt, details
|
|
4288
|
+
) VALUES (
|
|
4289
|
+
current_step.run_id, current_step.id, 'step_dead_lettered', p_attempt,
|
|
4290
|
+
jsonb_build_object(
|
|
4291
|
+
'operation', 'retry',
|
|
4292
|
+
'messageId', p_message_id::text,
|
|
4293
|
+
'workerId', normalized_worker_id,
|
|
4294
|
+
'errorMessage', normalized_error,
|
|
4295
|
+
'delaySeconds', p_delay_seconds
|
|
4296
|
+
)
|
|
4297
|
+
);
|
|
4298
|
+
RETURN supacloud_workflows.snapshot(current_step.run_id, false);
|
|
4299
|
+
END IF;
|
|
4300
|
+
|
|
4301
|
+
SELECT EXISTS (
|
|
4302
|
+
SELECT 1 FROM pgmq.set_vt('supacloud_internal_workflows', p_message_id, p_delay_seconds)
|
|
4303
|
+
) INTO queue_message_updated;
|
|
4304
|
+
IF queue_message_updated IS DISTINCT FROM true THEN
|
|
4305
|
+
RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_QUEUE_MESSAGE_MISSING' USING ERRCODE = '55000';
|
|
4306
|
+
END IF;
|
|
4307
|
+
UPDATE supacloud_workflows.steps
|
|
4308
|
+
SET status = 'queued', error_message = normalized_error,
|
|
4309
|
+
retry_delay_seconds = p_delay_seconds, updated_at = now()
|
|
4310
|
+
WHERE id = current_step.id;
|
|
4311
|
+
INSERT INTO supacloud_workflows.events (
|
|
4312
|
+
run_id, step_id, event_type, attempt, details
|
|
4313
|
+
) VALUES (
|
|
4314
|
+
current_step.run_id, current_step.id, 'step_retried', p_attempt,
|
|
4315
|
+
jsonb_build_object(
|
|
4316
|
+
'operation', 'retry',
|
|
4317
|
+
'messageId', p_message_id::text,
|
|
4318
|
+
'workerId', normalized_worker_id,
|
|
4319
|
+
'errorMessage', normalized_error,
|
|
4320
|
+
'delaySeconds', p_delay_seconds
|
|
4321
|
+
)
|
|
4322
|
+
);
|
|
4323
|
+
RETURN supacloud_workflows.snapshot(current_step.run_id, false);
|
|
4324
|
+
END;
|
|
4325
|
+
$$;
|
|
4326
|
+
|
|
4327
|
+
CREATE OR REPLACE FUNCTION supacloud_workflows.fail_step(
|
|
4328
|
+
p_step_id uuid,
|
|
4329
|
+
p_message_id bigint,
|
|
4330
|
+
p_attempt integer,
|
|
4331
|
+
p_worker_id text,
|
|
4332
|
+
p_error_message text
|
|
4333
|
+
) RETURNS jsonb
|
|
4334
|
+
LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
|
|
4335
|
+
DECLARE
|
|
4336
|
+
current_step supacloud_workflows.steps%ROWTYPE;
|
|
4337
|
+
current_run supacloud_workflows.runs%ROWTYPE;
|
|
4338
|
+
normalized_error text := nullif(btrim(p_error_message), '');
|
|
4339
|
+
archived boolean;
|
|
4340
|
+
BEGIN
|
|
4341
|
+
IF normalized_error IS NULL OR char_length(normalized_error) > 4000 THEN
|
|
4342
|
+
RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_FAILURE_INVALID' USING ERRCODE = '22023';
|
|
4343
|
+
END IF;
|
|
4344
|
+
current_step := supacloud_workflows.lock_step_attempt(
|
|
4345
|
+
p_step_id, p_message_id, p_attempt, p_worker_id
|
|
4346
|
+
);
|
|
4347
|
+
SELECT * INTO current_run
|
|
4348
|
+
FROM supacloud_workflows.runs
|
|
4349
|
+
WHERE id = current_step.run_id
|
|
4350
|
+
FOR UPDATE;
|
|
4351
|
+
|
|
4352
|
+
IF current_step.status = 'failed' THEN
|
|
4353
|
+
IF current_step.error_message IS DISTINCT FROM normalized_error
|
|
4354
|
+
OR current_run.status IS DISTINCT FROM 'failed'
|
|
4355
|
+
OR current_run.error_message IS DISTINCT FROM normalized_error THEN
|
|
4356
|
+
RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_IDEMPOTENCY_CONFLICT' USING ERRCODE = '23505';
|
|
4357
|
+
END IF;
|
|
4358
|
+
RETURN supacloud_workflows.snapshot(current_step.run_id, true);
|
|
4359
|
+
END IF;
|
|
4360
|
+
IF current_step.status <> 'running' THEN
|
|
4361
|
+
RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_STALE_ATTEMPT' USING ERRCODE = '40001';
|
|
4362
|
+
END IF;
|
|
4363
|
+
IF current_run.status <> 'running' THEN
|
|
4364
|
+
RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_RUN_NOT_ACTIVE' USING ERRCODE = '55000';
|
|
4365
|
+
END IF;
|
|
4366
|
+
|
|
4367
|
+
SELECT pgmq.archive('supacloud_internal_workflows', p_message_id) INTO archived;
|
|
4368
|
+
IF archived 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 = 'failed', error_message = normalized_error,
|
|
4373
|
+
completed_at = now(), updated_at = now()
|
|
4374
|
+
WHERE id = current_step.id;
|
|
4375
|
+
UPDATE supacloud_workflows.runs
|
|
4376
|
+
SET status = 'failed', error_message = normalized_error,
|
|
4377
|
+
completed_at = now(), updated_at = now(), row_version = row_version + 1
|
|
4378
|
+
WHERE id = current_step.run_id AND status = 'running';
|
|
4379
|
+
INSERT INTO supacloud_workflows.events (
|
|
4380
|
+
run_id, step_id, event_type, attempt, details
|
|
4381
|
+
) VALUES (
|
|
4382
|
+
current_step.run_id, current_step.id, 'step_failed', p_attempt,
|
|
4383
|
+
jsonb_build_object('errorMessage', normalized_error)
|
|
4384
|
+
);
|
|
4385
|
+
RETURN supacloud_workflows.snapshot(current_step.run_id, false);
|
|
4386
|
+
END;
|
|
4387
|
+
$$;
|
|
4388
|
+
|
|
4389
|
+
CREATE OR REPLACE FUNCTION supacloud_workflows.cancel_run(
|
|
4390
|
+
p_run_id uuid,
|
|
4391
|
+
p_reason text
|
|
4392
|
+
) RETURNS jsonb
|
|
4393
|
+
LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
|
|
4394
|
+
DECLARE
|
|
4395
|
+
normalized_reason text := nullif(btrim(p_reason), '');
|
|
4396
|
+
locked_run supacloud_workflows.runs%ROWTYPE;
|
|
4397
|
+
active_step supacloud_workflows.steps%ROWTYPE;
|
|
4398
|
+
BEGIN
|
|
4399
|
+
IF p_run_id IS NULL OR normalized_reason IS NULL OR char_length(normalized_reason) > 4000 THEN
|
|
4400
|
+
RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_CANCEL_INVALID' USING ERRCODE = '22023';
|
|
4401
|
+
END IF;
|
|
4402
|
+
PERFORM pg_advisory_xact_lock(hashtextextended(p_run_id::text, 0));
|
|
4403
|
+
SELECT * INTO locked_run FROM supacloud_workflows.runs WHERE id = p_run_id FOR UPDATE;
|
|
4404
|
+
IF NOT FOUND THEN
|
|
4405
|
+
RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_RUN_NOT_FOUND' USING ERRCODE = 'P0002';
|
|
4406
|
+
END IF;
|
|
4407
|
+
IF locked_run.status = 'cancelled' THEN
|
|
4408
|
+
IF locked_run.error_message IS DISTINCT FROM normalized_reason THEN
|
|
4409
|
+
RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_IDEMPOTENCY_CONFLICT' USING ERRCODE = '23505';
|
|
4410
|
+
END IF;
|
|
4411
|
+
RETURN supacloud_workflows.snapshot(p_run_id, true);
|
|
4412
|
+
END IF;
|
|
4413
|
+
IF locked_run.status NOT IN ('queued', 'running') THEN
|
|
4414
|
+
RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_RUN_NOT_ACTIVE' USING ERRCODE = '55000';
|
|
4415
|
+
END IF;
|
|
4416
|
+
SELECT * INTO active_step
|
|
4417
|
+
FROM supacloud_workflows.steps
|
|
4418
|
+
WHERE run_id = p_run_id AND status IN ('queued', 'running')
|
|
4419
|
+
FOR UPDATE;
|
|
4420
|
+
IF FOUND THEN
|
|
4421
|
+
PERFORM pgmq.archive('supacloud_internal_workflows', active_step.queue_message_id);
|
|
4422
|
+
UPDATE supacloud_workflows.steps
|
|
4423
|
+
SET status = 'cancelled', error_message = normalized_reason,
|
|
4424
|
+
completed_at = now(), updated_at = now()
|
|
4425
|
+
WHERE id = active_step.id;
|
|
4426
|
+
END IF;
|
|
4427
|
+
UPDATE supacloud_workflows.runs
|
|
4428
|
+
SET status = 'cancelled', error_message = normalized_reason,
|
|
4429
|
+
completed_at = now(), updated_at = now(), row_version = row_version + 1
|
|
4430
|
+
WHERE id = p_run_id;
|
|
4431
|
+
INSERT INTO supacloud_workflows.events (
|
|
4432
|
+
run_id, step_id, event_type, details
|
|
4433
|
+
) VALUES (
|
|
4434
|
+
p_run_id, active_step.id, 'run_cancelled',
|
|
4435
|
+
jsonb_build_object('reason', normalized_reason)
|
|
4436
|
+
);
|
|
4437
|
+
RETURN supacloud_workflows.snapshot(p_run_id, false);
|
|
4438
|
+
END;
|
|
4439
|
+
$$;
|
|
4440
|
+
|
|
4441
|
+
CREATE OR REPLACE FUNCTION supacloud_workflows.run_events(
|
|
4442
|
+
p_run_id uuid,
|
|
4443
|
+
p_after_event_id bigint,
|
|
4444
|
+
p_limit integer
|
|
4445
|
+
) RETURNS jsonb
|
|
4446
|
+
LANGUAGE sql STABLE SECURITY DEFINER SET search_path = '' AS $$
|
|
4447
|
+
SELECT coalesce(jsonb_agg(jsonb_build_object(
|
|
4448
|
+
'eventId', page.id::text,
|
|
4449
|
+
'runId', page.run_id,
|
|
4450
|
+
'stepId', page.step_id,
|
|
4451
|
+
'eventType', page.event_type,
|
|
4452
|
+
'attempt', page.attempt,
|
|
4453
|
+
'details', page.details,
|
|
4454
|
+
'createdAt', page.created_at
|
|
4455
|
+
) ORDER BY page.id), '[]'::jsonb)
|
|
4456
|
+
FROM (
|
|
4457
|
+
SELECT event.*
|
|
4458
|
+
FROM supacloud_workflows.events event
|
|
4459
|
+
WHERE event.run_id = p_run_id AND event.id > p_after_event_id
|
|
4460
|
+
ORDER BY event.id
|
|
4461
|
+
LIMIT p_limit
|
|
4462
|
+
) page
|
|
4463
|
+
$$;
|
|
4464
|
+
|
|
4465
|
+
CREATE OR REPLACE FUNCTION supacloud_workflows.request_uuid(
|
|
4466
|
+
request jsonb,
|
|
4467
|
+
key text
|
|
4468
|
+
) RETURNS uuid
|
|
4469
|
+
LANGUAGE plpgsql IMMUTABLE SET search_path = '' AS $$
|
|
4470
|
+
DECLARE
|
|
4471
|
+
uuid_text text;
|
|
4472
|
+
BEGIN
|
|
4473
|
+
IF jsonb_typeof(request) IS DISTINCT FROM 'object' THEN
|
|
4474
|
+
RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_REQUEST_INVALID' USING ERRCODE = '22023';
|
|
4475
|
+
END IF;
|
|
4476
|
+
uuid_text := request ->> key;
|
|
4477
|
+
IF uuid_text IS NULL
|
|
4478
|
+
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
|
|
4479
|
+
RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_REQUEST_INVALID' USING ERRCODE = '22023';
|
|
4480
|
+
END IF;
|
|
4481
|
+
RETURN uuid_text::uuid;
|
|
4482
|
+
END;
|
|
4483
|
+
$$;
|
|
4484
|
+
|
|
4485
|
+
CREATE OR REPLACE FUNCTION public.supacloud_workflow_start(request jsonb)
|
|
4486
|
+
RETURNS jsonb
|
|
4487
|
+
LANGUAGE plpgsql VOLATILE SECURITY DEFINER SET search_path = '' AS $$
|
|
4488
|
+
DECLARE
|
|
4489
|
+
max_attempts integer;
|
|
4490
|
+
BEGIN
|
|
4491
|
+
max_attempts := coalesce((request ->> 'maxAttempts')::integer, 3);
|
|
4492
|
+
RETURN supacloud_workflows.start_run(
|
|
4493
|
+
supacloud_workflows.request_uuid(request, 'runId'),
|
|
4494
|
+
request ->> 'workflowName',
|
|
4495
|
+
request ->> 'workflowVersion',
|
|
4496
|
+
request ->> 'firstStepKey',
|
|
4497
|
+
coalesce(request -> 'input', '{}'::jsonb),
|
|
4498
|
+
max_attempts
|
|
4499
|
+
);
|
|
4500
|
+
EXCEPTION
|
|
4501
|
+
WHEN invalid_parameter_value OR invalid_text_representation OR numeric_value_out_of_range THEN
|
|
4502
|
+
RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_START_INVALID' USING ERRCODE = '22023';
|
|
4503
|
+
END;
|
|
4504
|
+
$$;
|
|
4505
|
+
|
|
4506
|
+
CREATE OR REPLACE FUNCTION public.supacloud_workflow_claim(request jsonb)
|
|
4507
|
+
RETURNS jsonb
|
|
4508
|
+
LANGUAGE plpgsql VOLATILE SECURITY DEFINER SET search_path = '' AS $$
|
|
4509
|
+
DECLARE
|
|
4510
|
+
visibility_timeout_seconds integer;
|
|
4511
|
+
BEGIN
|
|
4512
|
+
IF jsonb_typeof(request) IS DISTINCT FROM 'object' THEN
|
|
4513
|
+
RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_CLAIM_INVALID' USING ERRCODE = '22023';
|
|
4514
|
+
END IF;
|
|
4515
|
+
visibility_timeout_seconds := coalesce((request ->> 'visibilityTimeoutSeconds')::integer, 300);
|
|
4516
|
+
RETURN supacloud_workflows.claim_step(
|
|
4517
|
+
request ->> 'workerId', visibility_timeout_seconds
|
|
4518
|
+
);
|
|
4519
|
+
EXCEPTION
|
|
4520
|
+
WHEN invalid_parameter_value OR invalid_text_representation OR numeric_value_out_of_range THEN
|
|
4521
|
+
RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_CLAIM_INVALID' USING ERRCODE = '22023';
|
|
4522
|
+
END;
|
|
4523
|
+
$$;
|
|
4524
|
+
|
|
4525
|
+
CREATE OR REPLACE FUNCTION public.supacloud_workflow_advance(request jsonb)
|
|
4526
|
+
RETURNS jsonb
|
|
4527
|
+
LANGUAGE plpgsql VOLATILE SECURITY DEFINER SET search_path = '' AS $$
|
|
4528
|
+
DECLARE
|
|
4529
|
+
message_id bigint;
|
|
4530
|
+
attempt integer;
|
|
4531
|
+
next_max_attempts integer;
|
|
4532
|
+
BEGIN
|
|
4533
|
+
message_id := (request ->> 'messageId')::bigint;
|
|
4534
|
+
attempt := (request ->> 'attempt')::integer;
|
|
4535
|
+
next_max_attempts := coalesce((request ->> 'nextMaxAttempts')::integer, 3);
|
|
4536
|
+
IF message_id <= 0 OR attempt <= 0 THEN
|
|
4537
|
+
RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_ADVANCE_INVALID' USING ERRCODE = '22023';
|
|
4538
|
+
END IF;
|
|
4539
|
+
RETURN supacloud_workflows.advance_step(
|
|
4540
|
+
supacloud_workflows.request_uuid(request, 'stepId'),
|
|
4541
|
+
message_id,
|
|
4542
|
+
attempt,
|
|
4543
|
+
request ->> 'workerId',
|
|
4544
|
+
coalesce(request -> 'output', '{}'::jsonb),
|
|
4545
|
+
request ->> 'nextStepKey',
|
|
4546
|
+
coalesce(request -> 'nextInput', '{}'::jsonb),
|
|
4547
|
+
next_max_attempts
|
|
4548
|
+
);
|
|
4549
|
+
EXCEPTION
|
|
4550
|
+
WHEN invalid_parameter_value OR invalid_text_representation OR numeric_value_out_of_range THEN
|
|
4551
|
+
RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_ADVANCE_INVALID' USING ERRCODE = '22023';
|
|
4552
|
+
END;
|
|
4553
|
+
$$;
|
|
4554
|
+
|
|
4555
|
+
CREATE OR REPLACE FUNCTION public.supacloud_workflow_complete(request jsonb)
|
|
4556
|
+
RETURNS jsonb
|
|
4557
|
+
LANGUAGE plpgsql VOLATILE SECURITY DEFINER SET search_path = '' AS $$
|
|
4558
|
+
DECLARE
|
|
4559
|
+
message_id bigint;
|
|
4560
|
+
attempt integer;
|
|
4561
|
+
BEGIN
|
|
4562
|
+
message_id := (request ->> 'messageId')::bigint;
|
|
4563
|
+
attempt := (request ->> 'attempt')::integer;
|
|
4564
|
+
IF message_id <= 0 OR attempt <= 0 THEN
|
|
4565
|
+
RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_COMPLETION_INVALID' USING ERRCODE = '22023';
|
|
4566
|
+
END IF;
|
|
4567
|
+
RETURN supacloud_workflows.complete_run(
|
|
4568
|
+
supacloud_workflows.request_uuid(request, 'stepId'),
|
|
4569
|
+
message_id,
|
|
4570
|
+
attempt,
|
|
4571
|
+
request ->> 'workerId',
|
|
4572
|
+
coalesce(request -> 'stepOutput', '{}'::jsonb),
|
|
4573
|
+
coalesce(request -> 'runOutput', '{}'::jsonb)
|
|
4574
|
+
);
|
|
4575
|
+
EXCEPTION
|
|
4576
|
+
WHEN invalid_parameter_value OR invalid_text_representation OR numeric_value_out_of_range THEN
|
|
4577
|
+
RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_COMPLETION_INVALID' USING ERRCODE = '22023';
|
|
4578
|
+
END;
|
|
4579
|
+
$$;
|
|
4580
|
+
|
|
4581
|
+
CREATE OR REPLACE FUNCTION public.supacloud_workflow_retry(request jsonb)
|
|
4582
|
+
RETURNS jsonb
|
|
4583
|
+
LANGUAGE plpgsql VOLATILE SECURITY DEFINER SET search_path = '' AS $$
|
|
4584
|
+
DECLARE
|
|
4585
|
+
message_id bigint;
|
|
4586
|
+
attempt integer;
|
|
4587
|
+
delay_seconds integer;
|
|
4588
|
+
BEGIN
|
|
4589
|
+
message_id := (request ->> 'messageId')::bigint;
|
|
4590
|
+
attempt := (request ->> 'attempt')::integer;
|
|
4591
|
+
delay_seconds := coalesce((request ->> 'delaySeconds')::integer, 0);
|
|
4592
|
+
IF message_id <= 0 OR attempt <= 0 THEN
|
|
4593
|
+
RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_RETRY_INVALID' USING ERRCODE = '22023';
|
|
4594
|
+
END IF;
|
|
4595
|
+
RETURN supacloud_workflows.retry_step(
|
|
4596
|
+
supacloud_workflows.request_uuid(request, 'stepId'),
|
|
4597
|
+
message_id,
|
|
4598
|
+
attempt,
|
|
4599
|
+
request ->> 'workerId',
|
|
4600
|
+
request ->> 'errorMessage',
|
|
4601
|
+
delay_seconds
|
|
4602
|
+
);
|
|
4603
|
+
EXCEPTION
|
|
4604
|
+
WHEN invalid_parameter_value OR invalid_text_representation OR numeric_value_out_of_range THEN
|
|
4605
|
+
RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_RETRY_INVALID' USING ERRCODE = '22023';
|
|
4606
|
+
END;
|
|
4607
|
+
$$;
|
|
4608
|
+
|
|
4609
|
+
CREATE OR REPLACE FUNCTION public.supacloud_workflow_fail(request jsonb)
|
|
4610
|
+
RETURNS jsonb
|
|
4611
|
+
LANGUAGE plpgsql VOLATILE SECURITY DEFINER SET search_path = '' AS $$
|
|
4612
|
+
DECLARE
|
|
4613
|
+
message_id bigint;
|
|
4614
|
+
attempt integer;
|
|
4615
|
+
BEGIN
|
|
4616
|
+
message_id := (request ->> 'messageId')::bigint;
|
|
4617
|
+
attempt := (request ->> 'attempt')::integer;
|
|
4618
|
+
IF message_id <= 0 OR attempt <= 0 THEN
|
|
4619
|
+
RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_FAILURE_INVALID' USING ERRCODE = '22023';
|
|
4620
|
+
END IF;
|
|
4621
|
+
RETURN supacloud_workflows.fail_step(
|
|
4622
|
+
supacloud_workflows.request_uuid(request, 'stepId'),
|
|
4623
|
+
message_id,
|
|
4624
|
+
attempt,
|
|
4625
|
+
request ->> 'workerId',
|
|
4626
|
+
request ->> 'errorMessage'
|
|
4627
|
+
);
|
|
4628
|
+
EXCEPTION
|
|
4629
|
+
WHEN invalid_parameter_value OR invalid_text_representation OR numeric_value_out_of_range THEN
|
|
4630
|
+
RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_FAILURE_INVALID' USING ERRCODE = '22023';
|
|
4631
|
+
END;
|
|
4632
|
+
$$;
|
|
4633
|
+
|
|
4634
|
+
CREATE OR REPLACE FUNCTION public.supacloud_workflow_cancel(request jsonb)
|
|
4635
|
+
RETURNS jsonb
|
|
4636
|
+
LANGUAGE plpgsql VOLATILE SECURITY DEFINER SET search_path = '' AS $$
|
|
4637
|
+
BEGIN
|
|
4638
|
+
RETURN supacloud_workflows.cancel_run(
|
|
4639
|
+
supacloud_workflows.request_uuid(request, 'runId'),
|
|
4640
|
+
request ->> 'reason'
|
|
4641
|
+
);
|
|
4642
|
+
EXCEPTION
|
|
4643
|
+
WHEN invalid_parameter_value OR invalid_text_representation THEN
|
|
4644
|
+
RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_CANCEL_INVALID' USING ERRCODE = '22023';
|
|
4645
|
+
END;
|
|
4646
|
+
$$;
|
|
4647
|
+
|
|
4648
|
+
CREATE OR REPLACE FUNCTION public.supacloud_workflow_get(request jsonb)
|
|
4649
|
+
RETURNS jsonb
|
|
4650
|
+
LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = '' AS $$
|
|
4651
|
+
BEGIN
|
|
4652
|
+
RETURN supacloud_workflows.snapshot(
|
|
4653
|
+
supacloud_workflows.request_uuid(request, 'runId'), false
|
|
4654
|
+
);
|
|
4655
|
+
EXCEPTION
|
|
4656
|
+
WHEN invalid_parameter_value OR invalid_text_representation THEN
|
|
4657
|
+
RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_GET_INVALID' USING ERRCODE = '22023';
|
|
4658
|
+
END;
|
|
4659
|
+
$$;
|
|
4660
|
+
|
|
4661
|
+
CREATE OR REPLACE FUNCTION public.supacloud_workflow_events(request jsonb)
|
|
4662
|
+
RETURNS jsonb
|
|
4663
|
+
LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = '' AS $$
|
|
4664
|
+
DECLARE
|
|
4665
|
+
after_event_id bigint;
|
|
4666
|
+
event_limit integer;
|
|
4667
|
+
BEGIN
|
|
4668
|
+
after_event_id := coalesce((request ->> 'afterEventId')::bigint, 0);
|
|
4669
|
+
event_limit := coalesce((request ->> 'limit')::integer, 100);
|
|
4670
|
+
IF after_event_id < 0 OR event_limit NOT BETWEEN 1 AND 500 THEN
|
|
4671
|
+
RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_EVENTS_INVALID' USING ERRCODE = '22023';
|
|
4672
|
+
END IF;
|
|
4673
|
+
RETURN supacloud_workflows.run_events(
|
|
4674
|
+
supacloud_workflows.request_uuid(request, 'runId'), after_event_id, event_limit
|
|
4675
|
+
);
|
|
4676
|
+
EXCEPTION
|
|
4677
|
+
WHEN invalid_parameter_value OR invalid_text_representation OR numeric_value_out_of_range THEN
|
|
4678
|
+
RAISE EXCEPTION 'SUPACLOUD_WORKFLOW_EVENTS_INVALID' USING ERRCODE = '22023';
|
|
4679
|
+
END;
|
|
4680
|
+
$$;
|
|
4681
|
+
|
|
4682
|
+
REVOKE ALL ON ALL TABLES IN SCHEMA supacloud_workflows
|
|
4683
|
+
FROM PUBLIC, anon, authenticated, service_role;
|
|
4684
|
+
REVOKE ALL ON ALL SEQUENCES IN SCHEMA supacloud_workflows
|
|
4685
|
+
FROM PUBLIC, anon, authenticated, service_role;
|
|
4686
|
+
REVOKE ALL ON ALL FUNCTIONS IN SCHEMA supacloud_workflows
|
|
4687
|
+
FROM PUBLIC, anon, authenticated, service_role;
|
|
4688
|
+
|
|
4689
|
+
REVOKE ALL ON FUNCTION public.supacloud_workflow_start(jsonb) FROM PUBLIC, anon, authenticated;
|
|
4690
|
+
REVOKE ALL ON FUNCTION public.supacloud_workflow_claim(jsonb) FROM PUBLIC, anon, authenticated;
|
|
4691
|
+
REVOKE ALL ON FUNCTION public.supacloud_workflow_advance(jsonb) FROM PUBLIC, anon, authenticated;
|
|
4692
|
+
REVOKE ALL ON FUNCTION public.supacloud_workflow_complete(jsonb) FROM PUBLIC, anon, authenticated;
|
|
4693
|
+
REVOKE ALL ON FUNCTION public.supacloud_workflow_retry(jsonb) FROM PUBLIC, anon, authenticated;
|
|
4694
|
+
REVOKE ALL ON FUNCTION public.supacloud_workflow_fail(jsonb) FROM PUBLIC, anon, authenticated;
|
|
4695
|
+
REVOKE ALL ON FUNCTION public.supacloud_workflow_cancel(jsonb) FROM PUBLIC, anon, authenticated;
|
|
4696
|
+
REVOKE ALL ON FUNCTION public.supacloud_workflow_get(jsonb) FROM PUBLIC, anon, authenticated;
|
|
4697
|
+
REVOKE ALL ON FUNCTION public.supacloud_workflow_events(jsonb) FROM PUBLIC, anon, authenticated;
|
|
4698
|
+
|
|
4699
|
+
GRANT EXECUTE ON FUNCTION public.supacloud_workflow_start(jsonb) TO service_role;
|
|
4700
|
+
GRANT EXECUTE ON FUNCTION public.supacloud_workflow_claim(jsonb) TO service_role;
|
|
4701
|
+
GRANT EXECUTE ON FUNCTION public.supacloud_workflow_advance(jsonb) TO service_role;
|
|
4702
|
+
GRANT EXECUTE ON FUNCTION public.supacloud_workflow_complete(jsonb) TO service_role;
|
|
4703
|
+
GRANT EXECUTE ON FUNCTION public.supacloud_workflow_retry(jsonb) TO service_role;
|
|
4704
|
+
GRANT EXECUTE ON FUNCTION public.supacloud_workflow_fail(jsonb) TO service_role;
|
|
4705
|
+
GRANT EXECUTE ON FUNCTION public.supacloud_workflow_cancel(jsonb) TO service_role;
|
|
4706
|
+
GRANT EXECUTE ON FUNCTION public.supacloud_workflow_get(jsonb) TO service_role;
|
|
4707
|
+
GRANT EXECUTE ON FUNCTION public.supacloud_workflow_events(jsonb) TO service_role;
|
|
4708
|
+
-- supacloud:sql-module:workflows-public:end
|
|
3588
4709
|
`;
|
|
3589
4710
|
var CRON_SQL = `
|
|
3590
4711
|
create schema if not exists cron;
|
|
@@ -3911,12 +5032,32 @@ function pickTag(text, base) {
|
|
|
3911
5032
|
return tag;
|
|
3912
5033
|
}
|
|
3913
5034
|
|
|
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
5035
|
// src/runtime/db/data-dir-lock.ts
|
|
3919
|
-
import { readFile, unlink } from "fs/promises";
|
|
5036
|
+
import { mkdir, open, readFile, unlink } from "fs/promises";
|
|
5037
|
+
import { dirname, resolve } from "path";
|
|
5038
|
+
async function acquireDataDirLock(dataDir, engineName = "database") {
|
|
5039
|
+
if (!dataDir || dataDir.includes("://"))
|
|
5040
|
+
return async () => {};
|
|
5041
|
+
const absoluteDataDir = resolve(dataDir);
|
|
5042
|
+
const lockPath = `${absoluteDataDir}.supacloud-lite.lock`;
|
|
5043
|
+
await mkdir(dirname(absoluteDataDir), { recursive: true, mode: 448 });
|
|
5044
|
+
const nonce = crypto.randomUUID();
|
|
5045
|
+
const handle = await createDataDirLock(absoluteDataDir, lockPath, nonce, engineName);
|
|
5046
|
+
let released = false;
|
|
5047
|
+
return async () => {
|
|
5048
|
+
if (released)
|
|
5049
|
+
return;
|
|
5050
|
+
released = true;
|
|
5051
|
+
await handle.close();
|
|
5052
|
+
const owner = await readDataDirLockOwner(lockPath);
|
|
5053
|
+
if (owner?.nonce !== nonce)
|
|
5054
|
+
return;
|
|
5055
|
+
await unlink(lockPath).catch((error) => {
|
|
5056
|
+
if (error.code !== "ENOENT")
|
|
5057
|
+
throw error;
|
|
5058
|
+
});
|
|
5059
|
+
};
|
|
5060
|
+
}
|
|
3920
5061
|
async function recoverStaleDataDirLock(lockPath) {
|
|
3921
5062
|
const lockState = await inspectDataDirLock(lockPath);
|
|
3922
5063
|
if (lockState.kind !== "stale")
|
|
@@ -3934,6 +5075,38 @@ async function readDataDirLockOwner(lockPath) {
|
|
|
3934
5075
|
const lockState = await inspectDataDirLock(lockPath);
|
|
3935
5076
|
return lockState.kind === "active" || lockState.kind === "stale" ? lockState.owner : null;
|
|
3936
5077
|
}
|
|
5078
|
+
async function createDataDirLock(absoluteDataDir, lockPath, nonce, engineName) {
|
|
5079
|
+
for (let attempt = 0;attempt < 3; attempt++) {
|
|
5080
|
+
try {
|
|
5081
|
+
return await writeDataDirLock(lockPath, nonce);
|
|
5082
|
+
} catch (error) {
|
|
5083
|
+
if (error.code !== "EEXIST")
|
|
5084
|
+
throw error;
|
|
5085
|
+
const lockState = await recoverStaleDataDirLock(lockPath);
|
|
5086
|
+
if (lockState.kind === "active") {
|
|
5087
|
+
throw new Error(`${engineName} data directory is already in use: ${absoluteDataDir} (pid ${lockState.owner.pid})`);
|
|
5088
|
+
}
|
|
5089
|
+
if (lockState.kind === "unreadable")
|
|
5090
|
+
throw unreadableLockError(lockPath, engineName);
|
|
5091
|
+
}
|
|
5092
|
+
}
|
|
5093
|
+
throw unreadableLockError(lockPath, engineName);
|
|
5094
|
+
}
|
|
5095
|
+
async function writeDataDirLock(lockPath, nonce) {
|
|
5096
|
+
const handle = await open(lockPath, "wx", 384);
|
|
5097
|
+
try {
|
|
5098
|
+
await handle.writeFile(`${JSON.stringify({ pid: process.pid, nonce, createdAt: new Date().toISOString() })}
|
|
5099
|
+
`);
|
|
5100
|
+
return handle;
|
|
5101
|
+
} catch (error) {
|
|
5102
|
+
await handle.close().catch(() => {});
|
|
5103
|
+
await unlink(lockPath).catch(() => {});
|
|
5104
|
+
throw error;
|
|
5105
|
+
}
|
|
5106
|
+
}
|
|
5107
|
+
function unreadableLockError(lockPath, engineName) {
|
|
5108
|
+
return new Error(`${engineName} data directory has an unreadable lock: ${lockPath}. ` + "Confirm no SupaCloud Lite process is using it, then remove the lock manually.");
|
|
5109
|
+
}
|
|
3937
5110
|
async function inspectDataDirLock(lockPath) {
|
|
3938
5111
|
let contents;
|
|
3939
5112
|
try {
|
|
@@ -3987,7 +5160,7 @@ begin
|
|
|
3987
5160
|
end $$;
|
|
3988
5161
|
`;
|
|
3989
5162
|
async function createPgliteEngine(dataDir) {
|
|
3990
|
-
const releaseLock = await acquireDataDirLock(dataDir);
|
|
5163
|
+
const releaseLock = await acquireDataDirLock(dataDir, "PGlite");
|
|
3991
5164
|
let PGlite, extensions;
|
|
3992
5165
|
const standaloneAssets = getStandaloneAssets();
|
|
3993
5166
|
let cleanupStandaloneBundles = async () => {};
|
|
@@ -4111,63 +5284,6 @@ async function removePreparedBundles(cleanup) {
|
|
|
4111
5284
|
console.error("Unable to remove temporary PGlite extension bundles:", error);
|
|
4112
5285
|
}
|
|
4113
5286
|
}
|
|
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
5287
|
|
|
4172
5288
|
// src/runtime/db/database.ts
|
|
4173
5289
|
var DEFAULT_SEARCH_PATH_SQL = `set search_path to "$user", public, extensions`;
|
|
@@ -4185,20 +5301,30 @@ class Database {
|
|
|
4185
5301
|
}
|
|
4186
5302
|
static async create(dataDirOrEngine, opts) {
|
|
4187
5303
|
const engine = dataDirOrEngine && typeof dataDirOrEngine === "object" ? dataDirOrEngine : await createPgliteEngine(dataDirOrEngine);
|
|
4188
|
-
|
|
4189
|
-
|
|
4190
|
-
|
|
4191
|
-
|
|
4192
|
-
|
|
4193
|
-
|
|
4194
|
-
|
|
4195
|
-
|
|
4196
|
-
|
|
4197
|
-
|
|
4198
|
-
await engine.
|
|
5304
|
+
try {
|
|
5305
|
+
if (engine.minimalBootstrap) {
|
|
5306
|
+
await engine.exec(MINIMAL_BOOTSTRAP_SQL);
|
|
5307
|
+
} else {
|
|
5308
|
+
await engine.exec(BOOTSTRAP_SQL);
|
|
5309
|
+
await engine.exec(PGMQ_SQL);
|
|
5310
|
+
await engine.exec(WORKFLOWS_SQL);
|
|
5311
|
+
await engine.exec(CRON_SQL);
|
|
5312
|
+
await engine.exec(NET_SQL);
|
|
5313
|
+
await engine.exec(EXT_COMPAT_SQL);
|
|
5314
|
+
await engine.exec(VAULT_SQL);
|
|
5315
|
+
if (opts?.vaultKey) {
|
|
5316
|
+
await engine.query(`select set_config('app.settings.vault_key', $1, false)`, [opts.vaultKey]);
|
|
5317
|
+
}
|
|
4199
5318
|
}
|
|
5319
|
+
return new Database(engine);
|
|
5320
|
+
} catch (error) {
|
|
5321
|
+
try {
|
|
5322
|
+
await engine.close();
|
|
5323
|
+
} catch (cleanupError) {
|
|
5324
|
+
throw new AggregateError([error, cleanupError], "database bootstrap and cleanup failed");
|
|
5325
|
+
}
|
|
5326
|
+
throw error;
|
|
4200
5327
|
}
|
|
4201
|
-
return new Database(engine);
|
|
4202
5328
|
}
|
|
4203
5329
|
query(sql, params) {
|
|
4204
5330
|
return this.engine.query(sql, params);
|
|
@@ -5937,6 +7063,25 @@ function parsePrefer(header) {
|
|
|
5937
7063
|
}
|
|
5938
7064
|
return prefer;
|
|
5939
7065
|
}
|
|
7066
|
+
function applyRequestRange(query, request) {
|
|
7067
|
+
const range = request.headers.get("range");
|
|
7068
|
+
if (!range || query.limits.has("") || query.offsets.has(""))
|
|
7069
|
+
return;
|
|
7070
|
+
const unit = request.headers.get("range-unit");
|
|
7071
|
+
if (unit !== null && unit.toLowerCase() !== "items")
|
|
7072
|
+
throw new ParseError(`unsupported range unit: ${unit}`);
|
|
7073
|
+
const match = range.match(/^(\d+)-(\d*)$/);
|
|
7074
|
+
if (!match)
|
|
7075
|
+
throw new ParseError(`invalid range: ${range}`);
|
|
7076
|
+
const start = Number(match[1]);
|
|
7077
|
+
const end = match[2] ? Number(match[2]) : undefined;
|
|
7078
|
+
if (!Number.isSafeInteger(start) || end !== undefined && (!Number.isSafeInteger(end) || end < start)) {
|
|
7079
|
+
throw new ParseError(`invalid range: ${range}`);
|
|
7080
|
+
}
|
|
7081
|
+
query.offsets.set("", start);
|
|
7082
|
+
if (end !== undefined)
|
|
7083
|
+
query.limits.set("", end - start + 1);
|
|
7084
|
+
}
|
|
5940
7085
|
var OBJECT_MEDIA = "application/vnd.pgrst.object+json";
|
|
5941
7086
|
var CSV_MEDIA = "text/csv";
|
|
5942
7087
|
var PLAN_MEDIA = "application/vnd.pgrst.plan";
|
|
@@ -6013,6 +7158,8 @@ class RestHandler {
|
|
|
6013
7158
|
const wantsObject = accept.includes(OBJECT_MEDIA);
|
|
6014
7159
|
const wantsCsv = accept.includes(CSV_MEDIA);
|
|
6015
7160
|
const q = parseQuery(url.searchParams);
|
|
7161
|
+
if (method === "GET" || method === "HEAD")
|
|
7162
|
+
applyRequestRange(q, req);
|
|
6016
7163
|
if (this.maxRows !== undefined && (method === "GET" || method === "HEAD")) {
|
|
6017
7164
|
const requested = q.limits.get("");
|
|
6018
7165
|
q.limits.set("", requested === undefined ? this.maxRows : Math.min(requested, this.maxRows));
|
|
@@ -6055,10 +7202,11 @@ class RestHandler {
|
|
|
6055
7202
|
}
|
|
6056
7203
|
return { rows: res.rows[0].body, count: count2 };
|
|
6057
7204
|
});
|
|
7205
|
+
const offset = q.offsets.get("") ?? 0;
|
|
6058
7206
|
return this.dataResponse(rows, {
|
|
6059
|
-
status: 200,
|
|
7207
|
+
status: count !== null && (offset > 0 || rows.length < count) ? 206 : 200,
|
|
6060
7208
|
count,
|
|
6061
|
-
offset
|
|
7209
|
+
offset,
|
|
6062
7210
|
wantsObject,
|
|
6063
7211
|
wantsCsv,
|
|
6064
7212
|
head: method === "HEAD"
|
|
@@ -6489,8 +7637,10 @@ function applyResize(image, metadata, options) {
|
|
|
6489
7637
|
const proportionalWidth = Math.max(1, Math.round(metadata.width * options.height / metadata.height));
|
|
6490
7638
|
image.resize(proportionalWidth);
|
|
6491
7639
|
}
|
|
6492
|
-
async function transformImage(
|
|
6493
|
-
|
|
7640
|
+
async function transformImage(source, options, knownSourceSize) {
|
|
7641
|
+
const actualSourceSize = source instanceof Uint8Array ? source.byteLength : await Promise.resolve(source.size);
|
|
7642
|
+
const sourceSize = Math.max(knownSourceSize ?? 0, actualSourceSize);
|
|
7643
|
+
if (sourceSize > MAX_TRANSFORM_BYTES) {
|
|
6494
7644
|
return {
|
|
6495
7645
|
ok: false,
|
|
6496
7646
|
status: 413,
|
|
@@ -6498,9 +7648,10 @@ async function transformImage(bytes, options) {
|
|
|
6498
7648
|
message: "The source image exceeds the 25MB transformation limit"
|
|
6499
7649
|
};
|
|
6500
7650
|
}
|
|
7651
|
+
const image = new Bun.Image(source, { maxPixels: MAX_TRANSFORM_PIXELS });
|
|
6501
7652
|
let metadata;
|
|
6502
7653
|
try {
|
|
6503
|
-
metadata = await
|
|
7654
|
+
metadata = await image.metadata();
|
|
6504
7655
|
} catch (error) {
|
|
6505
7656
|
return mapImageError(error);
|
|
6506
7657
|
}
|
|
@@ -6517,7 +7668,6 @@ async function transformImage(bytes, options) {
|
|
|
6517
7668
|
message: `The source format ${outputFormat} is not supported by this runtime`
|
|
6518
7669
|
};
|
|
6519
7670
|
}
|
|
6520
|
-
const image = new Bun.Image(bytes, { maxPixels: MAX_TRANSFORM_PIXELS });
|
|
6521
7671
|
applyResize(image, metadata, options);
|
|
6522
7672
|
if (options.format === "jpeg" || options.format === "origin" && outputFormat === "jpeg" && options.quality !== undefined) {
|
|
6523
7673
|
image.jpeg(options.quality === undefined ? undefined : { quality: options.quality });
|
|
@@ -6527,12 +7677,127 @@ async function transformImage(bytes, options) {
|
|
|
6527
7677
|
image.webp(options.quality === undefined ? undefined : { quality: options.quality });
|
|
6528
7678
|
}
|
|
6529
7679
|
try {
|
|
6530
|
-
|
|
7680
|
+
const bytes = await image.bytes();
|
|
7681
|
+
if (bytes.byteLength > MAX_TRANSFORM_BYTES) {
|
|
7682
|
+
return {
|
|
7683
|
+
ok: false,
|
|
7684
|
+
status: 413,
|
|
7685
|
+
error: "ImageTooLarge",
|
|
7686
|
+
message: "The transformed image exceeds the 25MB transformation limit"
|
|
7687
|
+
};
|
|
7688
|
+
}
|
|
7689
|
+
return { ok: true, bytes, contentType };
|
|
6531
7690
|
} catch (error) {
|
|
6532
7691
|
return mapImageError(error);
|
|
6533
7692
|
}
|
|
6534
7693
|
}
|
|
6535
7694
|
|
|
7695
|
+
// src/runtime/storage/image-transform-cache.ts
|
|
7696
|
+
var DEFAULT_MAX_CACHE_BYTES = 64 * 1024 * 1024;
|
|
7697
|
+
var DEFAULT_MAX_CACHE_ENTRIES = 128;
|
|
7698
|
+
var OBJECT_VERSION_PREFIX = "v2-";
|
|
7699
|
+
|
|
7700
|
+
class Semaphore {
|
|
7701
|
+
limit;
|
|
7702
|
+
active = 0;
|
|
7703
|
+
waiters = [];
|
|
7704
|
+
constructor(limit) {
|
|
7705
|
+
this.limit = limit;
|
|
7706
|
+
}
|
|
7707
|
+
async run(operation) {
|
|
7708
|
+
await this.acquire();
|
|
7709
|
+
try {
|
|
7710
|
+
return await operation();
|
|
7711
|
+
} finally {
|
|
7712
|
+
this.release();
|
|
7713
|
+
}
|
|
7714
|
+
}
|
|
7715
|
+
async acquire() {
|
|
7716
|
+
if (this.active < this.limit) {
|
|
7717
|
+
this.active += 1;
|
|
7718
|
+
return;
|
|
7719
|
+
}
|
|
7720
|
+
await new Promise((resolve2) => this.waiters.push(resolve2));
|
|
7721
|
+
}
|
|
7722
|
+
release() {
|
|
7723
|
+
const next = this.waiters.shift();
|
|
7724
|
+
if (next) {
|
|
7725
|
+
next();
|
|
7726
|
+
return;
|
|
7727
|
+
}
|
|
7728
|
+
this.active -= 1;
|
|
7729
|
+
}
|
|
7730
|
+
}
|
|
7731
|
+
var globalImageTransformSemaphore = new Semaphore(1);
|
|
7732
|
+
function imageTransformCacheKey(version, options) {
|
|
7733
|
+
if (!version?.startsWith(OBJECT_VERSION_PREFIX))
|
|
7734
|
+
return null;
|
|
7735
|
+
return [
|
|
7736
|
+
version,
|
|
7737
|
+
options.width ?? "",
|
|
7738
|
+
options.height ?? "",
|
|
7739
|
+
options.resize,
|
|
7740
|
+
options.quality ?? "",
|
|
7741
|
+
options.format
|
|
7742
|
+
].join("\x00");
|
|
7743
|
+
}
|
|
7744
|
+
|
|
7745
|
+
class ImageTransformCache {
|
|
7746
|
+
maxBytes;
|
|
7747
|
+
maxEntries;
|
|
7748
|
+
entries = new Map;
|
|
7749
|
+
inFlight = new Map;
|
|
7750
|
+
cachedBytes = 0;
|
|
7751
|
+
constructor(maxBytes = DEFAULT_MAX_CACHE_BYTES, maxEntries = DEFAULT_MAX_CACHE_ENTRIES) {
|
|
7752
|
+
this.maxBytes = maxBytes;
|
|
7753
|
+
this.maxEntries = maxEntries;
|
|
7754
|
+
}
|
|
7755
|
+
async getOrTransform(key, operation) {
|
|
7756
|
+
if (key === null)
|
|
7757
|
+
return globalImageTransformSemaphore.run(operation);
|
|
7758
|
+
const cached = this.entries.get(key);
|
|
7759
|
+
if (cached) {
|
|
7760
|
+
this.entries.delete(key);
|
|
7761
|
+
this.entries.set(key, cached);
|
|
7762
|
+
return cached.transform;
|
|
7763
|
+
}
|
|
7764
|
+
const pending = this.inFlight.get(key);
|
|
7765
|
+
if (pending)
|
|
7766
|
+
return pending;
|
|
7767
|
+
const transform = globalImageTransformSemaphore.run(operation);
|
|
7768
|
+
this.inFlight.set(key, transform);
|
|
7769
|
+
try {
|
|
7770
|
+
const transformResult = await transform;
|
|
7771
|
+
if (transformResult.ok)
|
|
7772
|
+
this.store(key, transformResult);
|
|
7773
|
+
return transformResult;
|
|
7774
|
+
} finally {
|
|
7775
|
+
if (this.inFlight.get(key) === transform)
|
|
7776
|
+
this.inFlight.delete(key);
|
|
7777
|
+
}
|
|
7778
|
+
}
|
|
7779
|
+
store(key, transform) {
|
|
7780
|
+
const size = transform.bytes.byteLength;
|
|
7781
|
+
if (size > this.maxBytes || this.maxEntries === 0)
|
|
7782
|
+
return;
|
|
7783
|
+
const previous = this.entries.get(key);
|
|
7784
|
+
if (previous) {
|
|
7785
|
+
this.cachedBytes -= previous.size;
|
|
7786
|
+
this.entries.delete(key);
|
|
7787
|
+
}
|
|
7788
|
+
this.entries.set(key, { transform, size });
|
|
7789
|
+
this.cachedBytes += size;
|
|
7790
|
+
while (this.entries.size > this.maxEntries || this.cachedBytes > this.maxBytes) {
|
|
7791
|
+
const oldestKey = this.entries.keys().next().value;
|
|
7792
|
+
if (oldestKey === undefined)
|
|
7793
|
+
break;
|
|
7794
|
+
const oldest = this.entries.get(oldestKey);
|
|
7795
|
+
this.entries.delete(oldestKey);
|
|
7796
|
+
this.cachedBytes -= oldest.size;
|
|
7797
|
+
}
|
|
7798
|
+
}
|
|
7799
|
+
}
|
|
7800
|
+
|
|
6536
7801
|
// src/runtime/storage/handler.ts
|
|
6537
7802
|
var MAX_SIGNED_URL_EXPIRY = 7 * 24 * 60 * 60;
|
|
6538
7803
|
function clampExpiry(expiresIn) {
|
|
@@ -6611,7 +7876,7 @@ var MAX_COMPLETED_TUS_UPLOADS = 64;
|
|
|
6611
7876
|
var COMPLETED_TUS_RETENTION_MS = 60 * 60 * 1000;
|
|
6612
7877
|
var PREFLIGHT_ROLLBACK = Symbol("storage-preflight-rollback");
|
|
6613
7878
|
var INTERNAL_STORAGE_BUCKET = ".supacloud-lite";
|
|
6614
|
-
var
|
|
7879
|
+
var OBJECT_VERSION_PREFIX2 = "v2-";
|
|
6615
7880
|
|
|
6616
7881
|
class StorageHandler {
|
|
6617
7882
|
db;
|
|
@@ -6619,6 +7884,7 @@ class StorageHandler {
|
|
|
6619
7884
|
config;
|
|
6620
7885
|
tusUploads = new Map;
|
|
6621
7886
|
mutationTail = Promise.resolve();
|
|
7887
|
+
imageTransforms = new ImageTransformCache;
|
|
6622
7888
|
constructor(db, driver, config) {
|
|
6623
7889
|
this.db = db;
|
|
6624
7890
|
this.driver = driver;
|
|
@@ -6661,15 +7927,21 @@ class StorageHandler {
|
|
|
6661
7927
|
const bucket2 = parts[3];
|
|
6662
7928
|
const key2 = parts.slice(4).join("/");
|
|
6663
7929
|
if (kind === "public" && (method === "GET" || method === "HEAD")) {
|
|
6664
|
-
const source = await this.
|
|
7930
|
+
const source = await this.loadPublicObject(bucket2, key2);
|
|
7931
|
+
if (source instanceof Response)
|
|
7932
|
+
return source;
|
|
6665
7933
|
return await this.transformImageResponse(source, url, method === "HEAD");
|
|
6666
7934
|
}
|
|
6667
7935
|
if (kind === "authenticated" && (method === "GET" || method === "HEAD")) {
|
|
6668
|
-
const source = await this.
|
|
7936
|
+
const source = await this.loadAuthenticatedObject(ctx, bucket2, key2);
|
|
7937
|
+
if (source instanceof Response)
|
|
7938
|
+
return source;
|
|
6669
7939
|
return await this.transformImageResponse(source, url, method === "HEAD");
|
|
6670
7940
|
}
|
|
6671
7941
|
if (kind === "sign" && method === "GET") {
|
|
6672
|
-
const source = await this.
|
|
7942
|
+
const source = await this.loadSignedObject(url, bucket2, key2);
|
|
7943
|
+
if (source instanceof Response)
|
|
7944
|
+
return source;
|
|
6673
7945
|
return await this.transformImageResponse(source, url, false);
|
|
6674
7946
|
}
|
|
6675
7947
|
return storageError(404, "not_found", `unknown render endpoint: ${rest}`);
|
|
@@ -6882,21 +8154,31 @@ class StorageHandler {
|
|
|
6882
8154
|
throw e;
|
|
6883
8155
|
}
|
|
6884
8156
|
}
|
|
6885
|
-
async transformImageResponse(
|
|
6886
|
-
if (!source.ok)
|
|
6887
|
-
return source;
|
|
8157
|
+
async transformImageResponse(row, url, head) {
|
|
6888
8158
|
const parsed = parseImageTransform(url.searchParams);
|
|
6889
8159
|
if (!parsed.ok)
|
|
6890
8160
|
return storageError(parsed.status, parsed.error, parsed.message);
|
|
6891
|
-
|
|
8161
|
+
let result;
|
|
8162
|
+
try {
|
|
8163
|
+
result = await this.imageTransforms.getOrTransform(imageTransformCacheKey(row.version, parsed.value), async () => {
|
|
8164
|
+
const source = await this.readObjectSource(row);
|
|
8165
|
+
if (source === null)
|
|
8166
|
+
throw new StorageObjectMissingError;
|
|
8167
|
+
return transformImage(source, parsed.value, objectSize(row));
|
|
8168
|
+
});
|
|
8169
|
+
} catch (error) {
|
|
8170
|
+
if (error instanceof StorageObjectMissingError) {
|
|
8171
|
+
return storageError(404, "not_found", "Object not found");
|
|
8172
|
+
}
|
|
8173
|
+
throw error;
|
|
8174
|
+
}
|
|
6892
8175
|
if (!result.ok)
|
|
6893
8176
|
return storageError(result.status, result.error, result.message);
|
|
6894
|
-
const headers =
|
|
8177
|
+
const headers = objectHeaders(row, result.bytes.length);
|
|
6895
8178
|
headers.delete("content-disposition");
|
|
6896
8179
|
headers.delete("etag");
|
|
6897
8180
|
headers.set("content-type", result.contentType);
|
|
6898
|
-
|
|
6899
|
-
return new Response(head ? null : result.bytes, { status: source.status, headers });
|
|
8181
|
+
return new Response(head ? null : result.bytes, { status: 200, headers });
|
|
6900
8182
|
}
|
|
6901
8183
|
async persistObject(ctx, bucketId, key, bytes, contentType, cacheControl, upsert) {
|
|
6902
8184
|
const metadata = objectMetadata(bytes.length, contentType, cacheControl);
|
|
@@ -7166,13 +8448,25 @@ class StorageHandler {
|
|
|
7166
8448
|
return null;
|
|
7167
8449
|
}
|
|
7168
8450
|
async download(ctx, bucketId, key, head) {
|
|
8451
|
+
const row = await this.loadAuthenticatedObject(ctx, bucketId, key);
|
|
8452
|
+
if (row instanceof Response)
|
|
8453
|
+
return row;
|
|
8454
|
+
return this.serveObject(row, head);
|
|
8455
|
+
}
|
|
8456
|
+
async loadAuthenticatedObject(ctx, bucketId, key) {
|
|
7169
8457
|
const res = await this.db.withContext(ctx, (q) => q(`select * from storage.objects where bucket_id = $1 and name = $2`, [bucketId, key]));
|
|
7170
8458
|
const row = res.rows[0];
|
|
7171
8459
|
if (!row)
|
|
7172
8460
|
return storageError(404, "not_found", "Object not found");
|
|
7173
|
-
return
|
|
8461
|
+
return row;
|
|
7174
8462
|
}
|
|
7175
8463
|
async downloadPublic(bucketId, key, head) {
|
|
8464
|
+
const row = await this.loadPublicObject(bucketId, key);
|
|
8465
|
+
if (row instanceof Response)
|
|
8466
|
+
return row;
|
|
8467
|
+
return this.serveObject(row, head);
|
|
8468
|
+
}
|
|
8469
|
+
async loadPublicObject(bucketId, key) {
|
|
7176
8470
|
const bucket = await this.loadBucket(bucketId);
|
|
7177
8471
|
if (!bucket?.public)
|
|
7178
8472
|
return storageError(400, "not_found", "Bucket is not public");
|
|
@@ -7183,24 +8477,16 @@ class StorageHandler {
|
|
|
7183
8477
|
const row = res.rows[0];
|
|
7184
8478
|
if (!row)
|
|
7185
8479
|
return storageError(404, "not_found", "Object not found");
|
|
7186
|
-
return
|
|
8480
|
+
return row;
|
|
7187
8481
|
}
|
|
7188
8482
|
async serveObject(row, head) {
|
|
7189
8483
|
const bytes = await this.readObjectBytes(row);
|
|
7190
8484
|
if (bytes === null)
|
|
7191
8485
|
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
|
-
};
|
|
8486
|
+
const contentType = String(row.metadata?.mimetype ?? "application/octet-stream");
|
|
8487
|
+
const headers = objectHeaders(row, bytes.length);
|
|
7202
8488
|
if (isRenderableActiveType(contentType))
|
|
7203
|
-
headers
|
|
8489
|
+
headers.set("content-disposition", "attachment");
|
|
7204
8490
|
return new Response(head ? null : bytes, { status: 200, headers });
|
|
7205
8491
|
}
|
|
7206
8492
|
async removeObjects(req, ctx, bucketId) {
|
|
@@ -7311,9 +8597,11 @@ class StorageHandler {
|
|
|
7311
8597
|
throw error;
|
|
7312
8598
|
}
|
|
7313
8599
|
async readObjectBytes(row) {
|
|
7314
|
-
|
|
7315
|
-
|
|
7316
|
-
|
|
8600
|
+
return this.driver.get(storageKey(row));
|
|
8601
|
+
}
|
|
8602
|
+
async readObjectSource(row) {
|
|
8603
|
+
const key = storageKey(row);
|
|
8604
|
+
return this.driver.getBlob ? this.driver.getBlob(key) : this.driver.get(key);
|
|
7317
8605
|
}
|
|
7318
8606
|
async cleanupObjectRows(rows) {
|
|
7319
8607
|
const keys = rows.flatMap((row) => [
|
|
@@ -7416,6 +8704,12 @@ class StorageHandler {
|
|
|
7416
8704
|
return json3(200, out);
|
|
7417
8705
|
}
|
|
7418
8706
|
async redeemSignedUrl(url, bucketId, key) {
|
|
8707
|
+
const row = await this.loadSignedObject(url, bucketId, key);
|
|
8708
|
+
if (row instanceof Response)
|
|
8709
|
+
return row;
|
|
8710
|
+
return this.serveObject(row, false);
|
|
8711
|
+
}
|
|
8712
|
+
async loadSignedObject(url, bucketId, key) {
|
|
7419
8713
|
const token = url.searchParams.get("token") ?? "";
|
|
7420
8714
|
const claims = await verifyJwt(token, this.config.jwtSecret);
|
|
7421
8715
|
if (!claims || claims.url !== `${bucketId}/${key}` || claims.type !== "download") {
|
|
@@ -7428,7 +8722,7 @@ class StorageHandler {
|
|
|
7428
8722
|
const row = res.rows[0];
|
|
7429
8723
|
if (!row)
|
|
7430
8724
|
return storageError(404, "not_found", "Object not found");
|
|
7431
|
-
return
|
|
8725
|
+
return row;
|
|
7432
8726
|
}
|
|
7433
8727
|
async signUploadUrl(ctx, bucketId, key) {
|
|
7434
8728
|
const keyErr = invalidObjectKey(key);
|
|
@@ -7486,6 +8780,9 @@ function objectJson(r) {
|
|
|
7486
8780
|
|
|
7487
8781
|
class StorageValidationError extends Error {
|
|
7488
8782
|
}
|
|
8783
|
+
|
|
8784
|
+
class StorageObjectMissingError extends Error {
|
|
8785
|
+
}
|
|
7489
8786
|
function parseSizeLimit(v) {
|
|
7490
8787
|
if (v === null || v === undefined || v === "")
|
|
7491
8788
|
return null;
|
|
@@ -7508,14 +8805,32 @@ function objectMetadata(size, contentType, cacheControl) {
|
|
|
7508
8805
|
httpStatusCode: 200
|
|
7509
8806
|
};
|
|
7510
8807
|
}
|
|
8808
|
+
function objectHeaders(row, contentLength) {
|
|
8809
|
+
const metadata = row.metadata ?? {};
|
|
8810
|
+
return new Headers({
|
|
8811
|
+
"content-type": String(metadata.mimetype ?? "application/octet-stream"),
|
|
8812
|
+
"content-length": String(contentLength),
|
|
8813
|
+
"cache-control": String(metadata.cacheControl ?? "no-cache"),
|
|
8814
|
+
etag: String(metadata.eTag ?? '""'),
|
|
8815
|
+
"last-modified": new Date(String(metadata.lastModified ?? Date.now())).toUTCString(),
|
|
8816
|
+
"x-content-type-options": "nosniff"
|
|
8817
|
+
});
|
|
8818
|
+
}
|
|
8819
|
+
function objectSize(row) {
|
|
8820
|
+
const size = Number(row.metadata?.size);
|
|
8821
|
+
return Number.isFinite(size) && size >= 0 ? size : undefined;
|
|
8822
|
+
}
|
|
7511
8823
|
function objectVersionKey(version) {
|
|
7512
8824
|
return `.supacloud-lite/objects/${version}`;
|
|
7513
8825
|
}
|
|
8826
|
+
function storageKey(row) {
|
|
8827
|
+
return isVersionedObjectVersion(row.version) ? objectVersionKey(row.version) : legacyObjectKey(row);
|
|
8828
|
+
}
|
|
7514
8829
|
function createObjectVersion() {
|
|
7515
|
-
return `${
|
|
8830
|
+
return `${OBJECT_VERSION_PREFIX2}${crypto.randomUUID()}`;
|
|
7516
8831
|
}
|
|
7517
8832
|
function isVersionedObjectVersion(version) {
|
|
7518
|
-
return version?.startsWith(
|
|
8833
|
+
return version?.startsWith(OBJECT_VERSION_PREFIX2) ?? false;
|
|
7519
8834
|
}
|
|
7520
8835
|
function isInternalStorageBucket(bucketId) {
|
|
7521
8836
|
return bucketId === INTERNAL_STORAGE_BUCKET || bucketId.startsWith(`${INTERNAL_STORAGE_BUCKET}/`);
|
|
@@ -8638,7 +9953,7 @@ ${msg.text}` : `[mail] to=${msg.to} subject="${msg.subject}"`));
|
|
|
8638
9953
|
const res = await safeHandle(req);
|
|
8639
9954
|
try {
|
|
8640
9955
|
const p = new URL(req.url).pathname;
|
|
8641
|
-
if (p !== "/health" && p !== "/") {
|
|
9956
|
+
if (p !== "/health" && p !== "/" && p !== "/auth/v1/health") {
|
|
8642
9957
|
const level = res.status >= 500 ? "error" : res.status >= 400 ? "warn" : "info";
|
|
8643
9958
|
logs.push(`${req.method} ${p} \u2192 ${res.status}`, level);
|
|
8644
9959
|
}
|
|
@@ -8767,6 +10082,21 @@ class S3StorageDriver {
|
|
|
8767
10082
|
throw error;
|
|
8768
10083
|
}
|
|
8769
10084
|
}
|
|
10085
|
+
async getBlob(key) {
|
|
10086
|
+
const file = this.client.file(this.objectKey(key));
|
|
10087
|
+
if (!await file.exists())
|
|
10088
|
+
return null;
|
|
10089
|
+
if (typeof file.arrayBuffer === "function")
|
|
10090
|
+
return file;
|
|
10091
|
+
try {
|
|
10092
|
+
const bytes = Uint8Array.from(await file.bytes());
|
|
10093
|
+
return new Blob([bytes.buffer]);
|
|
10094
|
+
} catch (error) {
|
|
10095
|
+
if (isNotFoundError(error))
|
|
10096
|
+
return null;
|
|
10097
|
+
throw error;
|
|
10098
|
+
}
|
|
10099
|
+
}
|
|
8770
10100
|
async delete(key) {
|
|
8771
10101
|
await this.client.file(this.objectKey(key)).delete();
|
|
8772
10102
|
}
|
|
@@ -8816,6 +10146,10 @@ class FsStorageDriver {
|
|
|
8816
10146
|
throw e;
|
|
8817
10147
|
}
|
|
8818
10148
|
}
|
|
10149
|
+
async getBlob(key) {
|
|
10150
|
+
const file = Bun.file(this.resolve(key));
|
|
10151
|
+
return await file.exists() ? file : null;
|
|
10152
|
+
}
|
|
8819
10153
|
async delete(key) {
|
|
8820
10154
|
await rm(this.resolve(key), { force: true });
|
|
8821
10155
|
}
|
|
@@ -8870,20 +10204,787 @@ async function serveBun(backend, opts = {}) {
|
|
|
8870
10204
|
}
|
|
8871
10205
|
};
|
|
8872
10206
|
}
|
|
10207
|
+
// src/runtime/node/native/engine.ts
|
|
10208
|
+
import { execFileSync, spawn } from "child_process";
|
|
10209
|
+
import { createHash as createHash2, randomBytes as randomBytes2 } from "crypto";
|
|
10210
|
+
import { appendFileSync, chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync } from "fs";
|
|
10211
|
+
import { writeFile as writeFile2 } from "fs/promises";
|
|
10212
|
+
import { homedir, tmpdir } from "os";
|
|
10213
|
+
import { join as join2 } from "path";
|
|
10214
|
+
import { extract as extractTar } from "tar";
|
|
10215
|
+
|
|
10216
|
+
// src/runtime/node/native/wire.ts
|
|
10217
|
+
import { createConnection } from "net";
|
|
10218
|
+
import { createHash, createHmac, pbkdf2Sync, randomBytes } from "crypto";
|
|
10219
|
+
|
|
10220
|
+
class PgWireError extends Error {
|
|
10221
|
+
code;
|
|
10222
|
+
detail;
|
|
10223
|
+
hint;
|
|
10224
|
+
severity;
|
|
10225
|
+
constructor(fields) {
|
|
10226
|
+
super(fields.get("M") ?? "postgres error");
|
|
10227
|
+
this.code = fields.get("C");
|
|
10228
|
+
this.detail = fields.get("D");
|
|
10229
|
+
this.hint = fields.get("H");
|
|
10230
|
+
this.severity = fields.get("S");
|
|
10231
|
+
}
|
|
10232
|
+
}
|
|
10233
|
+
|
|
10234
|
+
class PgWireClient {
|
|
10235
|
+
socket;
|
|
10236
|
+
buffer = Buffer.alloc(0);
|
|
10237
|
+
pending = null;
|
|
10238
|
+
queue = Promise.resolve();
|
|
10239
|
+
closed = false;
|
|
10240
|
+
onNotification = null;
|
|
10241
|
+
static async connect(opts) {
|
|
10242
|
+
const client = new PgWireClient;
|
|
10243
|
+
await client.open(opts);
|
|
10244
|
+
return client;
|
|
10245
|
+
}
|
|
10246
|
+
open(opts) {
|
|
10247
|
+
return new Promise((resolve2, reject) => {
|
|
10248
|
+
this.socket = opts.socketPath ? createConnection(opts.socketPath) : createConnection(opts.port ?? 5432, opts.host ?? "127.0.0.1");
|
|
10249
|
+
this.socket.on("error", (e) => {
|
|
10250
|
+
if (this.pending)
|
|
10251
|
+
this.pending.reject(e);
|
|
10252
|
+
reject(e);
|
|
10253
|
+
});
|
|
10254
|
+
this.socket.on("close", () => {
|
|
10255
|
+
this.closed = true;
|
|
10256
|
+
this.pending?.reject(new Error("connection closed"));
|
|
10257
|
+
});
|
|
10258
|
+
this.socket.on("connect", () => {
|
|
10259
|
+
const params = `user\x00${opts.user}\x00database\x00${opts.database}\x00client_encoding\x00UTF8\x00\x00`;
|
|
10260
|
+
const body = Buffer.from(params, "utf8");
|
|
10261
|
+
const msg = Buffer.alloc(8 + body.length);
|
|
10262
|
+
msg.writeInt32BE(8 + body.length, 0);
|
|
10263
|
+
msg.writeInt32BE(196608, 4);
|
|
10264
|
+
body.copy(msg, 8);
|
|
10265
|
+
this.socket.write(msg);
|
|
10266
|
+
});
|
|
10267
|
+
let clientNonce = "";
|
|
10268
|
+
let clientFirstBare = "";
|
|
10269
|
+
let serverSignature = "";
|
|
10270
|
+
const needPassword = () => {
|
|
10271
|
+
if (opts.password == null) {
|
|
10272
|
+
reject(new Error("the server requested a password but none was provided"));
|
|
10273
|
+
return false;
|
|
10274
|
+
}
|
|
10275
|
+
return true;
|
|
10276
|
+
};
|
|
10277
|
+
const startupHandler = (chunk) => {
|
|
10278
|
+
this.buffer = Buffer.concat([this.buffer, chunk]);
|
|
10279
|
+
let msg;
|
|
10280
|
+
while ((msg = this.nextMessage()) !== null) {
|
|
10281
|
+
const [type, payload] = msg;
|
|
10282
|
+
if (type === 82) {
|
|
10283
|
+
const code = payload.readInt32BE(0);
|
|
10284
|
+
if (code === 0) {} else if (code === 3) {
|
|
10285
|
+
if (!needPassword())
|
|
10286
|
+
return;
|
|
10287
|
+
this.socket.write(message(112, cstring(opts.password)));
|
|
10288
|
+
} else if (code === 5) {
|
|
10289
|
+
if (!needPassword())
|
|
10290
|
+
return;
|
|
10291
|
+
const salt = payload.subarray(4, 8);
|
|
10292
|
+
const inner = md5Hex(Buffer.from(opts.password + opts.user, "utf8"));
|
|
10293
|
+
const token = "md5" + md5Hex(Buffer.concat([Buffer.from(inner, "utf8"), salt]));
|
|
10294
|
+
this.socket.write(message(112, cstring(token)));
|
|
10295
|
+
} else if (code === 10) {
|
|
10296
|
+
if (!needPassword())
|
|
10297
|
+
return;
|
|
10298
|
+
const mechs = payload.subarray(4).toString("utf8").split("\x00").filter(Boolean);
|
|
10299
|
+
if (!mechs.includes("SCRAM-SHA-256")) {
|
|
10300
|
+
reject(new Error(`no supported SASL mechanism (server offered: ${mechs.join(", ")})`));
|
|
10301
|
+
return;
|
|
10302
|
+
}
|
|
10303
|
+
clientNonce = randomBytes(18).toString("base64");
|
|
10304
|
+
clientFirstBare = `n=,r=${clientNonce}`;
|
|
10305
|
+
const initial = Buffer.from(`n,,${clientFirstBare}`, "utf8");
|
|
10306
|
+
this.socket.write(message(112, Buffer.concat([cstring("SCRAM-SHA-256"), int32(initial.length), initial])));
|
|
10307
|
+
} else if (code === 11) {
|
|
10308
|
+
const serverFirst = payload.subarray(4).toString("utf8");
|
|
10309
|
+
const attrs = scramAttrs(serverFirst);
|
|
10310
|
+
if (!attrs.r?.startsWith(clientNonce)) {
|
|
10311
|
+
reject(new Error("SCRAM: server nonce does not extend client nonce"));
|
|
10312
|
+
return;
|
|
10313
|
+
}
|
|
10314
|
+
const salt = Buffer.from(attrs.s, "base64");
|
|
10315
|
+
const iterations = parseInt(attrs.i, 10);
|
|
10316
|
+
const saltedPassword = pbkdf2Sync(opts.password, salt, iterations, 32, "sha256");
|
|
10317
|
+
const clientKey = hmac(saltedPassword, "Client Key");
|
|
10318
|
+
const storedKey = sha256(clientKey);
|
|
10319
|
+
const finalNoProof = `c=biws,r=${attrs.r}`;
|
|
10320
|
+
const authMessage = `${clientFirstBare},${serverFirst},${finalNoProof}`;
|
|
10321
|
+
const clientSignature = hmac(storedKey, authMessage);
|
|
10322
|
+
const proof = xorBuffers(clientKey, clientSignature);
|
|
10323
|
+
serverSignature = hmac(hmac(saltedPassword, "Server Key"), authMessage).toString("base64");
|
|
10324
|
+
const clientFinal = `${finalNoProof},p=${proof.toString("base64")}`;
|
|
10325
|
+
this.socket.write(message(112, Buffer.from(clientFinal, "utf8")));
|
|
10326
|
+
} else if (code === 12) {
|
|
10327
|
+
const v = scramAttrs(payload.subarray(4).toString("utf8")).v;
|
|
10328
|
+
if (v && serverSignature && v !== serverSignature) {
|
|
10329
|
+
reject(new Error("SCRAM: server signature verification failed"));
|
|
10330
|
+
return;
|
|
10331
|
+
}
|
|
10332
|
+
} else {
|
|
10333
|
+
reject(new Error(`unsupported auth method ${code}`));
|
|
10334
|
+
return;
|
|
10335
|
+
}
|
|
10336
|
+
} else if (type === 69) {
|
|
10337
|
+
reject(new PgWireError(parseErrorFields(payload)));
|
|
10338
|
+
return;
|
|
10339
|
+
} else if (type === 90) {
|
|
10340
|
+
this.socket.off("data", startupHandler);
|
|
10341
|
+
this.socket.on("data", (c) => {
|
|
10342
|
+
this.buffer = Buffer.concat([this.buffer, c]);
|
|
10343
|
+
this.processMessages();
|
|
10344
|
+
});
|
|
10345
|
+
resolve2();
|
|
10346
|
+
return;
|
|
10347
|
+
}
|
|
10348
|
+
}
|
|
10349
|
+
};
|
|
10350
|
+
this.socket.on("data", startupHandler);
|
|
10351
|
+
});
|
|
10352
|
+
}
|
|
10353
|
+
run(send) {
|
|
10354
|
+
const op = this.queue.then(() => new Promise((resolve2, reject) => {
|
|
10355
|
+
if (this.closed)
|
|
10356
|
+
return reject(new Error("connection closed"));
|
|
10357
|
+
this.pending = { resolve: resolve2, reject, results: [], columns: [], error: null };
|
|
10358
|
+
send();
|
|
10359
|
+
}));
|
|
10360
|
+
this.queue = op.catch(() => {});
|
|
10361
|
+
return op;
|
|
10362
|
+
}
|
|
10363
|
+
async exec(sql) {
|
|
10364
|
+
return this.run(() => this.socket.write(message(81, cstring(sql))));
|
|
10365
|
+
}
|
|
10366
|
+
async query(sql, params = []) {
|
|
10367
|
+
const results = await this.run(() => {
|
|
10368
|
+
const parse = message(80, Buffer.concat([cstring(""), cstring(sql), int16(0)]));
|
|
10369
|
+
const paramBufs = [int16(0), int16(params.length)];
|
|
10370
|
+
for (const p of params) {
|
|
10371
|
+
if (p === null || p === undefined) {
|
|
10372
|
+
paramBufs.push(int32(-1));
|
|
10373
|
+
} else {
|
|
10374
|
+
const b = Buffer.from(String(p), "utf8");
|
|
10375
|
+
paramBufs.push(int32(b.length), b);
|
|
10376
|
+
}
|
|
10377
|
+
}
|
|
10378
|
+
paramBufs.push(int16(0));
|
|
10379
|
+
const bind = message(66, Buffer.concat([cstring(""), cstring(""), ...paramBufs]));
|
|
10380
|
+
const describe = message(68, Buffer.concat([Buffer.from("P"), cstring("")]));
|
|
10381
|
+
const execute = message(69, Buffer.concat([cstring(""), int32(0)]));
|
|
10382
|
+
const sync = message(83, Buffer.alloc(0));
|
|
10383
|
+
this.socket.write(Buffer.concat([parse, bind, describe, execute, sync]));
|
|
10384
|
+
});
|
|
10385
|
+
return results[0] ?? { rows: [] };
|
|
10386
|
+
}
|
|
10387
|
+
close() {
|
|
10388
|
+
return new Promise((resolve2) => {
|
|
10389
|
+
if (this.closed)
|
|
10390
|
+
return resolve2();
|
|
10391
|
+
this.socket.write(message(88, Buffer.alloc(0)));
|
|
10392
|
+
this.socket.end(() => resolve2());
|
|
10393
|
+
});
|
|
10394
|
+
}
|
|
10395
|
+
nextMessage() {
|
|
10396
|
+
if (this.buffer.length < 5)
|
|
10397
|
+
return null;
|
|
10398
|
+
const type = this.buffer[0];
|
|
10399
|
+
const length = this.buffer.readInt32BE(1);
|
|
10400
|
+
if (this.buffer.length < 1 + length)
|
|
10401
|
+
return null;
|
|
10402
|
+
const payload = this.buffer.subarray(5, 1 + length);
|
|
10403
|
+
this.buffer = this.buffer.subarray(1 + length);
|
|
10404
|
+
return [type, Buffer.from(payload)];
|
|
10405
|
+
}
|
|
10406
|
+
processMessages() {
|
|
10407
|
+
let msg;
|
|
10408
|
+
while ((msg = this.nextMessage()) !== null) {
|
|
10409
|
+
const [type, payload] = msg;
|
|
10410
|
+
const p = this.pending;
|
|
10411
|
+
switch (type) {
|
|
10412
|
+
case 84: {
|
|
10413
|
+
if (!p)
|
|
10414
|
+
break;
|
|
10415
|
+
const count = payload.readInt16BE(0);
|
|
10416
|
+
let off = 2;
|
|
10417
|
+
const columns = [];
|
|
10418
|
+
for (let i = 0;i < count; i++) {
|
|
10419
|
+
const end = payload.indexOf(0, off);
|
|
10420
|
+
const name = payload.toString("utf8", off, end);
|
|
10421
|
+
off = end + 1;
|
|
10422
|
+
const typeOid = payload.readInt32BE(off + 6);
|
|
10423
|
+
off += 18;
|
|
10424
|
+
columns.push({ name, typeOid });
|
|
10425
|
+
}
|
|
10426
|
+
p.columns = columns;
|
|
10427
|
+
break;
|
|
10428
|
+
}
|
|
10429
|
+
case 68: {
|
|
10430
|
+
if (!p)
|
|
10431
|
+
break;
|
|
10432
|
+
const count = payload.readInt16BE(0);
|
|
10433
|
+
let off = 2;
|
|
10434
|
+
const row = {};
|
|
10435
|
+
for (let i = 0;i < count; i++) {
|
|
10436
|
+
const len = payload.readInt32BE(off);
|
|
10437
|
+
off += 4;
|
|
10438
|
+
let value = null;
|
|
10439
|
+
if (len >= 0) {
|
|
10440
|
+
value = decodeValue(payload.toString("utf8", off, off + len), p.columns[i]?.typeOid ?? 25);
|
|
10441
|
+
off += len;
|
|
10442
|
+
}
|
|
10443
|
+
row[p.columns[i]?.name ?? `col${i}`] = value;
|
|
10444
|
+
}
|
|
10445
|
+
if (p.results.length === 0)
|
|
10446
|
+
p.results.push({ rows: [] });
|
|
10447
|
+
p.results[p.results.length - 1].rows.push(row);
|
|
10448
|
+
break;
|
|
10449
|
+
}
|
|
10450
|
+
case 67: {
|
|
10451
|
+
if (!p)
|
|
10452
|
+
break;
|
|
10453
|
+
const tag = payload.toString("utf8", 0, payload.length - 1);
|
|
10454
|
+
const parts = tag.split(" ");
|
|
10455
|
+
const affected = parseInt(parts[parts.length - 1], 10);
|
|
10456
|
+
if (p.results.length === 0)
|
|
10457
|
+
p.results.push({ rows: [] });
|
|
10458
|
+
const current = p.results[p.results.length - 1];
|
|
10459
|
+
if (!Number.isNaN(affected))
|
|
10460
|
+
current.affectedRows = affected;
|
|
10461
|
+
p.results.push({ rows: [] });
|
|
10462
|
+
p.columns = [];
|
|
10463
|
+
break;
|
|
10464
|
+
}
|
|
10465
|
+
case 69: {
|
|
10466
|
+
if (p)
|
|
10467
|
+
p.error = new PgWireError(parseErrorFields(payload));
|
|
10468
|
+
break;
|
|
10469
|
+
}
|
|
10470
|
+
case 65: {
|
|
10471
|
+
payload.readInt32BE(0);
|
|
10472
|
+
const channelEnd = payload.indexOf(0, 4);
|
|
10473
|
+
const channel = payload.toString("utf8", 4, channelEnd);
|
|
10474
|
+
const payloadEnd = payload.indexOf(0, channelEnd + 1);
|
|
10475
|
+
const body = payload.toString("utf8", channelEnd + 1, payloadEnd);
|
|
10476
|
+
this.onNotification?.(channel, body);
|
|
10477
|
+
break;
|
|
10478
|
+
}
|
|
10479
|
+
case 90: {
|
|
10480
|
+
if (!p)
|
|
10481
|
+
break;
|
|
10482
|
+
this.pending = null;
|
|
10483
|
+
if (p.error)
|
|
10484
|
+
p.reject(p.error);
|
|
10485
|
+
else {
|
|
10486
|
+
const results = p.results.filter((r, i) => i < p.results.length - 1 || r.rows.length > 0 || r.affectedRows !== undefined);
|
|
10487
|
+
p.resolve(results.length > 0 ? results : [{ rows: [] }]);
|
|
10488
|
+
}
|
|
10489
|
+
break;
|
|
10490
|
+
}
|
|
10491
|
+
}
|
|
10492
|
+
}
|
|
10493
|
+
}
|
|
10494
|
+
}
|
|
10495
|
+
var hmac = (key, data) => createHmac("sha256", key).update(data, "utf8").digest();
|
|
10496
|
+
var sha256 = (b) => createHash("sha256").update(b).digest();
|
|
10497
|
+
var md5Hex = (b) => createHash("md5").update(b).digest("hex");
|
|
10498
|
+
function xorBuffers(a, b) {
|
|
10499
|
+
const out = Buffer.alloc(a.length);
|
|
10500
|
+
for (let i = 0;i < a.length; i++)
|
|
10501
|
+
out[i] = a[i] ^ b[i];
|
|
10502
|
+
return out;
|
|
10503
|
+
}
|
|
10504
|
+
function scramAttrs(s) {
|
|
10505
|
+
const out = {};
|
|
10506
|
+
for (const part of s.split(",")) {
|
|
10507
|
+
const eq = part.indexOf("=");
|
|
10508
|
+
if (eq > 0)
|
|
10509
|
+
out[part.slice(0, eq)] = part.slice(eq + 1);
|
|
10510
|
+
}
|
|
10511
|
+
return out;
|
|
10512
|
+
}
|
|
10513
|
+
function message(type, body) {
|
|
10514
|
+
const out = Buffer.alloc(5 + body.length);
|
|
10515
|
+
out[0] = type;
|
|
10516
|
+
out.writeInt32BE(4 + body.length, 1);
|
|
10517
|
+
body.copy(out, 5);
|
|
10518
|
+
return out;
|
|
10519
|
+
}
|
|
10520
|
+
var cstring = (s) => Buffer.from(s + "\x00", "utf8");
|
|
10521
|
+
var int16 = (n) => {
|
|
10522
|
+
const b = Buffer.alloc(2);
|
|
10523
|
+
b.writeInt16BE(n);
|
|
10524
|
+
return b;
|
|
10525
|
+
};
|
|
10526
|
+
var int32 = (n) => {
|
|
10527
|
+
const b = Buffer.alloc(4);
|
|
10528
|
+
b.writeInt32BE(n);
|
|
10529
|
+
return b;
|
|
10530
|
+
};
|
|
10531
|
+
function parseErrorFields(payload) {
|
|
10532
|
+
const fields = new Map;
|
|
10533
|
+
let off = 0;
|
|
10534
|
+
while (off < payload.length && payload[off] !== 0) {
|
|
10535
|
+
const key = String.fromCharCode(payload[off]);
|
|
10536
|
+
const end = payload.indexOf(0, off + 1);
|
|
10537
|
+
fields.set(key, payload.toString("utf8", off + 1, end));
|
|
10538
|
+
off = end + 1;
|
|
10539
|
+
}
|
|
10540
|
+
return fields;
|
|
10541
|
+
}
|
|
10542
|
+
function decodeValue(text, oid) {
|
|
10543
|
+
switch (oid) {
|
|
10544
|
+
case 16:
|
|
10545
|
+
return text === "t";
|
|
10546
|
+
case 20: {
|
|
10547
|
+
const n = Number(text);
|
|
10548
|
+
return Number.isSafeInteger(n) ? n : text;
|
|
10549
|
+
}
|
|
10550
|
+
case 21:
|
|
10551
|
+
case 23:
|
|
10552
|
+
case 26:
|
|
10553
|
+
return Number(text);
|
|
10554
|
+
case 700:
|
|
10555
|
+
case 701:
|
|
10556
|
+
return Number(text);
|
|
10557
|
+
case 114:
|
|
10558
|
+
case 3802:
|
|
10559
|
+
return JSON.parse(text);
|
|
10560
|
+
case 1114:
|
|
10561
|
+
return new Date(text.replace(" ", "T") + "Z");
|
|
10562
|
+
case 1184: {
|
|
10563
|
+
let iso3 = text.replace(" ", "T");
|
|
10564
|
+
if (/[+-]\d\d$/.test(iso3))
|
|
10565
|
+
iso3 += ":00";
|
|
10566
|
+
return new Date(iso3);
|
|
10567
|
+
}
|
|
10568
|
+
case 1000:
|
|
10569
|
+
return parsePgArray(text).map((v) => v === "t");
|
|
10570
|
+
case 1007:
|
|
10571
|
+
return parsePgArray(text).map((v) => v === null ? null : Number(v));
|
|
10572
|
+
case 1016:
|
|
10573
|
+
return parsePgArray(text).map((v) => {
|
|
10574
|
+
if (v === null)
|
|
10575
|
+
return null;
|
|
10576
|
+
const n = Number(v);
|
|
10577
|
+
return Number.isSafeInteger(n) ? n : v;
|
|
10578
|
+
});
|
|
10579
|
+
case 1003:
|
|
10580
|
+
case 1009:
|
|
10581
|
+
case 1015:
|
|
10582
|
+
return parsePgArray(text);
|
|
10583
|
+
default:
|
|
10584
|
+
return text;
|
|
10585
|
+
}
|
|
10586
|
+
}
|
|
10587
|
+
function parsePgArray(text) {
|
|
10588
|
+
const out = [];
|
|
10589
|
+
if (text.length < 2)
|
|
10590
|
+
return out;
|
|
10591
|
+
let i = 1;
|
|
10592
|
+
while (i < text.length - 1) {
|
|
10593
|
+
if (text[i] === ",") {
|
|
10594
|
+
i++;
|
|
10595
|
+
continue;
|
|
10596
|
+
}
|
|
10597
|
+
if (text[i] === '"') {
|
|
10598
|
+
let value = "";
|
|
10599
|
+
i++;
|
|
10600
|
+
while (text[i] !== '"') {
|
|
10601
|
+
if (text[i] === "\\")
|
|
10602
|
+
i++;
|
|
10603
|
+
value += text[i++];
|
|
10604
|
+
}
|
|
10605
|
+
i++;
|
|
10606
|
+
out.push(value);
|
|
10607
|
+
} else {
|
|
10608
|
+
let value = "";
|
|
10609
|
+
while (i < text.length - 1 && text[i] !== ",")
|
|
10610
|
+
value += text[i++];
|
|
10611
|
+
out.push(value === "NULL" ? null : value);
|
|
10612
|
+
}
|
|
10613
|
+
}
|
|
10614
|
+
return out;
|
|
10615
|
+
}
|
|
10616
|
+
|
|
10617
|
+
// src/runtime/db/engine.ts
|
|
10618
|
+
class Mutex {
|
|
10619
|
+
tail = Promise.resolve();
|
|
10620
|
+
async lock() {
|
|
10621
|
+
let release;
|
|
10622
|
+
const next = new Promise((r) => release = r);
|
|
10623
|
+
const prev = this.tail;
|
|
10624
|
+
this.tail = this.tail.then(() => next);
|
|
10625
|
+
await prev;
|
|
10626
|
+
return release;
|
|
10627
|
+
}
|
|
10628
|
+
async run(fn) {
|
|
10629
|
+
const release = await this.lock();
|
|
10630
|
+
try {
|
|
10631
|
+
return await fn();
|
|
10632
|
+
} finally {
|
|
10633
|
+
release();
|
|
10634
|
+
}
|
|
10635
|
+
}
|
|
10636
|
+
}
|
|
10637
|
+
|
|
10638
|
+
// src/runtime/node/native/wire-engine.ts
|
|
10639
|
+
async function buildWireEngine(options) {
|
|
10640
|
+
const queryClient = await options.connect();
|
|
10641
|
+
let listenerClient;
|
|
10642
|
+
try {
|
|
10643
|
+
listenerClient = await options.connect();
|
|
10644
|
+
} catch (error) {
|
|
10645
|
+
await queryClient.close().catch(() => {});
|
|
10646
|
+
throw error;
|
|
10647
|
+
}
|
|
10648
|
+
const queryMutex = new Mutex;
|
|
10649
|
+
const listenerMutex = new Mutex;
|
|
10650
|
+
const listeners = new Map;
|
|
10651
|
+
listenerClient.onNotification = (channel, payload) => {
|
|
10652
|
+
for (const listener of listeners.get(channel) ?? [])
|
|
10653
|
+
listener(payload);
|
|
10654
|
+
};
|
|
10655
|
+
const transactionClient = {
|
|
10656
|
+
async query(sql, params) {
|
|
10657
|
+
const queryResult = await queryClient.query(sql, normalizeParams(params));
|
|
10658
|
+
return { rows: queryResult.rows, affectedRows: queryResult.affectedRows };
|
|
10659
|
+
},
|
|
10660
|
+
async exec(sql) {
|
|
10661
|
+
await queryClient.exec(sql);
|
|
10662
|
+
}
|
|
10663
|
+
};
|
|
10664
|
+
let closePromise = null;
|
|
10665
|
+
return {
|
|
10666
|
+
query(sql, params) {
|
|
10667
|
+
return queryMutex.run(() => transactionClient.query(sql, params));
|
|
10668
|
+
},
|
|
10669
|
+
exec(sql) {
|
|
10670
|
+
return queryMutex.run(() => transactionClient.exec(sql));
|
|
10671
|
+
},
|
|
10672
|
+
transaction(callback) {
|
|
10673
|
+
return queryMutex.run(async () => {
|
|
10674
|
+
await queryClient.exec("begin");
|
|
10675
|
+
try {
|
|
10676
|
+
const response = await callback(transactionClient);
|
|
10677
|
+
await queryClient.exec("commit");
|
|
10678
|
+
return response;
|
|
10679
|
+
} catch (error) {
|
|
10680
|
+
await queryClient.exec("rollback").catch(() => {});
|
|
10681
|
+
throw error;
|
|
10682
|
+
}
|
|
10683
|
+
});
|
|
10684
|
+
},
|
|
10685
|
+
async listen(channel, listener) {
|
|
10686
|
+
return listenerMutex.run(async () => {
|
|
10687
|
+
let channelListeners = listeners.get(channel);
|
|
10688
|
+
if (!channelListeners) {
|
|
10689
|
+
channelListeners = new Set;
|
|
10690
|
+
await listenerClient.exec(`listen "${channel.replaceAll('"', '""')}"`);
|
|
10691
|
+
listeners.set(channel, channelListeners);
|
|
10692
|
+
}
|
|
10693
|
+
channelListeners.add(listener);
|
|
10694
|
+
return () => {
|
|
10695
|
+
channelListeners.delete(listener);
|
|
10696
|
+
};
|
|
10697
|
+
});
|
|
10698
|
+
},
|
|
10699
|
+
close() {
|
|
10700
|
+
closePromise ??= closeWireEngine(queryClient, listenerClient, options.onClose);
|
|
10701
|
+
return closePromise;
|
|
10702
|
+
}
|
|
10703
|
+
};
|
|
10704
|
+
}
|
|
10705
|
+
async function closeWireEngine(queryClient, listenerClient, onClose) {
|
|
10706
|
+
const closeResults = await Promise.allSettled([queryClient.close(), listenerClient.close()]);
|
|
10707
|
+
let engineCleanupError;
|
|
10708
|
+
try {
|
|
10709
|
+
await onClose?.();
|
|
10710
|
+
} catch (error) {
|
|
10711
|
+
engineCleanupError = error;
|
|
10712
|
+
}
|
|
10713
|
+
const connectionErrors = closeResults.flatMap((closeResult) => closeResult.status === "rejected" ? [closeResult.reason] : []);
|
|
10714
|
+
if (engineCleanupError !== undefined)
|
|
10715
|
+
connectionErrors.push(engineCleanupError);
|
|
10716
|
+
if (connectionErrors.length > 0)
|
|
10717
|
+
throw new AggregateError(connectionErrors, "native database cleanup failed");
|
|
10718
|
+
}
|
|
10719
|
+
function normalizeParams(params) {
|
|
10720
|
+
return params?.map((parameter) => {
|
|
10721
|
+
if (parameter === null || parameter === undefined)
|
|
10722
|
+
return null;
|
|
10723
|
+
if (Array.isArray(parameter))
|
|
10724
|
+
return toPgArrayLiteral(parameter);
|
|
10725
|
+
if (parameter instanceof Date)
|
|
10726
|
+
return parameter.toISOString();
|
|
10727
|
+
if (typeof parameter === "object")
|
|
10728
|
+
return JSON.stringify(parameter);
|
|
10729
|
+
return parameter;
|
|
10730
|
+
});
|
|
10731
|
+
}
|
|
10732
|
+
function toPgArrayLiteral(array) {
|
|
10733
|
+
const encoded = array.map((element) => {
|
|
10734
|
+
if (element === null || element === undefined)
|
|
10735
|
+
return "NULL";
|
|
10736
|
+
if (Array.isArray(element))
|
|
10737
|
+
return toPgArrayLiteral(element);
|
|
10738
|
+
if (typeof element === "number" || typeof element === "boolean")
|
|
10739
|
+
return String(element);
|
|
10740
|
+
const text = typeof element === "object" ? JSON.stringify(element) : String(element);
|
|
10741
|
+
return `"${text.replaceAll("\\", "\\\\").replaceAll('"', "\\\"")}"`;
|
|
10742
|
+
});
|
|
10743
|
+
return `{${encoded.join(",")}}`;
|
|
10744
|
+
}
|
|
10745
|
+
|
|
10746
|
+
// src/runtime/node/native/engine.ts
|
|
10747
|
+
var DEFAULT_PG_VERSION = "17.7.0";
|
|
10748
|
+
var NATIVE_POSTGRES_MAJOR = DEFAULT_PG_VERSION.split(".")[0];
|
|
10749
|
+
function isNativeEngineSupported() {
|
|
10750
|
+
return (process.platform === "darwin" || process.platform === "linux") && (process.arch === "arm64" || process.arch === "x64") && (process.platform !== "linux" || isGlibcLinux());
|
|
10751
|
+
}
|
|
10752
|
+
function isGlibcLinux() {
|
|
10753
|
+
try {
|
|
10754
|
+
const version = execFileSync("ldd", ["--version"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
|
10755
|
+
return /glibc|gnu libc/i.test(version);
|
|
10756
|
+
} catch {
|
|
10757
|
+
return false;
|
|
10758
|
+
}
|
|
10759
|
+
}
|
|
10760
|
+
function target() {
|
|
10761
|
+
const arch = process.arch === "arm64" ? "aarch64" : process.arch === "x64" ? "x86_64" : null;
|
|
10762
|
+
if (!arch)
|
|
10763
|
+
throw new Error(`unsupported architecture for native engine: ${process.arch}`);
|
|
10764
|
+
if (process.platform === "darwin")
|
|
10765
|
+
return `${arch}-apple-darwin`;
|
|
10766
|
+
if (process.platform === "linux")
|
|
10767
|
+
return `${arch}-unknown-linux-gnu`;
|
|
10768
|
+
throw new Error(`unsupported platform for native engine: ${process.platform} (use the default PGlite engine)`);
|
|
10769
|
+
}
|
|
10770
|
+
function isCompleteInstall(dir) {
|
|
10771
|
+
return existsSync(join2(dir, "bin", "postgres")) && existsSync(join2(dir, "share", "postgres.bki"));
|
|
10772
|
+
}
|
|
10773
|
+
var PINNED_SHA256 = {
|
|
10774
|
+
"postgresql-17.7.0-x86_64-unknown-linux-gnu": "66ad03281a43624f955c8e16ac975cb0ab751e7edf8ba35308e3b08dd7d065c3",
|
|
10775
|
+
"postgresql-17.7.0-aarch64-unknown-linux-gnu": "89cc2f089880cc8e5e6b7a29387829ec4e4779427855bc0b9fa187c8fce33c8b",
|
|
10776
|
+
"postgresql-17.7.0-x86_64-apple-darwin": "0dd8c25173524bad4ae8ef6b970da1ac40f4c1f231150c416ccb8cd06feff8f2",
|
|
10777
|
+
"postgresql-17.7.0-aarch64-apple-darwin": "727ac08d20a704014a0d51eb3300aa0c8e292c1cf0a1c99d4f4b1002e1420220"
|
|
10778
|
+
};
|
|
10779
|
+
async function verifyTarball(tarball, key, url) {
|
|
10780
|
+
const actual = createHash2("sha256").update(readFileSync(tarball)).digest("hex");
|
|
10781
|
+
const pinned = PINNED_SHA256[key];
|
|
10782
|
+
if (pinned) {
|
|
10783
|
+
if (actual !== pinned) {
|
|
10784
|
+
throw new Error(`postgres binary checksum mismatch for ${key}: expected ${pinned}, got ${actual}`);
|
|
10785
|
+
}
|
|
10786
|
+
return;
|
|
10787
|
+
}
|
|
10788
|
+
const res = await fetchRelease(`${url}.sha256`);
|
|
10789
|
+
if (!res.ok)
|
|
10790
|
+
throw new Error(`could not fetch checksum for ${key}: HTTP ${res.status}`);
|
|
10791
|
+
const expected = (await res.text()).trim().split(/\s+/)[0].toLowerCase();
|
|
10792
|
+
if (!/^[0-9a-f]{64}$/.test(expected))
|
|
10793
|
+
throw new Error(`malformed published checksum for ${key}`);
|
|
10794
|
+
if (actual !== expected) {
|
|
10795
|
+
throw new Error(`postgres binary checksum mismatch for ${key}: expected ${expected}, got ${actual}`);
|
|
10796
|
+
}
|
|
10797
|
+
}
|
|
10798
|
+
async function ensurePostgres(version = DEFAULT_PG_VERSION, cacheDir, log) {
|
|
10799
|
+
const t = target();
|
|
10800
|
+
const root = cacheDir ?? join2(homedir(), ".cache", "supacloud-lite");
|
|
10801
|
+
const dir = join2(root, `postgresql-${version}-${t}`);
|
|
10802
|
+
if (isCompleteInstall(dir))
|
|
10803
|
+
return dir;
|
|
10804
|
+
const url = `https://github.com/theseus-rs/postgresql-binaries/releases/download/${version}/postgresql-${version}-${t}.tar.gz`;
|
|
10805
|
+
mkdirSync(root, { recursive: true });
|
|
10806
|
+
const uniq = `${process.pid}-${randomBytes2(6).toString("hex")}`;
|
|
10807
|
+
const tarball = join2(root, `pg-${version}-${uniq}.tar.gz`);
|
|
10808
|
+
const tmpDir = join2(root, `.tmp-${version}-${t}-${uniq}`);
|
|
10809
|
+
try {
|
|
10810
|
+
if (isCompleteInstall(dir))
|
|
10811
|
+
return dir;
|
|
10812
|
+
log?.(`downloading postgres ${version} (${t})\u2026`);
|
|
10813
|
+
const res = await fetchRelease(url);
|
|
10814
|
+
if (!res.ok)
|
|
10815
|
+
throw new Error(`failed to download ${url}: HTTP ${res.status}`);
|
|
10816
|
+
await writeFile2(tarball, Buffer.from(await res.arrayBuffer()));
|
|
10817
|
+
await verifyTarball(tarball, `postgresql-${version}-${t}`, url);
|
|
10818
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
10819
|
+
await extractTar({ cwd: tmpDir, file: tarball, gzip: true, preserveOwner: false, strict: true, strip: 1 });
|
|
10820
|
+
if (!isCompleteInstall(tmpDir))
|
|
10821
|
+
throw new Error("postgres archive extracted incompletely");
|
|
10822
|
+
try {
|
|
10823
|
+
renameSync(tmpDir, dir);
|
|
10824
|
+
} catch {
|
|
10825
|
+
if (!isCompleteInstall(dir)) {
|
|
10826
|
+
rmSync(dir, { recursive: true, force: true });
|
|
10827
|
+
renameSync(tmpDir, dir);
|
|
10828
|
+
}
|
|
10829
|
+
}
|
|
10830
|
+
log?.(`postgres installed to ${dir}`);
|
|
10831
|
+
return dir;
|
|
10832
|
+
} finally {
|
|
10833
|
+
rmSync(tarball, { force: true });
|
|
10834
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
10835
|
+
}
|
|
10836
|
+
}
|
|
10837
|
+
async function fetchRelease(url) {
|
|
10838
|
+
let lastError;
|
|
10839
|
+
for (let attempt = 1;attempt <= 3; attempt++) {
|
|
10840
|
+
try {
|
|
10841
|
+
const response = await fetch(url);
|
|
10842
|
+
if (response.ok || response.status < 500)
|
|
10843
|
+
return response;
|
|
10844
|
+
lastError = new Error(`failed to download ${url}: HTTP ${response.status}`);
|
|
10845
|
+
} catch (error) {
|
|
10846
|
+
lastError = error;
|
|
10847
|
+
}
|
|
10848
|
+
if (attempt < 3)
|
|
10849
|
+
await new Promise((resolve2) => setTimeout(resolve2, attempt * 500));
|
|
10850
|
+
}
|
|
10851
|
+
throw lastError instanceof Error ? lastError : new Error(`failed to download ${url}`);
|
|
10852
|
+
}
|
|
10853
|
+
var TUNED_CONF = `
|
|
10854
|
+
# supacloud-lite: memory-lean settings for an embedded, single-app Postgres
|
|
10855
|
+
listen_addresses = ''
|
|
10856
|
+
shared_buffers = 16MB
|
|
10857
|
+
dynamic_shared_memory_type = posix
|
|
10858
|
+
max_connections = 10
|
|
10859
|
+
wal_level = minimal
|
|
10860
|
+
max_wal_senders = 0
|
|
10861
|
+
logging_collector = off
|
|
10862
|
+
`;
|
|
10863
|
+
async function createNativeEngine(opts) {
|
|
10864
|
+
const releaseLock = await acquireDataDirLock(opts.dataDir, "native PostgreSQL");
|
|
10865
|
+
let socketDirectory;
|
|
10866
|
+
let postgres;
|
|
10867
|
+
let removeExitHandler;
|
|
10868
|
+
try {
|
|
10869
|
+
const installDir = await ensurePostgres(opts.version, opts.cacheDir, opts.log);
|
|
10870
|
+
const bin = (name) => join2(installDir, "bin", name);
|
|
10871
|
+
if (!existsSync(join2(opts.dataDir, "PG_VERSION"))) {
|
|
10872
|
+
mkdirSync(opts.dataDir, { recursive: true });
|
|
10873
|
+
try {
|
|
10874
|
+
execFileSync(bin("initdb"), ["-U", "postgres", "-A", "trust", "-E", "UTF8", "-D", opts.dataDir], {
|
|
10875
|
+
stdio: "pipe"
|
|
10876
|
+
});
|
|
10877
|
+
} catch (error) {
|
|
10878
|
+
const stderr = error.stderr?.toString() ?? "";
|
|
10879
|
+
throw new Error(`initdb failed:
|
|
10880
|
+
${stderr || error.message}`);
|
|
10881
|
+
}
|
|
10882
|
+
appendFileSync(join2(opts.dataDir, "postgresql.conf"), TUNED_CONF);
|
|
10883
|
+
}
|
|
10884
|
+
removeStalePidFile(join2(opts.dataDir, "postmaster.pid"));
|
|
10885
|
+
socketDirectory = mkdtempSync(join2(tmpdir(), "scl-"));
|
|
10886
|
+
chmodSync(socketDirectory, 448);
|
|
10887
|
+
postgres = spawn(bin("postgres"), ["-D", opts.dataDir, "-k", socketDirectory, "-c", "timezone=UTC"], {
|
|
10888
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
10889
|
+
detached: false
|
|
10890
|
+
});
|
|
10891
|
+
let postgresExited = false;
|
|
10892
|
+
let postgresStderr = "";
|
|
10893
|
+
postgres.stderr?.on("data", (chunk) => {
|
|
10894
|
+
postgresStderr = (postgresStderr + chunk.toString()).slice(-4000);
|
|
10895
|
+
});
|
|
10896
|
+
postgres.on("exit", () => postgresExited = true);
|
|
10897
|
+
const killPostgres = () => {
|
|
10898
|
+
if (!postgresExited)
|
|
10899
|
+
postgres?.kill("SIGTERM");
|
|
10900
|
+
};
|
|
10901
|
+
process.once("exit", killPostgres);
|
|
10902
|
+
removeExitHandler = () => process.off("exit", killPostgres);
|
|
10903
|
+
const socketPath = join2(socketDirectory, ".s.PGSQL.5432");
|
|
10904
|
+
const connect = async () => {
|
|
10905
|
+
const deadline = Date.now() + 20000;
|
|
10906
|
+
while (Date.now() <= deadline) {
|
|
10907
|
+
try {
|
|
10908
|
+
return await PgWireClient.connect({ socketPath, user: "postgres", database: "postgres" });
|
|
10909
|
+
} catch (error) {
|
|
10910
|
+
if (postgresExited) {
|
|
10911
|
+
const detail = postgresStderr.trim();
|
|
10912
|
+
throw new Error(`embedded postgres failed to start${detail ? `:
|
|
10913
|
+
${detail}` : " (no output)"}
|
|
10914
|
+
|
|
10915
|
+
` + `data dir: ${opts.dataDir}
|
|
10916
|
+
` + "If a previous run is still holding it, stop it; or delete the data dir to start fresh.");
|
|
10917
|
+
}
|
|
10918
|
+
await new Promise((resolve2) => setTimeout(resolve2, 150));
|
|
10919
|
+
}
|
|
10920
|
+
}
|
|
10921
|
+
throw new Error(`timed out waiting for embedded postgres at ${socketPath}`);
|
|
10922
|
+
};
|
|
10923
|
+
return await buildWireEngine({
|
|
10924
|
+
connect,
|
|
10925
|
+
onClose: async () => {
|
|
10926
|
+
removeExitHandler?.();
|
|
10927
|
+
await stopPostgres(postgres, () => postgresExited);
|
|
10928
|
+
rmSync(socketDirectory, { recursive: true, force: true });
|
|
10929
|
+
await releaseLock();
|
|
10930
|
+
}
|
|
10931
|
+
});
|
|
10932
|
+
} catch (error) {
|
|
10933
|
+
removeExitHandler?.();
|
|
10934
|
+
if (postgres)
|
|
10935
|
+
await stopPostgres(postgres, () => postgres.exitCode !== null);
|
|
10936
|
+
if (socketDirectory)
|
|
10937
|
+
rmSync(socketDirectory, { recursive: true, force: true });
|
|
10938
|
+
await releaseLock();
|
|
10939
|
+
throw error;
|
|
10940
|
+
}
|
|
10941
|
+
}
|
|
10942
|
+
async function stopPostgres(postgres, hasExited) {
|
|
10943
|
+
if (hasExited())
|
|
10944
|
+
return;
|
|
10945
|
+
postgres.kill("SIGINT");
|
|
10946
|
+
await new Promise((resolve2) => {
|
|
10947
|
+
const killTimeout = setTimeout(() => {
|
|
10948
|
+
postgres.kill("SIGKILL");
|
|
10949
|
+
resolve2();
|
|
10950
|
+
}, 5000);
|
|
10951
|
+
postgres.once("exit", () => {
|
|
10952
|
+
clearTimeout(killTimeout);
|
|
10953
|
+
resolve2();
|
|
10954
|
+
});
|
|
10955
|
+
});
|
|
10956
|
+
}
|
|
10957
|
+
function removeStalePidFile(pidPath) {
|
|
10958
|
+
if (!existsSync(pidPath))
|
|
10959
|
+
return;
|
|
10960
|
+
try {
|
|
10961
|
+
const pid = Number.parseInt(readFileSync(pidPath, "utf8").split(`
|
|
10962
|
+
`)[0]?.trim() ?? "", 10);
|
|
10963
|
+
if (!pid) {
|
|
10964
|
+
rmSync(pidPath, { force: true });
|
|
10965
|
+
return;
|
|
10966
|
+
}
|
|
10967
|
+
try {
|
|
10968
|
+
process.kill(pid, 0);
|
|
10969
|
+
} catch {
|
|
10970
|
+
rmSync(pidPath, { force: true });
|
|
10971
|
+
}
|
|
10972
|
+
} catch {}
|
|
10973
|
+
}
|
|
8873
10974
|
// src/project-runtime.ts
|
|
8874
|
-
import { chmod, link, lstat, mkdir as mkdir4, readFile as readFile6, realpath as realpath2, unlink as
|
|
8875
|
-
import { dirname as
|
|
10975
|
+
import { chmod, link, lstat, mkdir as mkdir4, readFile as readFile6, realpath as realpath2, unlink as unlink2, writeFile as writeFile4 } from "fs/promises";
|
|
10976
|
+
import { dirname as dirname4, isAbsolute, join as join7, parse, relative, resolve as resolve2 } from "path";
|
|
8876
10977
|
|
|
8877
10978
|
// src/runtime/node/config-toml.ts
|
|
8878
|
-
import { readFileSync } from "fs";
|
|
8879
|
-
import { join as
|
|
10979
|
+
import { readFileSync as readFileSync2 } from "fs";
|
|
10980
|
+
import { join as join3 } from "path";
|
|
8880
10981
|
function emptyTable() {
|
|
8881
10982
|
return { values: new Map, children: new Map };
|
|
8882
10983
|
}
|
|
8883
10984
|
function loadConfigToml(projectDir, env = process.env) {
|
|
8884
10985
|
let text;
|
|
8885
10986
|
try {
|
|
8886
|
-
text =
|
|
10987
|
+
text = readFileSync2(join3(projectDir, "supabase", "config.toml"), "utf8");
|
|
8887
10988
|
} catch {
|
|
8888
10989
|
return emptyTable();
|
|
8889
10990
|
}
|
|
@@ -9248,16 +11349,16 @@ function readFunctions(root) {
|
|
|
9248
11349
|
}
|
|
9249
11350
|
|
|
9250
11351
|
// src/runtime/node/load-functions.ts
|
|
9251
|
-
import { readdir, readFile as readFile4, realpath, rm as
|
|
9252
|
-
import { join as
|
|
11352
|
+
import { readdir, readFile as readFile4, realpath, rm as rm3, stat } from "fs/promises";
|
|
11353
|
+
import { dirname as dirname3, join as join5 } from "path";
|
|
9253
11354
|
import { pathToFileURL } from "url";
|
|
9254
11355
|
|
|
9255
11356
|
// src/runtime/node/bundle-function.ts
|
|
9256
|
-
import { createHash } from "crypto";
|
|
9257
|
-
import { mkdir as mkdir3, readFile as readFile3, writeFile as
|
|
9258
|
-
import { existsSync } from "fs";
|
|
9259
|
-
import { tmpdir } from "os";
|
|
9260
|
-
import { join as
|
|
11357
|
+
import { createHash as createHash3 } from "crypto";
|
|
11358
|
+
import { mkdir as mkdir3, readFile as readFile3, rm as rm2, writeFile as writeFile3 } from "fs/promises";
|
|
11359
|
+
import { existsSync as existsSync2 } from "fs";
|
|
11360
|
+
import { tmpdir as tmpdir2 } from "os";
|
|
11361
|
+
import { join as join4 } from "path";
|
|
9261
11362
|
function rewriteRemoteSpecifier(spec) {
|
|
9262
11363
|
if (spec.startsWith("npm:"))
|
|
9263
11364
|
return `https://esm.sh/${spec.slice(4)}`;
|
|
@@ -9265,18 +11366,18 @@ function rewriteRemoteSpecifier(spec) {
|
|
|
9265
11366
|
return `https://esm.sh/jsr/${spec.slice(4)}`;
|
|
9266
11367
|
return spec;
|
|
9267
11368
|
}
|
|
9268
|
-
var HTTP_CACHE =
|
|
11369
|
+
var HTTP_CACHE = join4(tmpdir2(), "supacloud-lite-fn-http");
|
|
9269
11370
|
async function fetchModule(url) {
|
|
9270
|
-
const key =
|
|
9271
|
-
const cached =
|
|
9272
|
-
if (
|
|
11371
|
+
const key = createHash3("sha256").update(url).digest("hex");
|
|
11372
|
+
const cached = join4(HTTP_CACHE, key);
|
|
11373
|
+
if (existsSync2(cached))
|
|
9273
11374
|
return readFile3(cached, "utf8");
|
|
9274
11375
|
const res = await fetch(url, { redirect: "follow" });
|
|
9275
11376
|
if (!res.ok)
|
|
9276
11377
|
throw new Error(`failed to fetch ${url}: HTTP ${res.status}`);
|
|
9277
11378
|
const body = await res.text();
|
|
9278
11379
|
await mkdir3(HTTP_CACHE, { recursive: true });
|
|
9279
|
-
await
|
|
11380
|
+
await writeFile3(cached, body);
|
|
9280
11381
|
return body;
|
|
9281
11382
|
}
|
|
9282
11383
|
function remotePlugin() {
|
|
@@ -9299,28 +11400,37 @@ function remotePlugin() {
|
|
|
9299
11400
|
};
|
|
9300
11401
|
}
|
|
9301
11402
|
async function bundleFunction(entryPath, name) {
|
|
9302
|
-
const outDir =
|
|
11403
|
+
const outDir = join4(tmpdir2(), "supacloud-lite-fn-bundle", name);
|
|
9303
11404
|
await mkdir3(outDir, { recursive: true });
|
|
9304
|
-
|
|
9305
|
-
|
|
9306
|
-
|
|
9307
|
-
|
|
9308
|
-
|
|
9309
|
-
|
|
9310
|
-
|
|
9311
|
-
|
|
9312
|
-
|
|
9313
|
-
|
|
11405
|
+
try {
|
|
11406
|
+
const buildOutput = await Bun.build({
|
|
11407
|
+
entrypoints: [entryPath],
|
|
11408
|
+
format: "esm",
|
|
11409
|
+
target: "bun",
|
|
11410
|
+
outdir: outDir,
|
|
11411
|
+
naming: `${name}-[hash].[ext]`,
|
|
11412
|
+
plugins: [remotePlugin()]
|
|
11413
|
+
});
|
|
11414
|
+
if (!buildOutput.success || buildOutput.outputs.length === 0) {
|
|
11415
|
+
throw new Error(buildOutput.logs.map((item) => item.message).join(`
|
|
9314
11416
|
`) || `failed to bundle ${entryPath}`);
|
|
11417
|
+
}
|
|
11418
|
+
return buildOutput.outputs[0].path;
|
|
11419
|
+
} catch (buildError) {
|
|
11420
|
+
try {
|
|
11421
|
+
await rm2(outDir, { recursive: true, force: true });
|
|
11422
|
+
} catch (cleanupError) {
|
|
11423
|
+
throw new AggregateError([buildError, cleanupError], `failed to clean function bundle directory ${outDir}`);
|
|
11424
|
+
}
|
|
11425
|
+
throw buildError;
|
|
9315
11426
|
}
|
|
9316
|
-
return result.outputs[0].path;
|
|
9317
11427
|
}
|
|
9318
11428
|
|
|
9319
11429
|
// src/runtime/node/load-functions.ts
|
|
9320
11430
|
async function loadFunctionEnv(projectDir) {
|
|
9321
11431
|
let text;
|
|
9322
11432
|
try {
|
|
9323
|
-
text = await readFile4(
|
|
11433
|
+
text = await readFile4(join5(projectDir, "supabase", "functions", ".env"), "utf8");
|
|
9324
11434
|
} catch {
|
|
9325
11435
|
return {};
|
|
9326
11436
|
}
|
|
@@ -9359,7 +11469,7 @@ async function loadFunctions2(projectDir, options = {}) {
|
|
|
9359
11469
|
}
|
|
9360
11470
|
async function loadFunctionsUnlocked(projectDir, options) {
|
|
9361
11471
|
const functions = new Map;
|
|
9362
|
-
const root =
|
|
11472
|
+
const root = join5(projectDir, "supabase", "functions");
|
|
9363
11473
|
let entries = [];
|
|
9364
11474
|
try {
|
|
9365
11475
|
entries = await readdir(root);
|
|
@@ -9372,10 +11482,10 @@ async function loadFunctionsUnlocked(projectDir, options) {
|
|
|
9372
11482
|
continue;
|
|
9373
11483
|
if (options[name]?.enabled === false)
|
|
9374
11484
|
continue;
|
|
9375
|
-
const dir =
|
|
11485
|
+
const dir = join5(root, name);
|
|
9376
11486
|
if (!(await stat(dir)).isDirectory())
|
|
9377
11487
|
continue;
|
|
9378
|
-
const candidates = options[name]?.entrypoint ? [
|
|
11488
|
+
const candidates = options[name]?.entrypoint ? [join5(projectDir, options[name].entrypoint)] : ["index.ts", "index.tsx", "index.js", "index.mjs"].map((f) => join5(dir, f));
|
|
9379
11489
|
for (const path of candidates) {
|
|
9380
11490
|
try {
|
|
9381
11491
|
await stat(path);
|
|
@@ -9396,11 +11506,12 @@ async function loadFunctionsUnlocked(projectDir, options) {
|
|
|
9396
11506
|
resetCapturedHandler();
|
|
9397
11507
|
const mod = await import(importUrl);
|
|
9398
11508
|
const denoHandler = takeCapturedHandler();
|
|
9399
|
-
const
|
|
11509
|
+
const defaultExport = mod.default;
|
|
11510
|
+
const handler = typeof defaultExport === "function" ? defaultExport : defaultExport && typeof defaultExport.fetch === "function" ? defaultExport.fetch.bind(defaultExport) : denoHandler ? (req) => denoHandler(req) : undefined;
|
|
9400
11511
|
if (handler) {
|
|
9401
11512
|
functions.set(name, handler);
|
|
9402
11513
|
} else {
|
|
9403
|
-
console.warn(` warning: function "${name}" has no default
|
|
11514
|
+
console.warn(` warning: function "${name}" has no default function, fetch object, or Deno.serve() handler, skipped`);
|
|
9404
11515
|
}
|
|
9405
11516
|
} catch (e) {
|
|
9406
11517
|
const msg = e instanceof Error ? e.message : String(e);
|
|
@@ -9411,7 +11522,7 @@ async function loadFunctionsUnlocked(projectDir, options) {
|
|
|
9411
11522
|
}
|
|
9412
11523
|
} finally {
|
|
9413
11524
|
if (bundledPath)
|
|
9414
|
-
await
|
|
11525
|
+
await rm3(dirname3(bundledPath), { recursive: true, force: true }).catch(() => {});
|
|
9415
11526
|
}
|
|
9416
11527
|
break;
|
|
9417
11528
|
}
|
|
@@ -9421,9 +11532,9 @@ async function loadFunctionsUnlocked(projectDir, options) {
|
|
|
9421
11532
|
|
|
9422
11533
|
// src/runtime/node/project.ts
|
|
9423
11534
|
import { readdir as readdir2, readFile as readFile5 } from "fs/promises";
|
|
9424
|
-
import { join as
|
|
11535
|
+
import { join as join6 } from "path";
|
|
9425
11536
|
async function loadSupabaseProject(projectDir, seed = {}) {
|
|
9426
|
-
const migrationsDir =
|
|
11537
|
+
const migrationsDir = join6(projectDir, "supabase", "migrations");
|
|
9427
11538
|
const migrations = [];
|
|
9428
11539
|
let entries = [];
|
|
9429
11540
|
try {
|
|
@@ -9435,19 +11546,19 @@ async function loadSupabaseProject(projectDir, seed = {}) {
|
|
|
9435
11546
|
for (const entry of entries.sort()) {
|
|
9436
11547
|
if (!entry.endsWith(".sql"))
|
|
9437
11548
|
continue;
|
|
9438
|
-
const sql = await readFile5(
|
|
11549
|
+
const sql = await readFile5(join6(migrationsDir, entry), "utf8");
|
|
9439
11550
|
migrations.push({ name: entry.replace(/\.sql$/, ""), sql });
|
|
9440
11551
|
}
|
|
9441
11552
|
let seedSql;
|
|
9442
11553
|
if (seed.enabled !== false) {
|
|
9443
11554
|
const parts = [];
|
|
9444
|
-
const supabaseDir =
|
|
11555
|
+
const supabaseDir = join6(projectDir, "supabase");
|
|
9445
11556
|
for (const configuredPath of seed.paths ?? ["seed.sql"]) {
|
|
9446
11557
|
const pattern = configuredPath.replace(/^\.\//, "");
|
|
9447
11558
|
const matches = /[*?[\]{}]/.test(pattern) ? [...new Bun.Glob(pattern).scanSync({ cwd: supabaseDir, onlyFiles: true })].sort() : [pattern];
|
|
9448
11559
|
for (const relativePath of matches) {
|
|
9449
11560
|
try {
|
|
9450
|
-
parts.push(await readFile5(
|
|
11561
|
+
parts.push(await readFile5(join6(supabaseDir, relativePath), "utf8"));
|
|
9451
11562
|
} catch (error) {
|
|
9452
11563
|
if (!isNotFound(error))
|
|
9453
11564
|
throw error;
|
|
@@ -9468,14 +11579,16 @@ function isNotFound(error) {
|
|
|
9468
11579
|
function resolveProjectPaths(options = {}) {
|
|
9469
11580
|
const projectDir = resolve2(options.projectDir ?? process.cwd());
|
|
9470
11581
|
const stateDir = resolvePath(projectDir, options.stateDir ?? process.env.SUPACLOUD_LITE_STATE_DIR ?? ".supacloud-lite");
|
|
9471
|
-
const
|
|
9472
|
-
const
|
|
11582
|
+
const databaseEngine = resolveDatabaseEngine(options.engine, options.memory);
|
|
11583
|
+
const dataDir = options.memory ? undefined : resolvePath(projectDir, options.dataDir ?? process.env.SUPACLOUD_LITE_DATA_DIR ?? join7(stateDir, databaseEngine === "native" ? "pgdata" : "db"));
|
|
11584
|
+
const storageDir = resolvePath(projectDir, options.storageDir ?? process.env.SUPACLOUD_LITE_STORAGE_DIR ?? join7(stateDir, "storage"));
|
|
9473
11585
|
return {
|
|
9474
11586
|
projectDir,
|
|
9475
11587
|
stateDir,
|
|
9476
11588
|
dataDir,
|
|
9477
11589
|
storageDir,
|
|
9478
|
-
secretsFile:
|
|
11590
|
+
secretsFile: join7(stateDir, "secrets.json"),
|
|
11591
|
+
databaseEngine
|
|
9479
11592
|
};
|
|
9480
11593
|
}
|
|
9481
11594
|
async function ensureProjectSecrets(paths) {
|
|
@@ -9498,7 +11611,7 @@ async function ensureProjectSecrets(paths) {
|
|
|
9498
11611
|
};
|
|
9499
11612
|
const temporaryFile = `${paths.secretsFile}.${crypto.randomUUID()}.tmp`;
|
|
9500
11613
|
try {
|
|
9501
|
-
await
|
|
11614
|
+
await writeFile4(temporaryFile, `${JSON.stringify(candidate, null, 2)}
|
|
9502
11615
|
`, { mode: 384, flag: "wx" });
|
|
9503
11616
|
await link(temporaryFile, paths.secretsFile);
|
|
9504
11617
|
stored = candidate;
|
|
@@ -9507,7 +11620,7 @@ async function ensureProjectSecrets(paths) {
|
|
|
9507
11620
|
throw error2;
|
|
9508
11621
|
stored = validateSecrets(JSON.parse(await readFile6(paths.secretsFile, "utf8")));
|
|
9509
11622
|
} finally {
|
|
9510
|
-
await
|
|
11623
|
+
await unlink2(temporaryFile).catch((error2) => {
|
|
9511
11624
|
if (error2.code !== "ENOENT")
|
|
9512
11625
|
throw error2;
|
|
9513
11626
|
});
|
|
@@ -9539,42 +11652,58 @@ async function createProjectBackend(options = {}) {
|
|
|
9539
11652
|
const webhooks = options.includeWebhooks === false ? [] : await loadWebhooks(paths.projectDir);
|
|
9540
11653
|
const configuredStorageBackend = options.storageDriver ? "fs" : resolveStorageBackend(options.storageBackend);
|
|
9541
11654
|
const storageBackend = options.storageDriver ? "custom" : configuredStorageBackend;
|
|
11655
|
+
const databaseEngine = paths.databaseEngine;
|
|
9542
11656
|
if (paths.dataDir) {
|
|
9543
11657
|
await mkdir4(paths.dataDir, { recursive: true, mode: 448 });
|
|
9544
11658
|
await chmod(paths.dataDir, 448);
|
|
9545
11659
|
}
|
|
9546
11660
|
await mkdir4(paths.storageDir, { recursive: true, mode: 448 });
|
|
9547
11661
|
await chmod(paths.storageDir, 448);
|
|
9548
|
-
const
|
|
9549
|
-
|
|
9550
|
-
|
|
9551
|
-
|
|
9552
|
-
|
|
9553
|
-
|
|
9554
|
-
|
|
9555
|
-
|
|
9556
|
-
|
|
9557
|
-
|
|
9558
|
-
|
|
9559
|
-
|
|
9560
|
-
|
|
9561
|
-
|
|
9562
|
-
|
|
9563
|
-
|
|
9564
|
-
|
|
9565
|
-
|
|
9566
|
-
|
|
9567
|
-
|
|
9568
|
-
|
|
9569
|
-
|
|
9570
|
-
|
|
9571
|
-
|
|
9572
|
-
|
|
9573
|
-
|
|
9574
|
-
|
|
9575
|
-
|
|
9576
|
-
|
|
9577
|
-
|
|
11662
|
+
const storageDriver = options.storageDriver ?? createStorageDriver(configuredStorageBackend, paths.storageDir, options.s3);
|
|
11663
|
+
const engine = databaseEngine === "native" ? await createNativeEngine({ dataDir: paths.dataDir, log: options.log }) : undefined;
|
|
11664
|
+
let backend;
|
|
11665
|
+
try {
|
|
11666
|
+
backend = await createBackend({
|
|
11667
|
+
engine,
|
|
11668
|
+
dataDir: databaseEngine === "pglite" ? paths.dataDir : undefined,
|
|
11669
|
+
jwtSecret: secrets.jwtSecret,
|
|
11670
|
+
vaultKey: secrets.vaultKey,
|
|
11671
|
+
apiUrl: url,
|
|
11672
|
+
siteUrl: options.siteUrl ?? process.env.SUPACLOUD_LITE_SITE_URL ?? config.auth.siteUrl ?? url,
|
|
11673
|
+
host,
|
|
11674
|
+
jwtExpiry: config.auth.jwtExpiry,
|
|
11675
|
+
uriAllowList: config.auth.uriAllowList,
|
|
11676
|
+
authEnabled: config.auth.enabled,
|
|
11677
|
+
authSettings: config.auth.settings,
|
|
11678
|
+
authRateLimits: config.auth.rateLimits,
|
|
11679
|
+
sessionTimeboxSeconds: config.auth.sessionTimeboxSeconds,
|
|
11680
|
+
sessionInactivitySeconds: config.auth.sessionInactivitySeconds,
|
|
11681
|
+
oauthProviders: config.auth.oauthProviders,
|
|
11682
|
+
smsSender: options.smsSender,
|
|
11683
|
+
dbSchemas: config.api.schemas,
|
|
11684
|
+
maxRows: config.api.maxRows,
|
|
11685
|
+
storageFileSizeLimit: config.storage.fileSizeLimit,
|
|
11686
|
+
buckets: config.storage.buckets,
|
|
11687
|
+
migrations: options.applyMigrations === false ? [] : project.migrations,
|
|
11688
|
+
seedSql: options.applyMigrations === false || options.includeSeed === false ? undefined : project.seedSql,
|
|
11689
|
+
functions,
|
|
11690
|
+
functionVerifyJwt: Object.fromEntries(Object.entries(config.functions).map(([name, functionOptions]) => [name, functionOptions.verifyJwt !== false])),
|
|
11691
|
+
functionEnv,
|
|
11692
|
+
webhooks,
|
|
11693
|
+
startRuntimeServices: options.startRuntimeServices,
|
|
11694
|
+
storageDriver,
|
|
11695
|
+
log: options.log
|
|
11696
|
+
});
|
|
11697
|
+
} catch (error) {
|
|
11698
|
+
if (engine) {
|
|
11699
|
+
try {
|
|
11700
|
+
await engine.close();
|
|
11701
|
+
} catch (cleanupError) {
|
|
11702
|
+
throw new AggregateError([error, cleanupError], "project database startup cleanup failed");
|
|
11703
|
+
}
|
|
11704
|
+
}
|
|
11705
|
+
throw error;
|
|
11706
|
+
}
|
|
9578
11707
|
return {
|
|
9579
11708
|
backend,
|
|
9580
11709
|
config,
|
|
@@ -9585,9 +11714,22 @@ async function createProjectBackend(options = {}) {
|
|
|
9585
11714
|
migrationCount: project.migrations.length,
|
|
9586
11715
|
functionNames: [...functions.keys()],
|
|
9587
11716
|
webhookCount: webhooks.length,
|
|
9588
|
-
storageBackend
|
|
11717
|
+
storageBackend,
|
|
11718
|
+
databaseEngine
|
|
9589
11719
|
};
|
|
9590
11720
|
}
|
|
11721
|
+
function resolveDatabaseEngine(value, memory = false) {
|
|
11722
|
+
const configured = value ?? process.env.SUPACLOUD_LITE_ENGINE ?? "pglite";
|
|
11723
|
+
if (configured !== "pglite" && configured !== "native") {
|
|
11724
|
+
throw new Error(`unsupported SUPACLOUD_LITE_ENGINE: ${configured}`);
|
|
11725
|
+
}
|
|
11726
|
+
if (configured === "native" && memory)
|
|
11727
|
+
throw new Error("--memory is only supported by the pglite engine");
|
|
11728
|
+
if (configured === "native" && !isNativeEngineSupported()) {
|
|
11729
|
+
throw new Error(`native PostgreSQL requires macOS or glibc Linux on x64/arm64; ` + `${process.platform}/${process.arch} must use --engine pglite`);
|
|
11730
|
+
}
|
|
11731
|
+
return configured;
|
|
11732
|
+
}
|
|
9591
11733
|
function resolveStorageBackend(value) {
|
|
9592
11734
|
const configured = value ?? process.env.SUPACLOUD_LITE_STORAGE_BACKEND ?? "fs";
|
|
9593
11735
|
if (configured === "fs" || configured === "memory" || configured === "s3")
|
|
@@ -9642,7 +11784,7 @@ async function startProjectServer(options = {}) {
|
|
|
9642
11784
|
}
|
|
9643
11785
|
async function loadWebhooks(projectDir) {
|
|
9644
11786
|
try {
|
|
9645
|
-
const parsed = JSON.parse(await readFile6(
|
|
11787
|
+
const parsed = JSON.parse(await readFile6(join7(projectDir, "supabase", "webhooks.json"), "utf8"));
|
|
9646
11788
|
return Array.isArray(parsed) ? parsed : [];
|
|
9647
11789
|
} catch (error) {
|
|
9648
11790
|
if (error.code === "ENOENT")
|
|
@@ -9692,9 +11834,9 @@ async function findEphemeralPort(host = "127.0.0.1") {
|
|
|
9692
11834
|
return port;
|
|
9693
11835
|
}
|
|
9694
11836
|
// src/snapshot.ts
|
|
9695
|
-
import { chmod as chmod2, copyFile, lstat as lstat2, mkdir as mkdir5, mkdtemp, readdir as readdir3, readFile as readFile7, rename as rename2, rm as
|
|
9696
|
-
import { dirname as
|
|
9697
|
-
import { create as createTar, extract as
|
|
11837
|
+
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";
|
|
11838
|
+
import { dirname as dirname5, join as join8, parse as parse2, relative as relative2, resolve as resolve3, sep as sep2 } from "path";
|
|
11839
|
+
import { create as createTar, extract as extractTar2 } from "tar";
|
|
9698
11840
|
var SNAPSHOT_FORMAT = "supacloud-lite-snapshot";
|
|
9699
11841
|
var SNAPSHOT_VERSION = 1;
|
|
9700
11842
|
async function createSnapshot(options) {
|
|
@@ -9709,21 +11851,27 @@ async function createSnapshot(options) {
|
|
|
9709
11851
|
storageBackend: options.storageBackend,
|
|
9710
11852
|
includesDatabase: Boolean(paths.dataDir),
|
|
9711
11853
|
includesLocalStorage: options.storageBackend === "fs",
|
|
9712
|
-
includesSecrets: true
|
|
11854
|
+
includesSecrets: true,
|
|
11855
|
+
databaseEngine: paths.databaseEngine,
|
|
11856
|
+
...paths.databaseEngine === "native" ? {
|
|
11857
|
+
platform: process.platform,
|
|
11858
|
+
architecture: process.arch,
|
|
11859
|
+
postgresMajor: await readPostgresMajor(paths.dataDir)
|
|
11860
|
+
} : {}
|
|
9713
11861
|
};
|
|
9714
11862
|
const output = resolve3(options.output);
|
|
9715
11863
|
if (await existingInfo(output))
|
|
9716
11864
|
throw new Error(`snapshot output already exists: ${output}`);
|
|
9717
|
-
await mkdir5(
|
|
9718
|
-
const stagingRoot = await mkdtemp(
|
|
11865
|
+
await mkdir5(dirname5(output), { recursive: true });
|
|
11866
|
+
const stagingRoot = await mkdtemp(join8(dirname5(output), ".supacloud-lite-snapshot-"));
|
|
9719
11867
|
try {
|
|
9720
|
-
await
|
|
11868
|
+
await writeFile5(join8(stagingRoot, "manifest.json"), `${JSON.stringify(manifest, null, 2)}
|
|
9721
11869
|
`);
|
|
9722
|
-
await stageFile(paths.secretsFile,
|
|
11870
|
+
await stageFile(paths.secretsFile, join8(stagingRoot, "secrets.json"));
|
|
9723
11871
|
if (paths.dataDir)
|
|
9724
|
-
await stageDirectory(paths.dataDir,
|
|
11872
|
+
await stageDirectory(paths.dataDir, join8(stagingRoot, "database"));
|
|
9725
11873
|
if (options.storageBackend === "fs")
|
|
9726
|
-
await stageDirectory(paths.storageDir,
|
|
11874
|
+
await stageDirectory(paths.storageDir, join8(stagingRoot, "storage"));
|
|
9727
11875
|
const entries = ["manifest.json", "secrets.json"];
|
|
9728
11876
|
if (paths.dataDir)
|
|
9729
11877
|
entries.push("database");
|
|
@@ -9734,23 +11882,23 @@ async function createSnapshot(options) {
|
|
|
9734
11882
|
await chmod2(output, 384);
|
|
9735
11883
|
return manifest;
|
|
9736
11884
|
} catch (error) {
|
|
9737
|
-
await
|
|
11885
|
+
await rm4(output, { force: true });
|
|
9738
11886
|
throw error;
|
|
9739
11887
|
} finally {
|
|
9740
|
-
await
|
|
11888
|
+
await rm4(stagingRoot, { recursive: true, force: true });
|
|
9741
11889
|
}
|
|
9742
11890
|
}
|
|
9743
11891
|
async function restoreSnapshot(options) {
|
|
9744
11892
|
const paths = normalizePaths(options.paths);
|
|
9745
11893
|
await assertSnapshotPaths(paths, { requireSecrets: false, allowMissingState: true });
|
|
9746
11894
|
await assertNoDataDirectoryLock(paths);
|
|
9747
|
-
const stagingRoot = await mkdtemp(
|
|
9748
|
-
const payloadRoot =
|
|
11895
|
+
const stagingRoot = await mkdtemp(join8(dirname5(paths.stateDir), ".supacloud-lite-restore-"));
|
|
11896
|
+
const payloadRoot = join8(stagingRoot, "payload");
|
|
9749
11897
|
const rollbackId = crypto.randomUUID();
|
|
9750
11898
|
const rollbackPaths = [];
|
|
9751
11899
|
try {
|
|
9752
11900
|
await mkdir5(payloadRoot, { recursive: true });
|
|
9753
|
-
await
|
|
11901
|
+
await extractTar2({
|
|
9754
11902
|
cwd: payloadRoot,
|
|
9755
11903
|
file: resolve3(options.input),
|
|
9756
11904
|
preserveOwner: false,
|
|
@@ -9775,6 +11923,7 @@ async function restoreSnapshot(options) {
|
|
|
9775
11923
|
if (manifest.storageBackend !== options.storageBackend) {
|
|
9776
11924
|
throw new Error(`snapshot storage backend is ${manifest.storageBackend}, but the target uses ${options.storageBackend}; ` + "restore with the matching --storage-backend value");
|
|
9777
11925
|
}
|
|
11926
|
+
assertDatabaseSnapshotCompatible(manifest, paths);
|
|
9778
11927
|
if (manifest.includesDatabase !== Boolean(paths.dataDir)) {
|
|
9779
11928
|
throw new Error("snapshot database mode does not match the target; do not restore a persistent snapshot into --memory");
|
|
9780
11929
|
}
|
|
@@ -9783,27 +11932,27 @@ async function restoreSnapshot(options) {
|
|
|
9783
11932
|
}
|
|
9784
11933
|
await assertSnapshotPayload(payloadRoot, manifest);
|
|
9785
11934
|
if (manifest.includesDatabase)
|
|
9786
|
-
await mkdir5(
|
|
11935
|
+
await mkdir5(join8(payloadRoot, "database"), { recursive: true });
|
|
9787
11936
|
if (manifest.includesLocalStorage)
|
|
9788
|
-
await mkdir5(
|
|
11937
|
+
await mkdir5(join8(payloadRoot, "storage"), { recursive: true });
|
|
9789
11938
|
await assertRestoreTargets(paths, manifest, options.force === true);
|
|
9790
|
-
const stateStage =
|
|
11939
|
+
const stateStage = join8(stagingRoot, "state");
|
|
9791
11940
|
await mkdir5(stateStage, { recursive: true });
|
|
9792
|
-
await copyEntry(
|
|
11941
|
+
await copyEntry(join8(payloadRoot, "secrets.json"), join8(stateStage, "secrets.json"));
|
|
9793
11942
|
if (paths.dataDir && isWithin(paths.stateDir, paths.dataDir)) {
|
|
9794
|
-
await copyEntry(
|
|
11943
|
+
await copyEntry(join8(payloadRoot, "database"), join8(stateStage, relative2(paths.stateDir, paths.dataDir)));
|
|
9795
11944
|
}
|
|
9796
11945
|
if (options.storageBackend === "fs" && isWithin(paths.stateDir, paths.storageDir)) {
|
|
9797
|
-
await copyEntry(
|
|
11946
|
+
await copyEntry(join8(payloadRoot, "storage"), join8(stateStage, relative2(paths.stateDir, paths.storageDir)));
|
|
9798
11947
|
}
|
|
9799
11948
|
const swaps = [];
|
|
9800
11949
|
try {
|
|
9801
11950
|
await applyDirectorySwap(stateStage, paths.stateDir, options.force === true, rollbackId, swaps);
|
|
9802
11951
|
if (paths.dataDir && !isWithin(paths.stateDir, paths.dataDir)) {
|
|
9803
|
-
await applyDirectorySwap(
|
|
11952
|
+
await applyDirectorySwap(join8(payloadRoot, "database"), paths.dataDir, options.force === true, rollbackId, swaps);
|
|
9804
11953
|
}
|
|
9805
11954
|
if (options.storageBackend === "fs" && !isWithin(paths.stateDir, paths.storageDir)) {
|
|
9806
|
-
await applyDirectorySwap(
|
|
11955
|
+
await applyDirectorySwap(join8(payloadRoot, "storage"), paths.storageDir, options.force === true, rollbackId, swaps);
|
|
9807
11956
|
}
|
|
9808
11957
|
} catch (error) {
|
|
9809
11958
|
await rollbackDirectorySwaps(swaps);
|
|
@@ -9822,7 +11971,7 @@ async function restoreSnapshot(options) {
|
|
|
9822
11971
|
} catch (error) {
|
|
9823
11972
|
throw error instanceof Error ? error : new Error(String(error));
|
|
9824
11973
|
} finally {
|
|
9825
|
-
await
|
|
11974
|
+
await rm4(stagingRoot, { recursive: true, force: true });
|
|
9826
11975
|
}
|
|
9827
11976
|
}
|
|
9828
11977
|
function normalizePaths(paths) {
|
|
@@ -9839,7 +11988,7 @@ async function assertSnapshotPaths(paths, options = {}) {
|
|
|
9839
11988
|
try {
|
|
9840
11989
|
if (paths.stateDir === parse2(paths.stateDir).root)
|
|
9841
11990
|
throw new Error("snapshot state directory must not be the filesystem root");
|
|
9842
|
-
if (paths.secretsFile !==
|
|
11991
|
+
if (paths.secretsFile !== join8(paths.stateDir, "secrets.json"))
|
|
9843
11992
|
throw new Error("snapshot secrets path must be inside the state directory");
|
|
9844
11993
|
const stateInfo = await lstat2(paths.stateDir);
|
|
9845
11994
|
if (!stateInfo.isDirectory() || stateInfo.isSymbolicLink())
|
|
@@ -9897,10 +12046,10 @@ async function stageDirectory(root, destination) {
|
|
|
9897
12046
|
throw error;
|
|
9898
12047
|
}
|
|
9899
12048
|
await mkdir5(destination, { recursive: true });
|
|
9900
|
-
const walk = async (current,
|
|
12049
|
+
const walk = async (current, target2) => {
|
|
9901
12050
|
for (const entry of await readdir3(current, { withFileTypes: true })) {
|
|
9902
|
-
const fullPath =
|
|
9903
|
-
const targetPath =
|
|
12051
|
+
const fullPath = join8(current, entry.name);
|
|
12052
|
+
const targetPath = join8(target2, entry.name);
|
|
9904
12053
|
if (entry.isSymbolicLink())
|
|
9905
12054
|
throw new Error(`snapshot refuses symbolic link: ${fullPath}`);
|
|
9906
12055
|
if (entry.isDirectory()) {
|
|
@@ -9915,14 +12064,14 @@ async function stageDirectory(root, destination) {
|
|
|
9915
12064
|
};
|
|
9916
12065
|
await walk(root, destination);
|
|
9917
12066
|
}
|
|
9918
|
-
async function stageFile(source,
|
|
9919
|
-
await mkdir5(
|
|
9920
|
-
await copyFile(source,
|
|
12067
|
+
async function stageFile(source, target2) {
|
|
12068
|
+
await mkdir5(dirname5(target2), { recursive: true });
|
|
12069
|
+
await copyFile(source, target2);
|
|
9921
12070
|
}
|
|
9922
12071
|
async function readManifest(payloadRoot) {
|
|
9923
12072
|
let parsed;
|
|
9924
12073
|
try {
|
|
9925
|
-
parsed = JSON.parse(await readFile7(
|
|
12074
|
+
parsed = JSON.parse(await readFile7(join8(payloadRoot, "manifest.json"), "utf8"));
|
|
9926
12075
|
} catch (error) {
|
|
9927
12076
|
throw new Error(`invalid snapshot manifest: ${error instanceof Error ? error.message : String(error)}`);
|
|
9928
12077
|
}
|
|
@@ -9934,13 +12083,38 @@ function isSnapshotManifest(value) {
|
|
|
9934
12083
|
if (!value || typeof value !== "object")
|
|
9935
12084
|
return false;
|
|
9936
12085
|
const candidate = value;
|
|
9937
|
-
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;
|
|
12086
|
+
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");
|
|
12087
|
+
}
|
|
12088
|
+
function assertDatabaseSnapshotCompatible(manifest, paths) {
|
|
12089
|
+
const sourceEngine = manifest.databaseEngine ?? "pglite";
|
|
12090
|
+
if (sourceEngine !== paths.databaseEngine) {
|
|
12091
|
+
throw new Error(`snapshot database engine is ${sourceEngine}, but the target uses ${paths.databaseEngine}`);
|
|
12092
|
+
}
|
|
12093
|
+
if (sourceEngine !== "native")
|
|
12094
|
+
return;
|
|
12095
|
+
if (manifest.platform !== process.platform || manifest.architecture !== process.arch) {
|
|
12096
|
+
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}`);
|
|
12097
|
+
}
|
|
12098
|
+
if (manifest.postgresMajor !== NATIVE_POSTGRES_MAJOR) {
|
|
12099
|
+
throw new Error(`native PostgreSQL snapshot major is ${manifest.postgresMajor ?? "unknown"}, ` + `but this Lite build uses ${NATIVE_POSTGRES_MAJOR}`);
|
|
12100
|
+
}
|
|
12101
|
+
}
|
|
12102
|
+
async function readPostgresMajor(dataDir) {
|
|
12103
|
+
if (!dataDir)
|
|
12104
|
+
return;
|
|
12105
|
+
try {
|
|
12106
|
+
return (await readFile7(join8(dataDir, "PG_VERSION"), "utf8")).trim();
|
|
12107
|
+
} catch (error) {
|
|
12108
|
+
if (error.code === "ENOENT")
|
|
12109
|
+
return;
|
|
12110
|
+
throw error;
|
|
12111
|
+
}
|
|
9938
12112
|
}
|
|
9939
12113
|
async function assertSnapshotPayload(payloadRoot, manifest) {
|
|
9940
12114
|
const required = ["manifest.json", "secrets.json"];
|
|
9941
12115
|
for (const path of required) {
|
|
9942
12116
|
try {
|
|
9943
|
-
await lstat2(
|
|
12117
|
+
await lstat2(join8(payloadRoot, path));
|
|
9944
12118
|
} catch {
|
|
9945
12119
|
throw new Error(`snapshot is missing required payload: ${path}`);
|
|
9946
12120
|
}
|
|
@@ -9963,9 +12137,9 @@ async function assertRestoreTargets(paths, manifest, force) {
|
|
|
9963
12137
|
if (manifest.includesLocalStorage && !isWithin(paths.stateDir, paths.storageDir))
|
|
9964
12138
|
targets.push(paths.storageDir);
|
|
9965
12139
|
if (!force) {
|
|
9966
|
-
for (const
|
|
9967
|
-
if (await directoryHasEntries(
|
|
9968
|
-
throw new Error(`restore target is not empty: ${
|
|
12140
|
+
for (const target2 of targets) {
|
|
12141
|
+
if (await directoryHasEntries(target2))
|
|
12142
|
+
throw new Error(`restore target is not empty: ${target2}; pass --force to replace it`);
|
|
9969
12143
|
}
|
|
9970
12144
|
}
|
|
9971
12145
|
}
|
|
@@ -9978,34 +12152,34 @@ async function directoryHasEntries(path) {
|
|
|
9978
12152
|
throw error;
|
|
9979
12153
|
}
|
|
9980
12154
|
}
|
|
9981
|
-
async function applyDirectorySwap(source,
|
|
9982
|
-
const targetInfo = await existingInfo(
|
|
12155
|
+
async function applyDirectorySwap(source, target2, force, rollbackId, swaps) {
|
|
12156
|
+
const targetInfo = await existingInfo(target2);
|
|
9983
12157
|
if (targetInfo && !targetInfo.isDirectory())
|
|
9984
|
-
throw new Error(`restore target is not a directory: ${
|
|
9985
|
-
const swap = { target };
|
|
12158
|
+
throw new Error(`restore target is not a directory: ${target2}`);
|
|
12159
|
+
const swap = { target: target2 };
|
|
9986
12160
|
if (targetInfo) {
|
|
9987
12161
|
if (!force) {
|
|
9988
|
-
if (await directoryHasEntries(
|
|
9989
|
-
throw new Error(`restore target is not empty: ${
|
|
9990
|
-
await
|
|
12162
|
+
if (await directoryHasEntries(target2))
|
|
12163
|
+
throw new Error(`restore target is not empty: ${target2}; pass --force to replace it`);
|
|
12164
|
+
await rm4(target2, { recursive: true, force: true });
|
|
9991
12165
|
} else {
|
|
9992
|
-
swap.rollbackPath =
|
|
9993
|
-
await rename2(
|
|
12166
|
+
swap.rollbackPath = join8(dirname5(target2), `.${target2.split(sep2).pop() ?? "state"}.restore-${rollbackId}`);
|
|
12167
|
+
await rename2(target2, swap.rollbackPath);
|
|
9994
12168
|
}
|
|
9995
12169
|
}
|
|
9996
12170
|
try {
|
|
9997
|
-
await mkdir5(
|
|
9998
|
-
await rename2(source,
|
|
12171
|
+
await mkdir5(dirname5(target2), { recursive: true });
|
|
12172
|
+
await rename2(source, target2);
|
|
9999
12173
|
swaps.push(swap);
|
|
10000
12174
|
} catch (error) {
|
|
10001
12175
|
if (swap.rollbackPath)
|
|
10002
|
-
await rename2(swap.rollbackPath,
|
|
12176
|
+
await rename2(swap.rollbackPath, target2).catch(() => {});
|
|
10003
12177
|
throw error;
|
|
10004
12178
|
}
|
|
10005
12179
|
}
|
|
10006
12180
|
async function rollbackDirectorySwaps(swaps) {
|
|
10007
12181
|
for (const swap of [...swaps].reverse()) {
|
|
10008
|
-
await
|
|
12182
|
+
await rm4(swap.target, { recursive: true, force: true });
|
|
10009
12183
|
if (swap.rollbackPath)
|
|
10010
12184
|
await rename2(swap.rollbackPath, swap.target);
|
|
10011
12185
|
}
|
|
@@ -10019,17 +12193,17 @@ async function existingInfo(path) {
|
|
|
10019
12193
|
throw error;
|
|
10020
12194
|
}
|
|
10021
12195
|
}
|
|
10022
|
-
async function copyEntry(source,
|
|
12196
|
+
async function copyEntry(source, target2) {
|
|
10023
12197
|
const info = await lstat2(source);
|
|
10024
12198
|
if (info.isSymbolicLink())
|
|
10025
12199
|
throw new Error(`snapshot refuses symbolic link: ${source}`);
|
|
10026
12200
|
if (info.isDirectory()) {
|
|
10027
|
-
await mkdir5(
|
|
12201
|
+
await mkdir5(target2, { recursive: true });
|
|
10028
12202
|
for (const entry of await readdir3(source))
|
|
10029
|
-
await copyEntry(
|
|
12203
|
+
await copyEntry(join8(source, entry), join8(target2, entry));
|
|
10030
12204
|
} else if (info.isFile()) {
|
|
10031
|
-
await mkdir5(
|
|
10032
|
-
await Bun.write(
|
|
12205
|
+
await mkdir5(dirname5(target2), { recursive: true });
|
|
12206
|
+
await Bun.write(target2, Bun.file(source));
|
|
10033
12207
|
} else
|
|
10034
12208
|
throw new Error(`snapshot refuses unsupported filesystem entry: ${source}`);
|
|
10035
12209
|
}
|
|
@@ -10040,7 +12214,7 @@ async function hardenRestoredTree(root) {
|
|
|
10040
12214
|
if (info.isDirectory()) {
|
|
10041
12215
|
await chmod2(root, 448);
|
|
10042
12216
|
for (const entry of await readdir3(root))
|
|
10043
|
-
await hardenRestoredTree(
|
|
12217
|
+
await hardenRestoredTree(join8(root, entry));
|
|
10044
12218
|
return;
|
|
10045
12219
|
}
|
|
10046
12220
|
if (info.isFile()) {
|
|
@@ -10051,7 +12225,7 @@ async function hardenRestoredTree(root) {
|
|
|
10051
12225
|
}
|
|
10052
12226
|
async function assertNoSymlinks(root) {
|
|
10053
12227
|
for (const entry of await readdir3(root, { withFileTypes: true })) {
|
|
10054
|
-
const fullPath =
|
|
12228
|
+
const fullPath = join8(root, entry.name);
|
|
10055
12229
|
if (entry.isSymbolicLink())
|
|
10056
12230
|
throw new Error(`snapshot refuses symbolic link in archive: ${fullPath}`);
|
|
10057
12231
|
if (entry.isDirectory())
|
|
@@ -10076,14 +12250,18 @@ export {
|
|
|
10076
12250
|
restoreSnapshot,
|
|
10077
12251
|
resolveStorageBackend,
|
|
10078
12252
|
resolveProjectPaths,
|
|
12253
|
+
resolveDatabaseEngine,
|
|
10079
12254
|
mintProjectKeys,
|
|
12255
|
+
isNativeEngineSupported,
|
|
10080
12256
|
inspectDb,
|
|
10081
12257
|
generateTypes,
|
|
10082
12258
|
ensureProjectSecrets,
|
|
12259
|
+
ensurePostgres,
|
|
10083
12260
|
decodeJwt,
|
|
10084
12261
|
createSnapshot,
|
|
10085
12262
|
createProjectBackend,
|
|
10086
12263
|
createPgliteEngine,
|
|
12264
|
+
createNativeEngine,
|
|
10087
12265
|
createBackend as createLiteBackend,
|
|
10088
12266
|
SUPACLOUD_LITE_VERSION,
|
|
10089
12267
|
S3StorageDriver,
|