@supacloud/lite 0.8.4 → 0.9.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/dist/cli.js CHANGED
@@ -3,12 +3,13 @@
3
3
  var __require = import.meta.require;
4
4
 
5
5
  // src/cli.ts
6
+ import { existsSync as existsSync4 } from "fs";
6
7
  import { mkdir as mkdir7, rm as rm6, writeFile as writeFile7 } from "fs/promises";
7
- import { dirname as dirname7, join as join10, resolve as resolve4 } from "path";
8
+ import { dirname as dirname7, join as join10, resolve as resolve5 } from "path";
8
9
  // package.json
9
10
  var package_default = {
10
11
  name: "@supacloud/lite",
11
- version: "0.8.4",
12
+ version: "0.9.0",
12
13
  description: "Bun-native, single-project Supabase-compatible backend powered by PGlite or native PostgreSQL",
13
14
  type: "module",
14
15
  license: "Apache-2.0",
@@ -46,7 +47,7 @@ var package_default = {
46
47
  dev: "bun run src/cli.ts start",
47
48
  start: "bun run src/cli.ts start",
48
49
  test: "bun test --timeout 20000",
49
- "test:native": "SUPACLOUD_LITE_TEST_NATIVE=1 bun test test/native-engine.test.ts test/qauth-workflow-matrix.test.ts --timeout 180000",
50
+ "test:native": "SUPACLOUD_LITE_TEST_NATIVE=1 bun test test/native-engine.test.ts test/qauth-workflow-matrix.test.ts test/maker-checker-workflow.test.ts test/commands-artifacts.test.ts --timeout 180000",
50
51
  parity: "bun run parity/harness.ts",
51
52
  "parity:native": "SUPACLOUD_LITE_TEST_NATIVE=1 bun run parity/harness.ts --engine native",
52
53
  "check:native": "bun run test:native && bun run parity:native",
@@ -4767,6 +4768,452 @@ GRANT EXECUTE ON FUNCTION public.supacloud_workflow_cancel(jsonb) TO service_rol
4767
4768
  GRANT EXECUTE ON FUNCTION public.supacloud_workflow_get(jsonb) TO service_role;
4768
4769
  GRANT EXECUTE ON FUNCTION public.supacloud_workflow_events(jsonb) TO service_role;
4769
4770
  -- supacloud:sql-module:workflows-public:end
4771
+
4772
+ -- supacloud:sql-module:commands-public:start
4773
+ CREATE SCHEMA IF NOT EXISTS supacloud_commands;
4774
+ REVOKE ALL ON SCHEMA supacloud_commands FROM PUBLIC, anon, authenticated;
4775
+ GRANT USAGE ON SCHEMA supacloud_commands TO service_role;
4776
+
4777
+ CREATE TABLE IF NOT EXISTS supacloud_commands.receipts (
4778
+ id uuid PRIMARY KEY,
4779
+ command_type text NOT NULL
4780
+ CHECK (command_type ~ '^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$'),
4781
+ target_type text NOT NULL
4782
+ CHECK (target_type ~ '^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$'),
4783
+ target_id text NOT NULL CHECK (char_length(target_id) BETWEEN 1 AND 500),
4784
+ actor_id uuid,
4785
+ payload jsonb NOT NULL DEFAULT '{}'::jsonb
4786
+ CHECK (jsonb_typeof(payload) = 'object'),
4787
+ payload_fingerprint text NOT NULL CHECK (char_length(payload_fingerprint) = 36),
4788
+ workflow_run_id uuid NOT NULL UNIQUE
4789
+ REFERENCES supacloud_workflows.runs(id) ON DELETE RESTRICT,
4790
+ created_at timestamptz NOT NULL DEFAULT now()
4791
+ );
4792
+
4793
+ CREATE INDEX IF NOT EXISTS supacloud_commands_target_idx
4794
+ ON supacloud_commands.receipts (target_type, target_id, created_at DESC, id);
4795
+
4796
+ CREATE OR REPLACE FUNCTION supacloud_commands.snapshot(
4797
+ p_command_id uuid,
4798
+ p_idempotent boolean DEFAULT false
4799
+ ) RETURNS jsonb
4800
+ LANGUAGE sql STABLE SECURITY DEFINER SET search_path = '' AS $$
4801
+ SELECT jsonb_build_object(
4802
+ 'commandId', receipt.id,
4803
+ 'commandType', receipt.command_type,
4804
+ 'targetType', receipt.target_type,
4805
+ 'targetId', receipt.target_id,
4806
+ 'actorId', receipt.actor_id,
4807
+ 'payloadFingerprint', receipt.payload_fingerprint,
4808
+ 'createdAt', receipt.created_at,
4809
+ 'idempotent', p_idempotent,
4810
+ 'workflow', supacloud_workflows.snapshot(receipt.workflow_run_id, false)
4811
+ )
4812
+ FROM supacloud_commands.receipts receipt
4813
+ WHERE receipt.id = p_command_id
4814
+ $$;
4815
+
4816
+ -- Application-owned SECURITY DEFINER RPCs may call this after their domain
4817
+ -- mutation. PostgreSQL commits the domain write, receipt, and workflow enqueue
4818
+ -- together, so a lost response can be replayed with the same command ID.
4819
+ DROP FUNCTION IF EXISTS supacloud_commands.submit(uuid, text, text, text, uuid, jsonb, integer);
4820
+ CREATE OR REPLACE FUNCTION supacloud_commands.submit(request jsonb)
4821
+ RETURNS jsonb
4822
+ LANGUAGE plpgsql VOLATILE SECURITY DEFINER SET search_path = '' AS $$
4823
+ DECLARE
4824
+ command_id_text text;
4825
+ command_id uuid;
4826
+ actor_id uuid;
4827
+ max_attempts integer;
4828
+ payload jsonb;
4829
+ normalized_command_type text;
4830
+ normalized_target_type text;
4831
+ normalized_target_id text;
4832
+ fingerprint text;
4833
+ existing supacloud_commands.receipts%ROWTYPE;
4834
+ BEGIN
4835
+ IF jsonb_typeof(request) IS DISTINCT FROM 'object' THEN
4836
+ RAISE EXCEPTION 'SUPACLOUD_COMMAND_INVALID' USING ERRCODE = '22023';
4837
+ END IF;
4838
+ command_id_text := request ->> 'commandId';
4839
+ IF command_id_text IS NULL
4840
+ OR command_id_text !~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$' THEN
4841
+ RAISE EXCEPTION 'SUPACLOUD_COMMAND_INVALID' USING ERRCODE = '22023';
4842
+ END IF;
4843
+ command_id := command_id_text::uuid;
4844
+ IF request ? 'actorId' AND request ->> 'actorId' IS NOT NULL THEN
4845
+ actor_id := (request ->> 'actorId')::uuid;
4846
+ END IF;
4847
+ max_attempts := coalesce((request ->> 'maxAttempts')::integer, 3);
4848
+ payload := coalesce(request -> 'payload', '{}'::jsonb);
4849
+ normalized_command_type := nullif(btrim(request ->> 'commandType'), '');
4850
+ normalized_target_type := nullif(btrim(request ->> 'targetType'), '');
4851
+ normalized_target_id := nullif(btrim(request ->> 'targetId'), '');
4852
+ IF normalized_command_type IS NULL
4853
+ OR normalized_command_type !~ '^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$'
4854
+ OR normalized_target_type IS NULL
4855
+ OR normalized_target_type !~ '^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$'
4856
+ OR normalized_target_id IS NULL
4857
+ OR char_length(normalized_target_id) > 500
4858
+ OR jsonb_typeof(payload) IS DISTINCT FROM 'object'
4859
+ OR max_attempts NOT BETWEEN 1 AND 100 THEN
4860
+ RAISE EXCEPTION 'SUPACLOUD_COMMAND_INVALID' USING ERRCODE = '22023';
4861
+ END IF;
4862
+
4863
+ fingerprint := 'md5:' || md5(payload::text);
4864
+ PERFORM pg_advisory_xact_lock(hashtextextended(command_id::text, 0));
4865
+ SELECT * INTO existing FROM supacloud_commands.receipts WHERE id = command_id;
4866
+ IF FOUND THEN
4867
+ IF existing.command_type <> normalized_command_type
4868
+ OR existing.target_type <> normalized_target_type
4869
+ OR existing.target_id <> normalized_target_id
4870
+ OR existing.actor_id IS DISTINCT FROM actor_id
4871
+ OR existing.payload <> payload
4872
+ OR existing.payload_fingerprint <> fingerprint THEN
4873
+ RAISE EXCEPTION 'SUPACLOUD_COMMAND_IDEMPOTENCY_CONFLICT' USING ERRCODE = '23505';
4874
+ END IF;
4875
+ RETURN supacloud_commands.snapshot(command_id, true);
4876
+ END IF;
4877
+
4878
+ PERFORM supacloud_workflows.start_run(
4879
+ command_id,
4880
+ 'command.' || normalized_command_type,
4881
+ '1',
4882
+ 'execute',
4883
+ jsonb_build_object(
4884
+ 'commandId', command_id,
4885
+ 'commandType', normalized_command_type,
4886
+ 'targetType', normalized_target_type,
4887
+ 'targetId', normalized_target_id,
4888
+ 'actorId', actor_id,
4889
+ 'payload', payload,
4890
+ 'payloadFingerprint', fingerprint
4891
+ ),
4892
+ max_attempts
4893
+ );
4894
+
4895
+ INSERT INTO supacloud_commands.receipts (
4896
+ id, command_type, target_type, target_id, actor_id, payload,
4897
+ payload_fingerprint, workflow_run_id
4898
+ ) VALUES (
4899
+ command_id, normalized_command_type, normalized_target_type,
4900
+ normalized_target_id, actor_id, payload, fingerprint, command_id
4901
+ );
4902
+ RETURN supacloud_commands.snapshot(command_id, false);
4903
+ EXCEPTION
4904
+ WHEN invalid_parameter_value OR invalid_text_representation OR numeric_value_out_of_range THEN
4905
+ RAISE EXCEPTION 'SUPACLOUD_COMMAND_INVALID' USING ERRCODE = '22023';
4906
+ END;
4907
+ $$;
4908
+
4909
+ CREATE OR REPLACE FUNCTION public.supacloud_command_submit(request jsonb)
4910
+ RETURNS jsonb
4911
+ LANGUAGE sql VOLATILE SECURITY DEFINER SET search_path = '' AS $$
4912
+ SELECT supacloud_commands.submit(request)
4913
+ $$;
4914
+
4915
+ CREATE OR REPLACE FUNCTION public.supacloud_command_get(request jsonb)
4916
+ RETURNS jsonb
4917
+ LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = '' AS $$
4918
+ DECLARE
4919
+ command_id_text text;
4920
+ BEGIN
4921
+ IF jsonb_typeof(request) IS DISTINCT FROM 'object' THEN
4922
+ RAISE EXCEPTION 'SUPACLOUD_COMMAND_GET_INVALID' USING ERRCODE = '22023';
4923
+ END IF;
4924
+ command_id_text := request ->> 'commandId';
4925
+ IF command_id_text IS NULL
4926
+ OR command_id_text !~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$' THEN
4927
+ RAISE EXCEPTION 'SUPACLOUD_COMMAND_GET_INVALID' USING ERRCODE = '22023';
4928
+ END IF;
4929
+ RETURN supacloud_commands.snapshot(command_id_text::uuid, false);
4930
+ EXCEPTION
4931
+ WHEN invalid_parameter_value OR invalid_text_representation THEN
4932
+ RAISE EXCEPTION 'SUPACLOUD_COMMAND_GET_INVALID' USING ERRCODE = '22023';
4933
+ END;
4934
+ $$;
4935
+
4936
+ REVOKE ALL ON ALL TABLES IN SCHEMA supacloud_commands
4937
+ FROM PUBLIC, anon, authenticated, service_role;
4938
+ REVOKE ALL ON ALL FUNCTIONS IN SCHEMA supacloud_commands
4939
+ FROM PUBLIC, anon, authenticated, service_role;
4940
+ REVOKE ALL ON FUNCTION public.supacloud_command_submit(jsonb) FROM PUBLIC, anon, authenticated;
4941
+ REVOKE ALL ON FUNCTION public.supacloud_command_get(jsonb) FROM PUBLIC, anon, authenticated;
4942
+ GRANT EXECUTE ON FUNCTION public.supacloud_command_submit(jsonb) TO service_role;
4943
+ GRANT EXECUTE ON FUNCTION public.supacloud_command_get(jsonb) TO service_role;
4944
+ -- supacloud:sql-module:commands-public:end
4945
+
4946
+ -- supacloud:sql-module:artifacts-public:start
4947
+ CREATE SCHEMA IF NOT EXISTS supacloud_artifacts;
4948
+ REVOKE ALL ON SCHEMA supacloud_artifacts FROM PUBLIC, anon, authenticated;
4949
+ GRANT USAGE ON SCHEMA supacloud_artifacts TO service_role;
4950
+
4951
+ CREATE TABLE IF NOT EXISTS supacloud_artifacts.artifacts (
4952
+ id uuid PRIMARY KEY,
4953
+ storage_object_id uuid NOT NULL UNIQUE REFERENCES storage.objects(id) ON DELETE RESTRICT,
4954
+ bucket_id text NOT NULL,
4955
+ object_path text NOT NULL,
4956
+ object_version text NOT NULL,
4957
+ artifact_type text NOT NULL
4958
+ CHECK (artifact_type ~ '^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$'),
4959
+ sha256 text NOT NULL CHECK (sha256 ~ '^[0-9a-f]{64}$'),
4960
+ size_bytes bigint NOT NULL CHECK (size_bytes >= 0),
4961
+ mime_type text NOT NULL CHECK (char_length(mime_type) BETWEEN 1 AND 255),
4962
+ metadata jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(metadata) = 'object'),
4963
+ retention_until timestamptz,
4964
+ created_by uuid,
4965
+ created_at timestamptz NOT NULL DEFAULT now(),
4966
+ CHECK (retention_until IS NULL OR retention_until >= created_at),
4967
+ UNIQUE (bucket_id, object_path, object_version)
4968
+ );
4969
+
4970
+ CREATE INDEX IF NOT EXISTS supacloud_artifacts_lookup_idx
4971
+ ON supacloud_artifacts.artifacts (artifact_type, created_at DESC, id);
4972
+
4973
+ CREATE TABLE IF NOT EXISTS supacloud_artifacts.lineage (
4974
+ parent_artifact_id uuid NOT NULL REFERENCES supacloud_artifacts.artifacts(id) ON DELETE RESTRICT,
4975
+ child_artifact_id uuid NOT NULL REFERENCES supacloud_artifacts.artifacts(id) ON DELETE RESTRICT,
4976
+ relation_type text NOT NULL
4977
+ CHECK (relation_type ~ '^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$'),
4978
+ metadata jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(metadata) = 'object'),
4979
+ created_at timestamptz NOT NULL DEFAULT now(),
4980
+ PRIMARY KEY (parent_artifact_id, child_artifact_id, relation_type),
4981
+ CHECK (parent_artifact_id <> child_artifact_id)
4982
+ );
4983
+
4984
+ CREATE INDEX IF NOT EXISTS supacloud_artifacts_lineage_child_idx
4985
+ ON supacloud_artifacts.lineage (child_artifact_id, created_at, parent_artifact_id);
4986
+
4987
+ CREATE OR REPLACE FUNCTION supacloud_artifacts.guard_storage_object()
4988
+ RETURNS trigger
4989
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
4990
+ BEGIN
4991
+ IF EXISTS (
4992
+ SELECT 1 FROM supacloud_artifacts.artifacts artifact
4993
+ WHERE artifact.storage_object_id = OLD.id
4994
+ ) THEN
4995
+ RAISE EXCEPTION 'SUPACLOUD_ARTIFACT_IMMUTABLE' USING ERRCODE = '55000';
4996
+ END IF;
4997
+ RETURN CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END;
4998
+ END;
4999
+ $$;
5000
+
5001
+ DROP TRIGGER IF EXISTS supacloud_artifact_storage_fence ON storage.objects;
5002
+ CREATE TRIGGER supacloud_artifact_storage_fence
5003
+ BEFORE UPDATE OR DELETE ON storage.objects
5004
+ FOR EACH ROW EXECUTE FUNCTION supacloud_artifacts.guard_storage_object();
5005
+
5006
+ CREATE OR REPLACE FUNCTION supacloud_artifacts.snapshot(
5007
+ p_artifact_id uuid,
5008
+ p_idempotent boolean DEFAULT false
5009
+ ) RETURNS jsonb
5010
+ LANGUAGE sql STABLE SECURITY DEFINER SET search_path = '' AS $$
5011
+ SELECT jsonb_build_object(
5012
+ 'artifactId', artifact.id,
5013
+ 'bucketId', artifact.bucket_id,
5014
+ 'objectPath', artifact.object_path,
5015
+ 'objectVersion', artifact.object_version,
5016
+ 'artifactType', artifact.artifact_type,
5017
+ 'sha256', artifact.sha256,
5018
+ 'sizeBytes', artifact.size_bytes::text,
5019
+ 'mimeType', artifact.mime_type,
5020
+ 'metadata', artifact.metadata,
5021
+ 'retentionUntil', artifact.retention_until,
5022
+ 'createdBy', artifact.created_by,
5023
+ 'createdAt', artifact.created_at,
5024
+ 'idempotent', p_idempotent,
5025
+ 'parents', coalesce((
5026
+ SELECT jsonb_agg(jsonb_build_object(
5027
+ 'artifactId', edge.parent_artifact_id,
5028
+ 'relationType', edge.relation_type,
5029
+ 'metadata', edge.metadata,
5030
+ 'createdAt', edge.created_at
5031
+ ) ORDER BY edge.created_at, edge.parent_artifact_id)
5032
+ FROM supacloud_artifacts.lineage edge
5033
+ WHERE edge.child_artifact_id = artifact.id
5034
+ ), '[]'::jsonb)
5035
+ )
5036
+ FROM supacloud_artifacts.artifacts artifact
5037
+ WHERE artifact.id = p_artifact_id
5038
+ $$;
5039
+
5040
+ DROP FUNCTION IF EXISTS supacloud_artifacts.register(uuid, text, text, text, text, bigint, text, jsonb, timestamptz, uuid);
5041
+ CREATE OR REPLACE FUNCTION supacloud_artifacts.register(request jsonb)
5042
+ RETURNS jsonb
5043
+ LANGUAGE plpgsql VOLATILE SECURITY DEFINER SET search_path = '' AS $$
5044
+ DECLARE
5045
+ artifact_id uuid;
5046
+ size_bytes bigint;
5047
+ metadata jsonb;
5048
+ retention_until timestamptz;
5049
+ created_by uuid;
5050
+ normalized_bucket text;
5051
+ normalized_path text;
5052
+ normalized_type text;
5053
+ normalized_sha256 text;
5054
+ normalized_mime text;
5055
+ object_row storage.objects%ROWTYPE;
5056
+ existing supacloud_artifacts.artifacts%ROWTYPE;
5057
+ BEGIN
5058
+ IF jsonb_typeof(request) IS DISTINCT FROM 'object' THEN
5059
+ RAISE EXCEPTION 'SUPACLOUD_ARTIFACT_INVALID' USING ERRCODE = '22023';
5060
+ END IF;
5061
+ artifact_id := (request ->> 'artifactId')::uuid;
5062
+ size_bytes := (request ->> 'sizeBytes')::bigint;
5063
+ metadata := coalesce(request -> 'metadata', '{}'::jsonb);
5064
+ retention_until := (request ->> 'retentionUntil')::timestamptz;
5065
+ created_by := (request ->> 'createdBy')::uuid;
5066
+ normalized_bucket := nullif(btrim(request ->> 'bucketId'), '');
5067
+ normalized_path := nullif(btrim(request ->> 'objectPath'), '');
5068
+ normalized_type := nullif(btrim(request ->> 'artifactType'), '');
5069
+ normalized_sha256 := lower(nullif(btrim(request ->> 'sha256'), ''));
5070
+ normalized_mime := lower(nullif(btrim(request ->> 'mimeType'), ''));
5071
+ IF artifact_id IS NULL OR normalized_bucket IS NULL OR normalized_path IS NULL
5072
+ OR normalized_path LIKE '/%' OR normalized_path LIKE '%\\%'
5073
+ OR normalized_path ~ '(^|/)(.|..)(/|$)'
5074
+ OR normalized_type IS NULL
5075
+ OR normalized_type !~ '^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$'
5076
+ OR normalized_sha256 IS NULL OR normalized_sha256 !~ '^[0-9a-f]{64}$'
5077
+ OR size_bytes IS NULL OR size_bytes < 0
5078
+ OR normalized_mime IS NULL OR char_length(normalized_mime) > 255
5079
+ OR jsonb_typeof(metadata) IS DISTINCT FROM 'object'
5080
+ OR (retention_until IS NOT NULL AND retention_until < now()) THEN
5081
+ RAISE EXCEPTION 'SUPACLOUD_ARTIFACT_INVALID' USING ERRCODE = '22023';
5082
+ END IF;
5083
+
5084
+ SELECT * INTO object_row FROM storage.objects
5085
+ WHERE bucket_id = normalized_bucket AND name = normalized_path;
5086
+ IF NOT FOUND THEN
5087
+ RAISE EXCEPTION 'SUPACLOUD_ARTIFACT_OBJECT_NOT_FOUND' USING ERRCODE = 'P0002';
5088
+ END IF;
5089
+
5090
+ PERFORM pg_advisory_xact_lock(hashtextextended(artifact_id::text, 0));
5091
+ SELECT * INTO existing FROM supacloud_artifacts.artifacts WHERE id = artifact_id;
5092
+ IF FOUND THEN
5093
+ IF existing.storage_object_id <> object_row.id
5094
+ OR existing.artifact_type <> normalized_type
5095
+ OR existing.sha256 <> normalized_sha256
5096
+ OR existing.size_bytes <> size_bytes
5097
+ OR existing.mime_type <> normalized_mime
5098
+ OR existing.metadata <> metadata
5099
+ OR existing.retention_until IS DISTINCT FROM retention_until
5100
+ OR existing.created_by IS DISTINCT FROM created_by THEN
5101
+ RAISE EXCEPTION 'SUPACLOUD_ARTIFACT_IDEMPOTENCY_CONFLICT' USING ERRCODE = '23505';
5102
+ END IF;
5103
+ RETURN supacloud_artifacts.snapshot(artifact_id, true);
5104
+ END IF;
5105
+
5106
+ INSERT INTO supacloud_artifacts.artifacts (
5107
+ id, storage_object_id, bucket_id, object_path, object_version,
5108
+ artifact_type, sha256, size_bytes, mime_type, metadata,
5109
+ retention_until, created_by
5110
+ ) VALUES (
5111
+ artifact_id, object_row.id, normalized_bucket, normalized_path,
5112
+ object_row.version::text, normalized_type, normalized_sha256,
5113
+ size_bytes, normalized_mime, metadata, retention_until, created_by
5114
+ );
5115
+ RETURN supacloud_artifacts.snapshot(artifact_id, false);
5116
+ EXCEPTION
5117
+ WHEN invalid_parameter_value OR invalid_text_representation OR numeric_value_out_of_range THEN
5118
+ RAISE EXCEPTION 'SUPACLOUD_ARTIFACT_INVALID' USING ERRCODE = '22023';
5119
+ END;
5120
+ $$;
5121
+
5122
+ CREATE OR REPLACE FUNCTION supacloud_artifacts.link(
5123
+ p_parent_artifact_id uuid,
5124
+ p_child_artifact_id uuid,
5125
+ p_relation_type text,
5126
+ p_metadata jsonb
5127
+ ) RETURNS jsonb
5128
+ LANGUAGE plpgsql VOLATILE SECURITY DEFINER SET search_path = '' AS $$
5129
+ DECLARE
5130
+ normalized_relation text := nullif(btrim(p_relation_type), '');
5131
+ inserted_count integer;
5132
+ BEGIN
5133
+ IF p_parent_artifact_id IS NULL OR p_child_artifact_id IS NULL
5134
+ OR p_parent_artifact_id = p_child_artifact_id
5135
+ OR normalized_relation IS NULL
5136
+ OR normalized_relation !~ '^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$'
5137
+ OR jsonb_typeof(p_metadata) IS DISTINCT FROM 'object' THEN
5138
+ RAISE EXCEPTION 'SUPACLOUD_ARTIFACT_LINEAGE_INVALID' USING ERRCODE = '22023';
5139
+ END IF;
5140
+ IF EXISTS (
5141
+ WITH RECURSIVE descendants(artifact_id) AS (
5142
+ SELECT edge.child_artifact_id
5143
+ FROM supacloud_artifacts.lineage edge
5144
+ WHERE edge.parent_artifact_id = p_child_artifact_id
5145
+ UNION
5146
+ SELECT edge.child_artifact_id
5147
+ FROM supacloud_artifacts.lineage edge
5148
+ JOIN descendants current ON edge.parent_artifact_id = current.artifact_id
5149
+ )
5150
+ SELECT 1 FROM descendants WHERE artifact_id = p_parent_artifact_id
5151
+ ) THEN
5152
+ RAISE EXCEPTION 'SUPACLOUD_ARTIFACT_LINEAGE_CYCLE' USING ERRCODE = '23514';
5153
+ END IF;
5154
+ INSERT INTO supacloud_artifacts.lineage (
5155
+ parent_artifact_id, child_artifact_id, relation_type, metadata
5156
+ ) VALUES (
5157
+ p_parent_artifact_id, p_child_artifact_id, normalized_relation, p_metadata
5158
+ ) ON CONFLICT DO NOTHING;
5159
+ GET DIAGNOSTICS inserted_count = ROW_COUNT;
5160
+ IF inserted_count = 0 AND NOT EXISTS (
5161
+ SELECT 1 FROM supacloud_artifacts.lineage
5162
+ WHERE parent_artifact_id = p_parent_artifact_id
5163
+ AND child_artifact_id = p_child_artifact_id
5164
+ AND relation_type = normalized_relation
5165
+ AND metadata = p_metadata
5166
+ ) THEN
5167
+ RAISE EXCEPTION 'SUPACLOUD_ARTIFACT_IDEMPOTENCY_CONFLICT' USING ERRCODE = '23505';
5168
+ END IF;
5169
+ RETURN supacloud_artifacts.snapshot(p_child_artifact_id, inserted_count = 0);
5170
+ END;
5171
+ $$;
5172
+
5173
+ CREATE OR REPLACE FUNCTION public.supacloud_artifact_register(request jsonb)
5174
+ RETURNS jsonb
5175
+ LANGUAGE sql VOLATILE SECURITY DEFINER SET search_path = '' AS $$
5176
+ SELECT supacloud_artifacts.register(request)
5177
+ $$;
5178
+
5179
+ CREATE OR REPLACE FUNCTION public.supacloud_artifact_get(request jsonb)
5180
+ RETURNS jsonb
5181
+ LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = '' AS $$
5182
+ BEGIN
5183
+ RETURN supacloud_artifacts.snapshot((request ->> 'artifactId')::uuid, false);
5184
+ EXCEPTION
5185
+ WHEN invalid_parameter_value OR invalid_text_representation THEN
5186
+ RAISE EXCEPTION 'SUPACLOUD_ARTIFACT_GET_INVALID' USING ERRCODE = '22023';
5187
+ END;
5188
+ $$;
5189
+
5190
+ CREATE OR REPLACE FUNCTION public.supacloud_artifact_link(request jsonb)
5191
+ RETURNS jsonb
5192
+ LANGUAGE plpgsql VOLATILE SECURITY DEFINER SET search_path = '' AS $$
5193
+ BEGIN
5194
+ RETURN supacloud_artifacts.link(
5195
+ (request ->> 'parentArtifactId')::uuid,
5196
+ (request ->> 'childArtifactId')::uuid,
5197
+ request ->> 'relationType',
5198
+ coalesce(request -> 'metadata', '{}'::jsonb)
5199
+ );
5200
+ EXCEPTION
5201
+ WHEN invalid_parameter_value OR invalid_text_representation THEN
5202
+ RAISE EXCEPTION 'SUPACLOUD_ARTIFACT_LINEAGE_INVALID' USING ERRCODE = '22023';
5203
+ END;
5204
+ $$;
5205
+
5206
+ REVOKE ALL ON ALL TABLES IN SCHEMA supacloud_artifacts
5207
+ FROM PUBLIC, anon, authenticated, service_role;
5208
+ REVOKE ALL ON ALL FUNCTIONS IN SCHEMA supacloud_artifacts
5209
+ FROM PUBLIC, anon, authenticated, service_role;
5210
+ REVOKE ALL ON FUNCTION public.supacloud_artifact_register(jsonb) FROM PUBLIC, anon, authenticated;
5211
+ REVOKE ALL ON FUNCTION public.supacloud_artifact_get(jsonb) FROM PUBLIC, anon, authenticated;
5212
+ REVOKE ALL ON FUNCTION public.supacloud_artifact_link(jsonb) FROM PUBLIC, anon, authenticated;
5213
+ GRANT EXECUTE ON FUNCTION public.supacloud_artifact_register(jsonb) TO service_role;
5214
+ GRANT EXECUTE ON FUNCTION public.supacloud_artifact_get(jsonb) TO service_role;
5215
+ GRANT EXECUTE ON FUNCTION public.supacloud_artifact_link(jsonb) TO service_role;
5216
+ -- supacloud:sql-module:artifacts-public:end
4770
5217
  `;
4771
5218
  var CRON_SQL = `
4772
5219
  create schema if not exists cron;
@@ -7845,8 +8292,7 @@ class ImageTransformCache {
7845
8292
  this.store(key, transformResult);
7846
8293
  return transformResult;
7847
8294
  } finally {
7848
- if (this.inFlight.get(key) === transform)
7849
- this.inFlight.delete(key);
8295
+ this.inFlight.delete(key);
7850
8296
  }
7851
8297
  }
7852
8298
  store(key, transform) {
@@ -10260,12 +10706,260 @@ import { dirname as dirname2, join as join2 } from "path";
10260
10706
  // src/runtime/node/native/engine.ts
10261
10707
  import { execFileSync, spawn, spawnSync } from "child_process";
10262
10708
  import { createHash as createHash2, randomBytes as randomBytes2 } from "crypto";
10263
- import { appendFileSync, chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync } from "fs";
10709
+ import { appendFileSync, chmodSync, existsSync as existsSync2, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync } from "fs";
10264
10710
  import { writeFile } from "fs/promises";
10265
10711
  import { homedir, tmpdir } from "os";
10266
10712
  import { join } from "path";
10267
10713
  import { extract as extractTar } from "tar";
10268
10714
 
10715
+ // src/runtime/node/native/replication.ts
10716
+ import { existsSync, statSync, writeFileSync } from "fs";
10717
+ import { isIP } from "net";
10718
+ import { resolve as resolve2 } from "path";
10719
+ var POWERSYNC_REPLICATION_ROLE = "supacloud_powersync";
10720
+ var POWERSYNC_PUBLICATION = "powersync";
10721
+ function validatePowerSyncReplicationOptions(options) {
10722
+ const host = options.host.trim();
10723
+ validateConnectionInput(host, options.port, options.password);
10724
+ const publicationTables = uniqueSorted(options.publicationTables.map(normalizeQualifiedTable));
10725
+ const allowCidrs = uniqueSorted(options.allowCidrs.map(normalizeCidr));
10726
+ validateAllowlists(publicationTables, allowCidrs);
10727
+ validateNetworkBoundary(host, allowCidrs, Boolean(options.tls));
10728
+ const tls = options.tls ? validateTlsOptions(options.tls) : undefined;
10729
+ return { ...options, host, allowCidrs, publicationTables, tls };
10730
+ }
10731
+ function buildPowerSyncPostgresArgs(options, hbaFile) {
10732
+ const validated = validatePowerSyncReplicationOptions(options);
10733
+ const args = [
10734
+ "-c",
10735
+ `listen_addresses=${validated.host}`,
10736
+ "-c",
10737
+ `port=${validated.port}`,
10738
+ "-c",
10739
+ "wal_level=logical",
10740
+ "-c",
10741
+ "max_wal_senders=4",
10742
+ "-c",
10743
+ "max_replication_slots=4",
10744
+ "-c",
10745
+ "max_slot_wal_keep_size=1024MB",
10746
+ "-c",
10747
+ "wal_keep_size=64MB",
10748
+ "-c",
10749
+ `hba_file=${resolve2(hbaFile)}`
10750
+ ];
10751
+ if (!validated.tls)
10752
+ return [...args, "-c", "ssl=off"];
10753
+ args.push("-c", "ssl=on", "-c", `ssl_cert_file=${validated.tls.certFile}`, "-c", `ssl_key_file=${validated.tls.keyFile}`);
10754
+ return args;
10755
+ }
10756
+ function writePowerSyncHba(path, options) {
10757
+ const validated = validatePowerSyncReplicationOptions(options);
10758
+ const hostRecord = validated.tls ? "hostssl" : "host";
10759
+ const lines = [
10760
+ "# Managed by SupaCloud Lite. Changes are replaced when the PowerSync profile starts.",
10761
+ "local all postgres trust",
10762
+ ...validated.allowCidrs.flatMap((cidr) => [
10763
+ `${hostRecord} postgres ${POWERSYNC_REPLICATION_ROLE} ${cidr} scram-sha-256`,
10764
+ `${hostRecord} replication ${POWERSYNC_REPLICATION_ROLE} ${cidr} scram-sha-256`
10765
+ ]),
10766
+ "host all all 0.0.0.0/0 reject",
10767
+ "host all all ::/0 reject",
10768
+ ""
10769
+ ];
10770
+ writeFileSync(path, lines.join(`
10771
+ `), { mode: 384 });
10772
+ }
10773
+ async function ensurePowerSyncReplicationCatalog(engine, options) {
10774
+ const validated = validatePowerSyncReplicationOptions(options);
10775
+ await engine.transaction(async (tx) => {
10776
+ const tables = await inspectPublicationTables(tx, validated.publicationTables);
10777
+ validatePublicationTables(tables);
10778
+ await configureReplicationRole(tx, validated.password);
10779
+ await synchronizeReplicationGrants(tx, tables);
10780
+ await assertEffectiveSelectAllowlist(tx, tables);
10781
+ await synchronizePublication(tx, tables);
10782
+ });
10783
+ }
10784
+ function validatePublicationTables(tables) {
10785
+ const missing = tables.filter((table) => !table.present).map((table) => table.name);
10786
+ if (missing.length > 0) {
10787
+ throw new Error(`PowerSync publication tables do not exist: ${missing.join(", ")}`);
10788
+ }
10789
+ const unsupported2 = tables.filter((table) => table.present && !["r", "p"].includes(table.relkind ?? ""));
10790
+ if (unsupported2.length > 0) {
10791
+ throw new Error(`PowerSync publication only accepts ordinary or partitioned tables: ${unsupported2.map((table) => table.name).join(", ")}`);
10792
+ }
10793
+ }
10794
+ async function configureReplicationRole(tx, passwordValue) {
10795
+ await tx.exec(`
10796
+ DO $profile$
10797
+ BEGIN
10798
+ IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${POWERSYNC_REPLICATION_ROLE}') THEN
10799
+ CREATE ROLE ${POWERSYNC_REPLICATION_ROLE};
10800
+ END IF;
10801
+ END
10802
+ $profile$;
10803
+ ALTER ROLE ${POWERSYNC_REPLICATION_ROLE}
10804
+ WITH LOGIN NOINHERIT REPLICATION BYPASSRLS CONNECTION LIMIT 4 PASSWORD ${quoteLiteral2(passwordValue)};
10805
+ ALTER ROLE ${POWERSYNC_REPLICATION_ROLE} SET search_path = pg_catalog;
10806
+ GRANT CONNECT ON DATABASE postgres TO ${POWERSYNC_REPLICATION_ROLE};
10807
+ `);
10808
+ }
10809
+ async function synchronizeReplicationGrants(tx, tables) {
10810
+ await revokeReplicationGrants(tx);
10811
+ await grantReplicationAllowlist(tx, tables);
10812
+ }
10813
+ async function revokeReplicationGrants(tx) {
10814
+ const staleGrants = await tx.query(`
10815
+ SELECT DISTINCT table_schema, table_name
10816
+ FROM information_schema.role_table_grants
10817
+ WHERE grantee = '${POWERSYNC_REPLICATION_ROLE}' AND privilege_type = 'SELECT'
10818
+ `);
10819
+ for (const grant of staleGrants.rows) {
10820
+ await tx.exec(`REVOKE SELECT ON TABLE ${quoteIdentifier(grant.table_schema)}.${quoteIdentifier(grant.table_name)} ` + `FROM ${POWERSYNC_REPLICATION_ROLE}`);
10821
+ }
10822
+ const applicationSchemas = await tx.query(`
10823
+ SELECT nspname AS schema_name
10824
+ FROM pg_namespace
10825
+ WHERE nspname NOT IN ('pg_catalog', 'information_schema')
10826
+ AND nspname !~ '^pg_toast'
10827
+ `);
10828
+ for (const schema of applicationSchemas.rows) {
10829
+ await tx.exec(`REVOKE USAGE ON SCHEMA ${quoteIdentifier(schema.schema_name)} FROM ${POWERSYNC_REPLICATION_ROLE}`);
10830
+ }
10831
+ }
10832
+ async function grantReplicationAllowlist(tx, tables) {
10833
+ const schemas = uniqueSorted(tables.map((table) => table.schema));
10834
+ for (const schema of schemas) {
10835
+ await tx.exec(`GRANT USAGE ON SCHEMA ${quoteIdentifier(schema)} TO ${POWERSYNC_REPLICATION_ROLE}`);
10836
+ }
10837
+ for (const table of tables) {
10838
+ await tx.exec(`GRANT SELECT ON TABLE ${quoteQualifiedTable(table.name)} TO ${POWERSYNC_REPLICATION_ROLE}`);
10839
+ }
10840
+ }
10841
+ async function assertEffectiveSelectAllowlist(tx, tables) {
10842
+ const allowed = new Set(tables.map((table) => table.name));
10843
+ const selectable = await tx.query(`
10844
+ SELECT namespace.nspname || '.' || relation.relname AS qualified_name
10845
+ FROM pg_class relation
10846
+ JOIN pg_namespace namespace ON namespace.oid = relation.relnamespace
10847
+ WHERE relation.relkind IN ('r', 'p')
10848
+ AND namespace.nspname NOT IN ('pg_catalog', 'information_schema')
10849
+ AND namespace.nspname !~ '^pg_toast'
10850
+ AND has_table_privilege('${POWERSYNC_REPLICATION_ROLE}', relation.oid, 'SELECT')
10851
+ ORDER BY namespace.nspname, relation.relname
10852
+ `);
10853
+ const outsideAllowlist = selectable.rows.map((row) => row.qualified_name).filter((table) => !allowed.has(table));
10854
+ if (outsideAllowlist.length > 0) {
10855
+ throw new Error(`PowerSync role has SELECT outside the publication allowlist: ${outsideAllowlist.join(", ")}`);
10856
+ }
10857
+ }
10858
+ async function synchronizePublication(tx, tables) {
10859
+ const publication = await tx.query(`SELECT puballtables FROM pg_publication WHERE pubname = '${POWERSYNC_PUBLICATION}'`);
10860
+ if (publication.rows[0]?.puballtables) {
10861
+ throw new Error("existing powersync publication uses FOR ALL TABLES; replace it with an explicit allowlist");
10862
+ }
10863
+ if (publication.rows.length === 0) {
10864
+ await tx.exec(`CREATE PUBLICATION ${POWERSYNC_PUBLICATION} WITH (publish = 'insert, update, delete')`);
10865
+ }
10866
+ await tx.exec(`ALTER PUBLICATION ${POWERSYNC_PUBLICATION} SET TABLE ${tables.map((table) => quoteQualifiedTable(table.name)).join(", ")}; ` + `ALTER PUBLICATION ${POWERSYNC_PUBLICATION} SET (publish = 'insert, update, delete')`);
10867
+ }
10868
+ async function inspectPublicationTables(tx, names) {
10869
+ const rows = [];
10870
+ for (const name of names) {
10871
+ const tableLookup = await tx.query(`
10872
+ SELECT namespace.nspname AS schema_name, relation.relkind
10873
+ FROM pg_class relation
10874
+ JOIN pg_namespace namespace ON namespace.oid = relation.relnamespace
10875
+ WHERE relation.oid = to_regclass($1)
10876
+ `, [name]);
10877
+ const row = tableLookup.rows[0];
10878
+ rows.push({ name, schema: row?.schema_name, relkind: row?.relkind, present: Boolean(row) });
10879
+ }
10880
+ return rows;
10881
+ }
10882
+ function validateConnectionInput(host, port, password) {
10883
+ if (!host || host !== "localhost" && isIP(host) === 0) {
10884
+ throw new Error("PowerSync replication host must be localhost or an explicit IP address");
10885
+ }
10886
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
10887
+ throw new Error("PowerSync replication port must be between 1 and 65535");
10888
+ }
10889
+ if (password.length < 32) {
10890
+ throw new Error("SUPACLOUD_LITE_POWERSYNC_PASSWORD must contain at least 32 characters");
10891
+ }
10892
+ if (password.includes("\x00"))
10893
+ throw new Error("SUPACLOUD_LITE_POWERSYNC_PASSWORD must not contain NUL bytes");
10894
+ }
10895
+ function validateAllowlists(publicationTables, allowCidrs) {
10896
+ if (publicationTables.length === 0) {
10897
+ throw new Error("PowerSync replication requires an explicit publication table allowlist");
10898
+ }
10899
+ if (allowCidrs.length === 0)
10900
+ throw new Error("PowerSync replication requires at least one client CIDR");
10901
+ }
10902
+ function validateNetworkBoundary(host, allowCidrs, tls) {
10903
+ if (!isLoopbackHost(host) && !tls) {
10904
+ throw new Error("non-loopback PowerSync replication requires TLS certificate and key files");
10905
+ }
10906
+ if (!tls && allowCidrs.some((cidr) => !isLoopbackCidr(cidr))) {
10907
+ throw new Error("non-loopback PowerSync client CIDRs require TLS");
10908
+ }
10909
+ }
10910
+ function validateTlsOptions(options) {
10911
+ const tls = {
10912
+ certFile: resolve2(options.certFile),
10913
+ keyFile: resolve2(options.keyFile)
10914
+ };
10915
+ for (const path of [tls.certFile, tls.keyFile]) {
10916
+ if (!existsSync(path) || !statSync(path).isFile())
10917
+ throw new Error(`PowerSync TLS file does not exist: ${path}`);
10918
+ }
10919
+ if (process.platform !== "win32" && (statSync(tls.keyFile).mode & 63) !== 0) {
10920
+ throw new Error("PowerSync TLS private key must not be readable by group or other users");
10921
+ }
10922
+ return tls;
10923
+ }
10924
+ function normalizeQualifiedTable(tableName) {
10925
+ const normalized = tableName.trim().toLowerCase();
10926
+ if (!/^[a-z_][a-z0-9_]*\.[a-z_][a-z0-9_]*$/.test(normalized)) {
10927
+ throw new Error(`invalid PowerSync publication table: ${tableName}`);
10928
+ }
10929
+ return normalized;
10930
+ }
10931
+ function normalizeCidr(cidr) {
10932
+ const normalized = cidr.trim();
10933
+ const match = /^(.+)\/(\d{1,3})$/.exec(normalized);
10934
+ if (!match)
10935
+ throw new Error(`invalid PowerSync client CIDR: ${cidr}`);
10936
+ const address = match[1];
10937
+ const family = isIP(address);
10938
+ const prefix = Number(match[2]);
10939
+ if (family === 0 || prefix < 0 || prefix > (family === 4 ? 32 : 128)) {
10940
+ throw new Error(`invalid PowerSync client CIDR: ${cidr}`);
10941
+ }
10942
+ return `${address}/${prefix}`;
10943
+ }
10944
+ function isLoopbackHost(host) {
10945
+ return host === "localhost" || host === "127.0.0.1" || host === "::1";
10946
+ }
10947
+ function isLoopbackCidr(cidr) {
10948
+ return cidr === "127.0.0.1/32" || cidr === "::1/128";
10949
+ }
10950
+ function quoteIdentifier(identifier) {
10951
+ return `"${identifier.replaceAll('"', '""')}"`;
10952
+ }
10953
+ function quoteQualifiedTable(tableName) {
10954
+ return tableName.split(".").map(quoteIdentifier).join(".");
10955
+ }
10956
+ function quoteLiteral2(literal) {
10957
+ return `'${literal.replaceAll("'", "''")}'`;
10958
+ }
10959
+ function uniqueSorted(values) {
10960
+ return [...new Set(values)].sort();
10961
+ }
10962
+
10269
10963
  // src/runtime/node/native/wire.ts
10270
10964
  import { createConnection } from "net";
10271
10965
  import { createHash, createHmac, pbkdf2Sync, randomBytes } from "crypto";
@@ -10297,7 +10991,7 @@ class PgWireClient {
10297
10991
  return client;
10298
10992
  }
10299
10993
  open(opts) {
10300
- return new Promise((resolve2, reject) => {
10994
+ return new Promise((resolve3, reject) => {
10301
10995
  this.socket = opts.socketPath ? createConnection(opts.socketPath) : createConnection(opts.port ?? 5432, opts.host ?? "127.0.0.1");
10302
10996
  this.socket.on("error", (e) => {
10303
10997
  if (this.pending)
@@ -10395,7 +11089,7 @@ class PgWireClient {
10395
11089
  this.buffer = Buffer.concat([this.buffer, c]);
10396
11090
  this.processMessages();
10397
11091
  });
10398
- resolve2();
11092
+ resolve3();
10399
11093
  return;
10400
11094
  }
10401
11095
  }
@@ -10404,10 +11098,10 @@ class PgWireClient {
10404
11098
  });
10405
11099
  }
10406
11100
  run(send) {
10407
- const op = this.queue.then(() => new Promise((resolve2, reject) => {
11101
+ const op = this.queue.then(() => new Promise((resolve3, reject) => {
10408
11102
  if (this.closed)
10409
11103
  return reject(new Error("connection closed"));
10410
- this.pending = { resolve: resolve2, reject, results: [], columns: [], error: null };
11104
+ this.pending = { resolve: resolve3, reject, results: [], columns: [], error: null };
10411
11105
  send();
10412
11106
  }));
10413
11107
  this.queue = op.catch(() => {});
@@ -10438,11 +11132,11 @@ class PgWireClient {
10438
11132
  return results[0] ?? { rows: [] };
10439
11133
  }
10440
11134
  close() {
10441
- return new Promise((resolve2) => {
11135
+ return new Promise((resolve3) => {
10442
11136
  if (this.closed)
10443
- return resolve2();
11137
+ return resolve3();
10444
11138
  this.socket.write(message(88, Buffer.alloc(0)));
10445
- this.socket.end(() => resolve2());
11139
+ this.socket.end(() => resolve3());
10446
11140
  });
10447
11141
  }
10448
11142
  nextMessage() {
@@ -10831,7 +11525,7 @@ function reportedGlibcVersion() {
10831
11525
  }
10832
11526
  function glibcDynamicLoaderPresent() {
10833
11527
  const loaderPaths = process.arch === "x64" ? GLIBC_DYNAMIC_LOADERS.x64 : process.arch === "arm64" ? GLIBC_DYNAMIC_LOADERS.arm64 : [];
10834
- return loaderPaths.some((loaderPath) => existsSync(loaderPath));
11528
+ return loaderPaths.some((loaderPath) => existsSync2(loaderPath));
10835
11529
  }
10836
11530
  function lddVersion() {
10837
11531
  const command = spawnSync("ldd", ["--version"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
@@ -10851,7 +11545,7 @@ function target() {
10851
11545
  throw new Error(`unsupported platform for native engine: ${process.platform} (use the default PGlite engine)`);
10852
11546
  }
10853
11547
  function isCompleteInstall(dir) {
10854
- return existsSync(join(dir, "bin", "postgres")) && existsSync(join(dir, "share", "postgres.bki"));
11548
+ return existsSync2(join(dir, "bin", "postgres")) && existsSync2(join(dir, "share", "postgres.bki"));
10855
11549
  }
10856
11550
  var PINNED_SHA256 = {
10857
11551
  "postgresql-17.7.0-x86_64-unknown-linux-gnu": "66ad03281a43624f955c8e16ac975cb0ab751e7edf8ba35308e3b08dd7d065c3",
@@ -10957,7 +11651,7 @@ async function fetchRelease(url) {
10957
11651
  lastError = error;
10958
11652
  }
10959
11653
  if (attempt < 3)
10960
- await new Promise((resolve2) => setTimeout(resolve2, attempt * 500));
11654
+ await new Promise((resolve3) => setTimeout(resolve3, attempt * 500));
10961
11655
  }
10962
11656
  throw lastError instanceof Error ? lastError : new Error(`failed to download ${url}`);
10963
11657
  }
@@ -10969,6 +11663,7 @@ dynamic_shared_memory_type = posix
10969
11663
  max_connections = 10
10970
11664
  wal_level = minimal
10971
11665
  max_wal_senders = 0
11666
+ max_replication_slots = 0
10972
11667
  logging_collector = off
10973
11668
  `;
10974
11669
  async function createNativeEngine(opts) {
@@ -10979,7 +11674,7 @@ async function createNativeEngine(opts) {
10979
11674
  try {
10980
11675
  const installDir = await ensurePostgres(opts.version, opts.cacheDir, opts.log, opts.downloadMirror);
10981
11676
  const bin = (name) => join(installDir, "bin", name);
10982
- if (!existsSync(join(opts.dataDir, "PG_VERSION"))) {
11677
+ if (!existsSync2(join(opts.dataDir, "PG_VERSION"))) {
10983
11678
  mkdirSync(opts.dataDir, { recursive: true });
10984
11679
  try {
10985
11680
  execFileSync(bin("initdb"), ["-U", "postgres", "-A", "trust", "-E", "UTF8", "-D", opts.dataDir], {
@@ -10995,7 +11690,20 @@ ${stderr || error.message}`);
10995
11690
  removeStalePidFile(join(opts.dataDir, "postmaster.pid"));
10996
11691
  socketDirectory = mkdtempSync(join(tmpdir(), "scl-"));
10997
11692
  chmodSync(socketDirectory, 448);
10998
- postgres = spawn(bin("postgres"), ["-D", opts.dataDir, "-k", socketDirectory, "-c", "timezone=UTC"], {
11693
+ const replicationHba = opts.replication ? join(opts.dataDir, "supacloud-powersync-hba.conf") : undefined;
11694
+ if (opts.replication && replicationHba)
11695
+ writePowerSyncHba(replicationHba, opts.replication);
11696
+ const replicationArgs = opts.replication && replicationHba ? buildPowerSyncPostgresArgs(opts.replication, replicationHba) : [
11697
+ "-c",
11698
+ "listen_addresses=",
11699
+ "-c",
11700
+ "wal_level=minimal",
11701
+ "-c",
11702
+ "max_wal_senders=0",
11703
+ "-c",
11704
+ "max_replication_slots=0"
11705
+ ];
11706
+ postgres = spawn(bin("postgres"), ["-D", opts.dataDir, "-k", socketDirectory, "-c", "timezone=UTC", ...replicationArgs], {
10999
11707
  stdio: ["ignore", "ignore", "pipe"],
11000
11708
  detached: false
11001
11709
  });
@@ -11011,7 +11719,8 @@ ${stderr || error.message}`);
11011
11719
  };
11012
11720
  process.once("exit", killPostgres);
11013
11721
  removeExitHandler = () => process.off("exit", killPostgres);
11014
- const socketPath = join(socketDirectory, ".s.PGSQL.5432");
11722
+ const postgresPort = opts.replication?.port ?? 5432;
11723
+ const socketPath = join(socketDirectory, `.s.PGSQL.${postgresPort}`);
11015
11724
  const connect = async () => {
11016
11725
  const deadline = Date.now() + 20000;
11017
11726
  while (Date.now() <= deadline) {
@@ -11026,7 +11735,7 @@ ${detail}` : " (no output)"}
11026
11735
  ` + `data dir: ${opts.dataDir}
11027
11736
  ` + "If a previous run is still holding it, stop it; or delete the data dir to start fresh.");
11028
11737
  }
11029
- await new Promise((resolve2) => setTimeout(resolve2, 150));
11738
+ await new Promise((resolve3) => setTimeout(resolve3, 150));
11030
11739
  }
11031
11740
  }
11032
11741
  throw new Error(`timed out waiting for embedded postgres at ${socketPath}`);
@@ -11054,19 +11763,19 @@ async function stopPostgres(postgres, hasExited) {
11054
11763
  if (hasExited())
11055
11764
  return;
11056
11765
  postgres.kill("SIGINT");
11057
- await new Promise((resolve2) => {
11766
+ await new Promise((resolve3) => {
11058
11767
  const killTimeout = setTimeout(() => {
11059
11768
  postgres.kill("SIGKILL");
11060
- resolve2();
11769
+ resolve3();
11061
11770
  }, 5000);
11062
11771
  postgres.once("exit", () => {
11063
11772
  clearTimeout(killTimeout);
11064
- resolve2();
11773
+ resolve3();
11065
11774
  });
11066
11775
  });
11067
11776
  }
11068
11777
  function removeStalePidFile(pidPath) {
11069
- if (!existsSync(pidPath))
11778
+ if (!existsSync2(pidPath))
11070
11779
  return;
11071
11780
  try {
11072
11781
  const pid = Number.parseInt(readFileSync(pidPath, "utf8").split(`
@@ -11203,6 +11912,236 @@ async function closeResources(...resources) {
11203
11912
  throw failed.reason;
11204
11913
  }
11205
11914
 
11915
+ // src/runtime/node/native/readiness.ts
11916
+ function liteCapabilities(engine, replicationProfile) {
11917
+ if (engine === "pglite") {
11918
+ return {
11919
+ engine,
11920
+ state_machine_sql: "supported",
11921
+ durable_workflows: "supported",
11922
+ commands: "supported",
11923
+ artifacts: "supported",
11924
+ postgrest_schema_config: "static",
11925
+ logical_replication: "unsupported",
11926
+ powersync_source: "unsupported"
11927
+ };
11928
+ }
11929
+ return {
11930
+ engine,
11931
+ state_machine_sql: "supported",
11932
+ durable_workflows: "supported",
11933
+ commands: "supported",
11934
+ artifacts: "supported",
11935
+ postgrest_schema_config: "static",
11936
+ logical_replication: replicationProfile ? "supported" : "disabled",
11937
+ powersync_source: replicationProfile ? "supported" : "disabled",
11938
+ ...replicationProfile ? { replication_profile: replicationProfile } : {}
11939
+ };
11940
+ }
11941
+ async function inspectPowerSyncReadiness(engine, options) {
11942
+ return buildReadiness(await loadReplicationInventory(engine), options);
11943
+ }
11944
+ async function loadReplicationInventory(engine) {
11945
+ const [settings, senders, role, publication, publicationTables, selectableTables, slots] = await Promise.all([
11946
+ engine.query(`
11947
+ SELECT name, setting
11948
+ FROM pg_settings
11949
+ WHERE name IN (
11950
+ 'wal_level', 'max_wal_senders', 'max_replication_slots',
11951
+ 'max_slot_wal_keep_size', 'ssl', 'listen_addresses', 'port'
11952
+ )
11953
+ `),
11954
+ engine.query("SELECT count(*)::integer AS count FROM pg_stat_replication"),
11955
+ engine.query(`
11956
+ SELECT rolcanlogin, rolreplication, rolbypassrls
11957
+ FROM pg_roles WHERE rolname = '${POWERSYNC_REPLICATION_ROLE}'
11958
+ `),
11959
+ engine.query(`
11960
+ SELECT puballtables, pubinsert, pubupdate, pubdelete
11961
+ FROM pg_publication WHERE pubname = '${POWERSYNC_PUBLICATION}'
11962
+ `),
11963
+ engine.query(`
11964
+ SELECT
11965
+ quote_ident(namespace.nspname) || '.' || quote_ident(relation.relname) AS qualified_name,
11966
+ relation.relreplident = 'n' OR (
11967
+ relation.relreplident = 'd'
11968
+ AND NOT EXISTS (
11969
+ SELECT 1 FROM pg_index index_record
11970
+ WHERE index_record.indrelid = relation.oid AND index_record.indisprimary
11971
+ )
11972
+ ) AS replica_identity_missing
11973
+ FROM pg_publication_tables published
11974
+ JOIN pg_namespace namespace ON namespace.nspname = published.schemaname
11975
+ JOIN pg_class relation
11976
+ ON relation.relnamespace = namespace.oid AND relation.relname = published.tablename
11977
+ WHERE published.pubname = '${POWERSYNC_PUBLICATION}'
11978
+ ORDER BY namespace.nspname, relation.relname
11979
+ `),
11980
+ engine.query(`
11981
+ SELECT namespace.nspname || '.' || relation.relname AS qualified_name
11982
+ FROM pg_roles role_record
11983
+ JOIN pg_class relation ON true
11984
+ JOIN pg_namespace namespace ON namespace.oid = relation.relnamespace
11985
+ WHERE role_record.rolname = '${POWERSYNC_REPLICATION_ROLE}'
11986
+ AND relation.relkind IN ('r', 'p')
11987
+ AND namespace.nspname NOT IN ('pg_catalog', 'information_schema')
11988
+ AND namespace.nspname !~ '^pg_toast'
11989
+ AND has_table_privilege(role_record.oid, relation.oid, 'SELECT')
11990
+ ORDER BY namespace.nspname, relation.relname
11991
+ `),
11992
+ engine.query(`
11993
+ SELECT
11994
+ slot_name,
11995
+ active,
11996
+ wal_status,
11997
+ coalesce(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn), 0)::text AS retained_wal_bytes,
11998
+ coalesce(pg_wal_lsn_diff(pg_current_wal_lsn(), confirmed_flush_lsn), 0)::text AS unconfirmed_wal_bytes,
11999
+ safe_wal_size::text,
12000
+ invalidation_reason
12001
+ FROM pg_replication_slots
12002
+ WHERE slot_type = 'logical'
12003
+ ORDER BY slot_name
12004
+ `)
12005
+ ]);
12006
+ return {
12007
+ settings: new Map(settings.rows.map((row) => [row.name, row.setting])),
12008
+ activeSenders: senders.rows[0]?.count ?? 0,
12009
+ role: role.rows[0],
12010
+ publication: publication.rows[0],
12011
+ publicationTables: publicationTables.rows,
12012
+ selectableTables: selectableTables.rows.map((row) => row.qualified_name),
12013
+ slots: slots.rows
12014
+ };
12015
+ }
12016
+ function buildReadiness(inventory, options) {
12017
+ const actualTables = catalogTableNames(inventory.publicationTables);
12018
+ const expectedTables = [...options.publicationTables].sort();
12019
+ const missingIdentity = missingReplicaIdentity(inventory.publicationTables);
12020
+ const blockers = readinessBlockers(inventory, actualTables, expectedTables, missingIdentity);
12021
+ return {
12022
+ ready: blockers.length === 0,
12023
+ blockers,
12024
+ warnings: readinessWarnings(inventory),
12025
+ connection: {
12026
+ host: options.host,
12027
+ port: options.port,
12028
+ tls: Boolean(options.tls),
12029
+ allowed_cidrs: options.allowCidrs
12030
+ },
12031
+ wal: {
12032
+ level: inventory.settings.get("wal_level") ?? "unknown",
12033
+ max_senders: integerSetting(inventory.settings.get("max_wal_senders")),
12034
+ active_senders: inventory.activeSenders,
12035
+ max_replication_slots: integerSetting(inventory.settings.get("max_replication_slots")),
12036
+ used_replication_slots: inventory.slots.length,
12037
+ max_slot_wal_keep_size: inventory.settings.get("max_slot_wal_keep_size") ?? "unknown"
12038
+ },
12039
+ role: roleReadiness(inventory.role, unexpectedSelectableTables(inventory, expectedTables)),
12040
+ publication: publicationReadiness(inventory.publication, actualTables, expectedTables, missingIdentity),
12041
+ slots: inventory.slots.map((slot) => ({
12042
+ name: slot.slot_name,
12043
+ active: slot.active,
12044
+ wal_status: slot.wal_status,
12045
+ retained_wal_bytes: slot.retained_wal_bytes,
12046
+ unconfirmed_wal_bytes: slot.unconfirmed_wal_bytes,
12047
+ safe_wal_size: slot.safe_wal_size,
12048
+ invalidation_reason: slot.invalidation_reason
12049
+ }))
12050
+ };
12051
+ }
12052
+ function readinessBlockers(inventory, actualTables, expectedTables, missingIdentity) {
12053
+ const blockers = capacityBlockers(inventory);
12054
+ if (!roleIsReady(inventory.role))
12055
+ blockers.push("POWERSYNC_ROLE_NOT_READY");
12056
+ if (unexpectedSelectableTables(inventory, expectedTables).length > 0) {
12057
+ blockers.push("POWERSYNC_ROLE_SELECT_OUTSIDE_ALLOWLIST");
12058
+ }
12059
+ blockers.push(...publicationBlockers(inventory.publication, actualTables, expectedTables, missingIdentity));
12060
+ return blockers;
12061
+ }
12062
+ function capacityBlockers(inventory) {
12063
+ const blockers = [];
12064
+ const maxSenders = integerSetting(inventory.settings.get("max_wal_senders"));
12065
+ const maxSlots = integerSetting(inventory.settings.get("max_replication_slots"));
12066
+ if (inventory.settings.get("wal_level") !== "logical")
12067
+ blockers.push("WAL_LEVEL_NOT_LOGICAL");
12068
+ if (maxSenders - inventory.activeSenders < 1)
12069
+ blockers.push("NO_FREE_WAL_SENDER");
12070
+ if (maxSlots - inventory.slots.length < 1)
12071
+ blockers.push("NO_FREE_REPLICATION_SLOT");
12072
+ return blockers;
12073
+ }
12074
+ function publicationBlockers(publication, actualTables, expectedTables, missingIdentity) {
12075
+ if (!publication)
12076
+ return ["POWERSYNC_PUBLICATION_MISSING"];
12077
+ const blockers = [];
12078
+ if (publication.puballtables)
12079
+ blockers.push("POWERSYNC_PUBLICATION_NOT_ALLOWLISTED");
12080
+ if (!publication.pubinsert || !publication.pubupdate || !publication.pubdelete) {
12081
+ blockers.push("POWERSYNC_PUBLICATION_DML_INCOMPLETE");
12082
+ }
12083
+ if (!sameStrings(actualTables, expectedTables))
12084
+ blockers.push("POWERSYNC_PUBLICATION_TABLE_MISMATCH");
12085
+ if (missingIdentity.length > 0)
12086
+ blockers.push("POWERSYNC_REPLICA_IDENTITY_INCOMPLETE");
12087
+ return blockers;
12088
+ }
12089
+ function readinessWarnings(inventory) {
12090
+ const warnings = [];
12091
+ if (inventory.settings.get("max_slot_wal_keep_size") === "-1")
12092
+ warnings.push("SLOT_WAL_KEEP_SIZE_UNBOUNDED");
12093
+ if (inventory.slots.some((slot) => slot.wal_status === "lost" || slot.invalidation_reason)) {
12094
+ warnings.push("INVALID_LOGICAL_SLOTS");
12095
+ }
12096
+ return warnings;
12097
+ }
12098
+ function roleIsReady(role) {
12099
+ return Boolean(role?.rolcanlogin && role.rolreplication && role.rolbypassrls);
12100
+ }
12101
+ function roleReadiness(role, unexpectedTables) {
12102
+ return {
12103
+ name: POWERSYNC_REPLICATION_ROLE,
12104
+ present: Boolean(role),
12105
+ login: role?.rolcanlogin ?? false,
12106
+ replication: role?.rolreplication ?? false,
12107
+ bypass_rls: role?.rolbypassrls ?? false,
12108
+ unexpected_selectable_tables: unexpectedTables
12109
+ };
12110
+ }
12111
+ function unexpectedSelectableTables(inventory, expectedTables) {
12112
+ const expected = new Set(expectedTables);
12113
+ return inventory.selectableTables.filter((table) => !expected.has(table));
12114
+ }
12115
+ function publicationReadiness(publication, tables, expectedTables, missingIdentity) {
12116
+ return {
12117
+ name: POWERSYNC_PUBLICATION,
12118
+ present: Boolean(publication),
12119
+ all_tables: publication?.puballtables ?? false,
12120
+ tables,
12121
+ expected_tables: expectedTables,
12122
+ publishes_insert: publication?.pubinsert ?? false,
12123
+ publishes_update: publication?.pubupdate ?? false,
12124
+ publishes_delete: publication?.pubdelete ?? false,
12125
+ replica_identity_missing_tables: missingIdentity
12126
+ };
12127
+ }
12128
+ function catalogTableNames(rows) {
12129
+ return rows.map((row) => normalizeCatalogTable(row.qualified_name));
12130
+ }
12131
+ function missingReplicaIdentity(rows) {
12132
+ return rows.filter((row) => row.replica_identity_missing).map((row) => normalizeCatalogTable(row.qualified_name));
12133
+ }
12134
+ function integerSetting(value) {
12135
+ const parsed = Number(value);
12136
+ return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : 0;
12137
+ }
12138
+ function normalizeCatalogTable(value) {
12139
+ return value.replaceAll('"', "").toLowerCase();
12140
+ }
12141
+ function sameStrings(left, right) {
12142
+ return left.length === right.length && left.every((value, index) => value === right[index]);
12143
+ }
12144
+
11206
12145
  // src/runtime/node/project.ts
11207
12146
  import { readdir, readFile as readFile2 } from "fs/promises";
11208
12147
  import { join as join3 } from "path";
@@ -11250,7 +12189,7 @@ function isNotFound(error) {
11250
12189
 
11251
12190
  // src/project-runtime.ts
11252
12191
  import { chmod, link, lstat, mkdir as mkdir5, readFile as readFile6, realpath as realpath2, unlink as unlink2, writeFile as writeFile5 } from "fs/promises";
11253
- import { dirname as dirname5, isAbsolute, join as join8, parse, relative, resolve as resolve2 } from "path";
12192
+ import { dirname as dirname5, isAbsolute, join as join8, parse, relative, resolve as resolve3 } from "path";
11254
12193
 
11255
12194
  // src/runtime/node/bun-server.ts
11256
12195
  async function serveBun(backend, opts = {}) {
@@ -11730,7 +12669,7 @@ import { pathToFileURL } from "url";
11730
12669
  // src/runtime/node/bundle-function.ts
11731
12670
  import { createHash as createHash3 } from "crypto";
11732
12671
  import { mkdir as mkdir4, readFile as readFile4, rm as rm3, writeFile as writeFile4 } from "fs/promises";
11733
- import { existsSync as existsSync2 } from "fs";
12672
+ import { existsSync as existsSync3 } from "fs";
11734
12673
  import { tmpdir as tmpdir3 } from "os";
11735
12674
  import { join as join6 } from "path";
11736
12675
  function rewriteRemoteSpecifier(spec) {
@@ -11744,7 +12683,7 @@ var HTTP_CACHE = join6(tmpdir3(), "supacloud-lite-fn-http");
11744
12683
  async function fetchModule(url) {
11745
12684
  const key = createHash3("sha256").update(url).digest("hex");
11746
12685
  const cached = join6(HTTP_CACHE, key);
11747
- if (existsSync2(cached))
12686
+ if (existsSync3(cached))
11748
12687
  return readFile4(cached, "utf8");
11749
12688
  const res = await fetch(url, { redirect: "follow" });
11750
12689
  if (!res.ok)
@@ -12002,7 +12941,7 @@ class S3StorageDriver {
12002
12941
  var RESET_INITIALIZATION_ERROR = 'db reset requires initialized state; run "supacloud-lite migrate" first';
12003
12942
  var RESET_INVALID_SECRETS_ERROR = "db reset requires a valid project secrets marker; restore the state before retrying";
12004
12943
  function resolveProjectPaths(options = {}) {
12005
- const projectDir = resolve2(options.projectDir ?? process.cwd());
12944
+ const projectDir = resolve3(options.projectDir ?? process.cwd());
12006
12945
  const stateDir = resolvePath(projectDir, options.stateDir ?? process.env.SUPACLOUD_LITE_STATE_DIR ?? ".supacloud-lite");
12007
12946
  const databaseEngine = resolveDatabaseEngine(options.engine, options.memory);
12008
12947
  const dataDir = options.memory ? undefined : resolvePath(projectDir, options.dataDir ?? process.env.SUPACLOUD_LITE_DATA_DIR ?? join8(stateDir, databaseEngine === "native" ? "pgdata" : "db"));
@@ -12017,7 +12956,7 @@ function resolveProjectPaths(options = {}) {
12017
12956
  };
12018
12957
  }
12019
12958
  async function assertResetPathsSafe(paths) {
12020
- const stateDir = resolve2(paths.stateDir);
12959
+ const stateDir = resolve3(paths.stateDir);
12021
12960
  if (stateDir === parse(stateDir).root)
12022
12961
  throw new Error("refusing to use the filesystem root as the state directory");
12023
12962
  const stateInfo = await requiredResetEntry(stateDir);
@@ -12025,7 +12964,7 @@ async function assertResetPathsSafe(paths) {
12025
12964
  throw new Error(`refusing to reset through an invalid state directory: ${stateDir}`);
12026
12965
  }
12027
12966
  const canonicalStateDir = await realpath2(stateDir);
12028
- const secretsFile = resolve2(paths.secretsFile);
12967
+ const secretsFile = resolve3(paths.secretsFile);
12029
12968
  if (secretsFile !== join8(stateDir, "secrets.json")) {
12030
12969
  throw new Error(`refusing to reset a state directory with an invalid secrets marker path: ${secretsFile}`);
12031
12970
  }
@@ -12039,7 +12978,7 @@ async function assertResetPathsSafe(paths) {
12039
12978
  ["storage", paths.storageDir]
12040
12979
  ];
12041
12980
  for (const [label, targetPath] of targets) {
12042
- const target2 = resolve2(targetPath);
12981
+ const target2 = resolve3(targetPath);
12043
12982
  const relativePath = relative(stateDir, target2);
12044
12983
  if (!relativePath || relativePath.startsWith("..") || isAbsolute(relativePath)) {
12045
12984
  throw new Error(`refusing to reset ${label} path outside the state directory: ${target2}`);
@@ -12088,7 +13027,7 @@ async function assertResetTargetCanonical(stateDir, canonicalStateDir, target2,
12088
13027
  current = parent;
12089
13028
  }
12090
13029
  const existingAncestor = await nearestExistingAncestor(target2);
12091
- const canonicalTarget = resolve2(await realpath2(existingAncestor), relative(existingAncestor, target2));
13030
+ const canonicalTarget = resolve3(await realpath2(existingAncestor), relative(existingAncestor, target2));
12092
13031
  const canonicalRelative = relative(canonicalStateDir, canonicalTarget);
12093
13032
  if (!canonicalRelative || canonicalRelative.startsWith("..") || isAbsolute(canonicalRelative)) {
12094
13033
  throw new Error(`refusing to reset ${label} path outside the canonical state directory: ${target2}`);
@@ -12172,6 +13111,7 @@ async function createProjectBackend(options = {}) {
12172
13111
  const configuredStorageBackend = options.storageDriver ? "fs" : resolveStorageBackend(options.storageBackend);
12173
13112
  const storageBackend = options.storageDriver ? "custom" : configuredStorageBackend;
12174
13113
  const databaseEngine = paths.databaseEngine;
13114
+ const replication = resolveNativeReplicationOptions(options, databaseEngine);
12175
13115
  if (paths.dataDir) {
12176
13116
  await mkdir5(paths.dataDir, { recursive: true, mode: 448 });
12177
13117
  await chmod(paths.dataDir, 448);
@@ -12179,7 +13119,7 @@ async function createProjectBackend(options = {}) {
12179
13119
  await mkdir5(paths.storageDir, { recursive: true, mode: 448 });
12180
13120
  await chmod(paths.storageDir, 448);
12181
13121
  const storageDriver = options.storageDriver ?? createStorageDriver(configuredStorageBackend, paths.storageDir, options.s3);
12182
- const engine = databaseEngine === "native" ? await createNativeEngine({ dataDir: paths.dataDir, log: options.log }) : undefined;
13122
+ const engine = databaseEngine === "native" ? await createNativeEngine({ dataDir: paths.dataDir, log: options.log, replication }) : undefined;
12183
13123
  let backend;
12184
13124
  try {
12185
13125
  backend = await createBackend({
@@ -12213,8 +13153,17 @@ async function createProjectBackend(options = {}) {
12213
13153
  storageDriver,
12214
13154
  log: options.log
12215
13155
  });
13156
+ if (replication && options.applyMigrations !== false) {
13157
+ await ensurePowerSyncReplicationCatalog(backend.db.engine, replication);
13158
+ }
12216
13159
  } catch (error) {
12217
- if (engine) {
13160
+ if (backend) {
13161
+ try {
13162
+ await backend.close();
13163
+ } catch (cleanupError) {
13164
+ throw new AggregateError([error, cleanupError], "project database startup cleanup failed");
13165
+ }
13166
+ } else if (engine) {
12218
13167
  try {
12219
13168
  await engine.close();
12220
13169
  } catch (cleanupError) {
@@ -12234,9 +13183,33 @@ async function createProjectBackend(options = {}) {
12234
13183
  functionNames: [...functions.keys()],
12235
13184
  webhookCount: webhooks.length,
12236
13185
  storageBackend,
12237
- databaseEngine
13186
+ databaseEngine,
13187
+ replicationProfile: replication?.profile
12238
13188
  };
12239
13189
  }
13190
+ function resolveNativeReplicationOptions(options, databaseEngine = resolveDatabaseEngine(options.engine, options.memory)) {
13191
+ const profile = options.replicationProfile ?? process.env.SUPACLOUD_LITE_REPLICATION_PROFILE;
13192
+ if (!profile)
13193
+ return;
13194
+ if (profile !== "powersync")
13195
+ throw new Error(`unsupported SUPACLOUD_LITE_REPLICATION_PROFILE: ${profile}`);
13196
+ if (databaseEngine !== "native")
13197
+ throw new Error("PowerSync replication is only supported by --engine native");
13198
+ const tlsCertFile = options.replicationTlsCertFile ?? process.env.SUPACLOUD_LITE_REPLICATION_TLS_CERT_FILE;
13199
+ const tlsKeyFile = options.replicationTlsKeyFile ?? process.env.SUPACLOUD_LITE_REPLICATION_TLS_KEY_FILE;
13200
+ if (Boolean(tlsCertFile) !== Boolean(tlsKeyFile)) {
13201
+ throw new Error("PowerSync replication TLS requires both certificate and key files");
13202
+ }
13203
+ return validatePowerSyncReplicationOptions({
13204
+ profile,
13205
+ host: options.replicationHost ?? process.env.SUPACLOUD_LITE_REPLICATION_HOST ?? "127.0.0.1",
13206
+ port: options.replicationPort ?? parsePort(process.env.SUPACLOUD_LITE_REPLICATION_PORT, 54322),
13207
+ allowCidrs: options.replicationAllowCidrs ?? commaSeparated(process.env.SUPACLOUD_LITE_REPLICATION_ALLOW_CIDRS, ["127.0.0.1/32", "::1/128"]),
13208
+ publicationTables: options.powersyncPublicationTables ?? commaSeparated(process.env.SUPACLOUD_LITE_POWERSYNC_TABLES),
13209
+ password: options.powersyncPassword ?? process.env.SUPACLOUD_LITE_POWERSYNC_PASSWORD ?? "",
13210
+ tls: tlsCertFile && tlsKeyFile ? { certFile: tlsCertFile, keyFile: tlsKeyFile } : undefined
13211
+ });
13212
+ }
12240
13213
  function resolveDatabaseEngine(value, memory = false) {
12241
13214
  const configured = value ?? process.env.SUPACLOUD_LITE_ENGINE ?? "pglite";
12242
13215
  if (configured !== "pglite" && configured !== "native") {
@@ -12312,7 +13285,7 @@ async function loadWebhooks(projectDir) {
12312
13285
  }
12313
13286
  }
12314
13287
  function resolvePath(projectDir, path) {
12315
- return isAbsolute(path) ? path : resolve2(projectDir, path);
13288
+ return isAbsolute(path) ? path : resolve3(projectDir, path);
12316
13289
  }
12317
13290
  function randomHex(bytes) {
12318
13291
  const value = crypto.getRandomValues(new Uint8Array(bytes));
@@ -12339,6 +13312,9 @@ function parsePort(value, fallback) {
12339
13312
  throw new Error(`invalid port: ${value}`);
12340
13313
  return port;
12341
13314
  }
13315
+ function commaSeparated(value, fallback = []) {
13316
+ return value === undefined ? fallback : value.split(",").map((entry) => entry.trim()).filter(Boolean);
13317
+ }
12342
13318
  function displayHost(host) {
12343
13319
  if (host === "0.0.0.0" || host === "::")
12344
13320
  return "127.0.0.1";
@@ -12355,7 +13331,7 @@ async function findEphemeralPort(host = "127.0.0.1") {
12355
13331
 
12356
13332
  // src/snapshot.ts
12357
13333
  import { chmod as chmod2, copyFile, lstat as lstat2, mkdir as mkdir6, mkdtemp, readdir as readdir3, readFile as readFile7, rename as rename2, rm as rm5, writeFile as writeFile6 } from "fs/promises";
12358
- import { dirname as dirname6, join as join9, parse as parse2, relative as relative2, resolve as resolve3, sep as sep2 } from "path";
13334
+ import { dirname as dirname6, join as join9, parse as parse2, relative as relative2, resolve as resolve4, sep as sep2 } from "path";
12359
13335
  import { create as createTar, extract as extractTar2 } from "tar";
12360
13336
  var SNAPSHOT_FORMAT = "supacloud-lite-snapshot";
12361
13337
  var SNAPSHOT_VERSION = 1;
@@ -12379,7 +13355,7 @@ async function createSnapshot(options) {
12379
13355
  postgresMajor: await readPostgresMajor(paths.dataDir)
12380
13356
  } : {}
12381
13357
  };
12382
- const output = resolve3(options.output);
13358
+ const output = resolve4(options.output);
12383
13359
  if (await existingInfo(output))
12384
13360
  throw new Error(`snapshot output already exists: ${output}`);
12385
13361
  await mkdir6(dirname6(output), { recursive: true });
@@ -12420,7 +13396,7 @@ async function restoreSnapshot(options) {
12420
13396
  await mkdir6(payloadRoot, { recursive: true });
12421
13397
  await extractTar2({
12422
13398
  cwd: payloadRoot,
12423
- file: resolve3(options.input),
13399
+ file: resolve4(options.input),
12424
13400
  preserveOwner: false,
12425
13401
  preservePaths: false,
12426
13402
  strict: true,
@@ -12497,11 +13473,11 @@ async function restoreSnapshot(options) {
12497
13473
  function normalizePaths(paths) {
12498
13474
  return {
12499
13475
  ...paths,
12500
- projectDir: resolve3(paths.projectDir),
12501
- stateDir: resolve3(paths.stateDir),
12502
- dataDir: paths.dataDir ? resolve3(paths.dataDir) : undefined,
12503
- storageDir: resolve3(paths.storageDir),
12504
- secretsFile: resolve3(paths.secretsFile)
13476
+ projectDir: resolve4(paths.projectDir),
13477
+ stateDir: resolve4(paths.stateDir),
13478
+ dataDir: paths.dataDir ? resolve4(paths.dataDir) : undefined,
13479
+ storageDir: resolve4(paths.storageDir),
13480
+ secretsFile: resolve4(paths.secretsFile)
12505
13481
  };
12506
13482
  }
12507
13483
  async function assertSnapshotPaths(paths, options = {}) {
@@ -12531,7 +13507,7 @@ async function assertSnapshotPaths(paths, options = {}) {
12531
13507
  async function assertDirectoryOrMissing(path) {
12532
13508
  if (!path)
12533
13509
  return;
12534
- if (resolve3(path) === parse2(resolve3(path)).root)
13510
+ if (resolve4(path) === parse2(resolve4(path)).root)
12535
13511
  throw new Error(`snapshot path must not be the filesystem root: ${path}`);
12536
13512
  try {
12537
13513
  const info = await lstat2(path);
@@ -12753,13 +13729,13 @@ async function assertNoSymlinks(root) {
12753
13729
  }
12754
13730
  }
12755
13731
  function isWithin(parent, child) {
12756
- const normalizedParent = resolve3(parent);
12757
- const normalizedChild = resolve3(child);
13732
+ const normalizedParent = resolve4(parent);
13733
+ const normalizedChild = resolve4(child);
12758
13734
  return normalizedChild !== normalizedParent && normalizedChild.startsWith(`${normalizedParent}${sep2}`);
12759
13735
  }
12760
13736
  function pathsOverlap(left, right) {
12761
- const normalizedLeft = resolve3(left);
12762
- const normalizedRight = resolve3(right);
13737
+ const normalizedLeft = resolve4(left);
13738
+ const normalizedRight = resolve4(right);
12763
13739
  return normalizedLeft === normalizedRight || isWithin(normalizedLeft, normalizedRight) || isWithin(normalizedRight, normalizedLeft);
12764
13740
  }
12765
13741
 
@@ -12788,7 +13764,8 @@ function parseArgs(argv) {
12788
13764
  host: process.env.SUPACLOUD_LITE_HOST ?? "127.0.0.1",
12789
13765
  port: process.env.SUPACLOUD_LITE_PORT || process.env.PORT ? Number.parseInt(process.env.SUPACLOUD_LITE_PORT ?? process.env.PORT, 10) : 54321,
12790
13766
  serviceRole: false,
12791
- force: false
13767
+ force: false,
13768
+ json: false
12792
13769
  };
12793
13770
  for (let index = 0;index < args.length; index++) {
12794
13771
  const argument = args[index];
@@ -12807,29 +13784,45 @@ function parseArgs(argv) {
12807
13784
  else if (argument === "--site-url")
12808
13785
  options.siteUrl = next();
12809
13786
  else if (argument === "--project-dir" || argument === "--dir")
12810
- options.projectDir = resolve4(next());
13787
+ options.projectDir = resolve5(next());
12811
13788
  else if (argument === "--state-dir")
12812
- options.stateDir = resolve4(next());
13789
+ options.stateDir = resolve5(next());
12813
13790
  else if (argument === "--data-dir")
12814
- options.dataDir = resolve4(next());
13791
+ options.dataDir = resolve5(next());
12815
13792
  else if (argument === "--storage-dir")
12816
- options.storageDir = resolve4(next());
13793
+ options.storageDir = resolve5(next());
12817
13794
  else if (argument === "--storage-backend")
12818
13795
  options.storageBackend = next();
12819
13796
  else if (argument === "--s3-prefix")
12820
13797
  options.s3 = { ...options.s3, prefix: next() };
12821
13798
  else if (argument === "--engine")
12822
13799
  options.engine = next();
13800
+ else if (argument === "--replication-profile") {
13801
+ options.replicationProfile = next();
13802
+ } else if (argument === "--replication-host")
13803
+ options.replicationHost = next();
13804
+ else if (argument === "--replication-port")
13805
+ options.replicationPort = Number.parseInt(next(), 10);
13806
+ else if (argument === "--replication-allow-cidrs")
13807
+ options.replicationAllowCidrs = commaSeparated2(next());
13808
+ else if (argument === "--powersync-tables")
13809
+ options.powersyncPublicationTables = commaSeparated2(next());
13810
+ else if (argument === "--replication-tls-cert")
13811
+ options.replicationTlsCertFile = resolve5(next());
13812
+ else if (argument === "--replication-tls-key")
13813
+ options.replicationTlsKeyFile = resolve5(next());
12823
13814
  else if (argument === "--memory")
12824
13815
  options.memory = true;
12825
13816
  else if (argument === "--output" || argument === "-o")
12826
- options.output = resolve4(next());
13817
+ options.output = resolve5(next());
12827
13818
  else if (argument === "--file" || argument === "-f")
12828
13819
  options.diffFile = next();
12829
13820
  else if (argument === "--service-role")
12830
13821
  options.serviceRole = true;
12831
13822
  else if (argument === "--force")
12832
13823
  options.force = true;
13824
+ else if (argument === "--json")
13825
+ options.json = true;
12833
13826
  else if (argument === "--version") {
12834
13827
  console.log(package_default.version);
12835
13828
  process.exit(0);
@@ -12918,6 +13911,27 @@ ${privilegedKey}
12918
13911
  }
12919
13912
  return;
12920
13913
  }
13914
+ if (options.command === "doctor") {
13915
+ const replication = resolveNativeReplicationOptions(options, paths.databaseEngine);
13916
+ const report = liteCapabilities(paths.databaseEngine, replication?.profile);
13917
+ if (paths.databaseEngine === "native" && replication) {
13918
+ if (!paths.dataDir || !existsSync4(join10(paths.dataDir, "PG_VERSION"))) {
13919
+ throw new Error("PowerSync readiness requires an initialized native database; run migrate first");
13920
+ }
13921
+ const engine = await createNativeEngine({ dataDir: paths.dataDir, log: quietLog, replication });
13922
+ try {
13923
+ report.powersync_readiness = await inspectPowerSyncReadiness(engine, replication);
13924
+ } finally {
13925
+ await engine.close();
13926
+ }
13927
+ }
13928
+ if (options.json)
13929
+ await writeStandardOutput(`${JSON.stringify(report, null, 2)}
13930
+ `);
13931
+ else
13932
+ await printDoctor(report);
13933
+ return;
13934
+ }
12921
13935
  if (options.command === "migrate" || options.command === "status") {
12922
13936
  const project2 = await createProjectBackend({
12923
13937
  ...options,
@@ -12989,9 +14003,13 @@ async function runDbCommand(options) {
12989
14003
  }
12990
14004
  return;
12991
14005
  }
12992
- const project = await loadSupabaseProject(resolve4(options.projectDir ?? process.cwd()));
14006
+ const project = await loadSupabaseProject(resolve5(options.projectDir ?? process.cwd()));
12993
14007
  if (subcommand === "diff") {
12994
- const liveEngine = paths.databaseEngine === "native" ? await createNativeEngine({ dataDir: paths.dataDir, log: quietLog }) : undefined;
14008
+ const liveEngine = paths.databaseEngine === "native" ? await createNativeEngine({
14009
+ dataDir: paths.dataDir,
14010
+ log: quietLog,
14011
+ replication: resolveNativeReplicationOptions(options, paths.databaseEngine)
14012
+ }) : undefined;
12995
14013
  const ddl = await computeDbDiff({
12996
14014
  liveDataDir: paths.databaseEngine === "pglite" ? paths.dataDir : undefined,
12997
14015
  liveEngine,
@@ -13019,7 +14037,11 @@ async function runDbCommand(options) {
13019
14037
  return;
13020
14038
  }
13021
14039
  if (subcommand === "pull") {
13022
- const liveEngine = paths.databaseEngine === "native" ? await createNativeEngine({ dataDir: paths.dataDir, log: quietLog }) : undefined;
14040
+ const liveEngine = paths.databaseEngine === "native" ? await createNativeEngine({
14041
+ dataDir: paths.dataDir,
14042
+ log: quietLog,
14043
+ replication: resolveNativeReplicationOptions(options, paths.databaseEngine)
14044
+ }) : undefined;
13023
14045
  const result = await pullSchema({
13024
14046
  liveDataDir: paths.databaseEngine === "pglite" ? paths.dataDir : undefined,
13025
14047
  liveEngine,
@@ -13064,7 +14086,7 @@ async function runSnapshotCommand(options) {
13064
14086
  const rollbackLines = result.rollbackPaths.map((rollbackPath) => `Previous state retained at ${rollbackPath}`);
13065
14087
  const reconnectLine = result.manifest.storageBackend === "s3" ? ["Reconnect the original S3 bucket/prefix before starting Lite."] : [];
13066
14088
  await writeStandardOutput([
13067
- `Snapshot restored from ${resolve4(input)}`,
14089
+ `Snapshot restored from ${resolve5(input)}`,
13068
14090
  ...rollbackLines,
13069
14091
  ...reconnectLine
13070
14092
  ].join(`
@@ -13120,6 +14142,19 @@ async function printInspection(rows) {
13120
14142
  `)}
13121
14143
  `);
13122
14144
  }
14145
+ async function printDoctor(report) {
14146
+ const output = Object.entries(report).filter(([, value]) => typeof value !== "object").map(([name, value]) => `${name}: ${value}`);
14147
+ if (report.powersync_readiness) {
14148
+ output.push(`powersync_ready: ${report.powersync_readiness.ready}`);
14149
+ for (const blocker of report.powersync_readiness.blockers)
14150
+ output.push(`blocker: ${blocker}`);
14151
+ for (const warning of report.powersync_readiness.warnings)
14152
+ output.push(`warning: ${warning}`);
14153
+ }
14154
+ await writeStandardOutput(`${output.join(`
14155
+ `)}
14156
+ `);
14157
+ }
13123
14158
  async function writeStandardOutput(output) {
13124
14159
  await Bun.write(Bun.stdout, output);
13125
14160
  }
@@ -13130,8 +14165,11 @@ function timestamp() {
13130
14165
  return new Date().toISOString().replace(/[-:T]/g, "").slice(0, 14);
13131
14166
  }
13132
14167
  function quietLog() {}
14168
+ function commaSeparated2(value) {
14169
+ return value.split(",").map((entry) => entry.trim()).filter(Boolean);
14170
+ }
13133
14171
  function printHelp() {
13134
- console.log(`supacloud-lite - Bun-native Supabase-compatible backend on PGlite
14172
+ console.log(`supacloud-lite - Bun-native Supabase-compatible backend on PGlite or native PostgreSQL
13135
14173
 
13136
14174
  Usage: supacloud-lite [command] [options]
13137
14175
 
@@ -13149,6 +14187,7 @@ Commands:
13149
14187
  snapshot restore <f> restore a snapshot into an empty target
13150
14188
  upgrade snapshot first, then apply pending migrations
13151
14189
  inspect show table rows and sizes
14190
+ doctor report Lite capability and replication readiness
13152
14191
  version print the package version
13153
14192
 
13154
14193
  Fresh projects must run "supacloud-lite migrate" before "db reset".
@@ -13165,7 +14204,15 @@ Options:
13165
14204
  --storage-backend <b> fs, memory, or s3 (default fs)
13166
14205
  --s3-prefix <p> optional key prefix for the s3 backend
13167
14206
  --engine <e> pglite (default) or native (macOS/glibc Linux x64/arm64)
14207
+ --replication-profile <p> optional powersync profile (native only)
14208
+ --replication-host <ip> database listener (default 127.0.0.1)
14209
+ --replication-port <n> database listener port (default 54322)
14210
+ --replication-allow-cidrs <list> explicit client CIDRs
14211
+ --powersync-tables <list> schema-qualified publication table allowlist
14212
+ --replication-tls-cert <p> PostgreSQL TLS certificate
14213
+ --replication-tls-key <p> PostgreSQL TLS private key
13168
14214
  --memory use an in-memory PGlite database
14215
+ --json emit machine-readable doctor output
13169
14216
  -o, --output <p> output file for gen types
13170
14217
  -f, --file <name> migration suffix for db diff
13171
14218
  --force replace non-empty restore targets and retain rollback copies