@tangen/db 0.0.0-stage → 0.1.54
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/README.md +73 -2
- package/drizzle/0000_flimsy_shotgun.sql +339 -0
- package/drizzle/0001_app_data.sql +11 -0
- package/drizzle/0002_add_indexes.sql +36 -0
- package/drizzle/0003_extension_data_rename.sql +3 -0
- package/drizzle/0004_extension_data_published_globals.sql +12 -0
- package/drizzle/0005_auth.sql +51 -0
- package/drizzle/0006_auth_identities.sql +13 -0
- package/drizzle/0007_ai.sql +18 -0
- package/drizzle/0008_ai_chat_threads.sql +5 -0
- package/drizzle/0009_forms_source_slug.sql +11 -0
- package/drizzle/0010_rbac_roles_status.sql +14 -0
- package/drizzle/0011_jobs_v2.sql +67 -0
- package/drizzle/0012_search_platform.sql +36 -0
- package/drizzle/0013_search_synonym_label.sql +2 -0
- package/drizzle/0014_object_types_disabled.sql +2 -0
- package/drizzle/0015_playground_collections.sql +25 -0
- package/drizzle/0016_catchup_activity_promotion.sql +36 -0
- package/drizzle/0017_fields_disabled.sql +3 -0
- package/drizzle/0018_object_types_plural.sql +1 -0
- package/drizzle/0019_member_profile.sql +2 -0
- package/drizzle/0020_paused_jobs.sql +7 -0
- package/drizzle/0021_form_submission_notes.sql +2 -0
- package/drizzle/0022_paused_jobs_env_reason.sql +4 -0
- package/drizzle/0023_scale_indexes.sql +3 -0
- package/drizzle/0024_migration_runs.sql +24 -0
- package/drizzle/0025_porter_transfer.sql +30 -0
- package/drizzle/0026_cron_timezone.sql +1 -0
- package/drizzle/0027_rate_limit_counters.sql +6 -0
- package/drizzle/0028_realtime_events.sql +10 -0
- package/drizzle/0030_delivery_artifacts.sql +10 -0
- package/drizzle/0031_realtime_presence.sql +8 -0
- package/drizzle/0032_schema_pushes.sql +15 -0
- package/drizzle/0033_studio_design_capability.sql +37 -0
- package/drizzle/0034_object_types_provided_by.sql +12 -0
- package/drizzle/0035_custom_table_owners.sql +24 -0
- package/drizzle/0036_revision_version_unique.sql +42 -0
- package/drizzle/0037_job_queue_dedupe.sql +20 -0
- package/drizzle/0038_search_documents.sql +87 -0
- package/drizzle/0039_job_prune_indexes.sql +25 -0
- package/drizzle/0040_extension_data_object_idx.sql +10 -0
- package/drizzle/0041_knowledge.sql +79 -0
- package/drizzle/0042_schema_pushes_checksum.sql +7 -0
- package/drizzle/0043_guard_adoption.sql +12 -0
- package/drizzle/0044_retention_indexes.sql +7 -0
- package/drizzle/0045_extension_install_per_env.sql +40 -0
- package/drizzle/0046_extension_migrations.sql +18 -0
- package/drizzle/0047_environment_role.sql +25 -0
- package/drizzle/0048_environment_role_adoption.sql +30 -0
- package/drizzle/0049_release_lifecycle.sql +55 -0
- package/drizzle/0050_member_env_membership.sql +21 -0
- package/drizzle/0051_remove_environments.sql +350 -0
- package/drizzle/0052_release_actor_columns.sql +9 -0
- package/drizzle/0053_kernel_event_record.sql +26 -0
- package/drizzle/0054_kernel_run_identity.sql +13 -0
- package/drizzle/0055_kernel_identities.sql +19 -0
- package/drizzle/0056_forms_leave_core.sql +7 -0
- package/drizzle/0057_auth_leave_core.sql +8 -0
- package/drizzle/0058_member_grants.sql +11 -0
- package/drizzle/0059_extension_granted_grants.sql +7 -0
- package/drizzle/0060_connections.sql +21 -0
- package/drizzle/meta/0000_snapshot.json +1692 -0
- package/drizzle/meta/0002_snapshot.json +1935 -0
- package/drizzle/meta/0003_snapshot.json +1935 -0
- package/drizzle/meta/_journal.json +426 -0
- package/package.json +85 -4
- package/src/cache-keys.ts +43 -0
- package/src/client.ts +136 -0
- package/src/content-ddl.ts +70 -0
- package/src/content-tables.ts +110 -0
- package/src/custom-tables-loader.ts +65 -0
- package/src/ensure-system-schema.ts +195 -0
- package/src/extension-install-state.ts +220 -0
- package/src/extension-migrations.ts +270 -0
- package/src/generated/content-tables.ts +271 -0
- package/src/generated/custom-tables.ts +18 -0
- package/src/index.ts +24 -0
- package/src/instrument.ts +139 -0
- package/src/knowledge-schema.ts +232 -0
- package/src/migrate.ts +67 -0
- package/src/prepare-policy.ts +118 -0
- package/src/reconcile.ts +115 -0
- package/src/schema/ai-chats.ts +36 -0
- package/src/schema/ai-settings.ts +24 -0
- package/src/schema/api-keys.ts +37 -0
- package/src/schema/audit-log.ts +54 -0
- package/src/schema/connections.ts +35 -0
- package/src/schema/custom-table-owners.ts +32 -0
- package/src/schema/delivery-artifacts.ts +33 -0
- package/src/schema/entity-tags.ts +33 -0
- package/src/schema/extension-data.ts +38 -0
- package/src/schema/extension-migrations.ts +42 -0
- package/src/schema/fields.ts +37 -0
- package/src/schema/globals.ts +35 -0
- package/src/schema/identities.ts +27 -0
- package/src/schema/index.ts +51 -0
- package/src/schema/jobs.ts +104 -0
- package/src/schema/knowledge.ts +85 -0
- package/src/schema/media.ts +52 -0
- package/src/schema/member-grants.ts +34 -0
- package/src/schema/members.ts +49 -0
- package/src/schema/migration-runs.ts +46 -0
- package/src/schema/object-types.ts +41 -0
- package/src/schema/objects.ts +38 -0
- package/src/schema/paused-jobs.ts +25 -0
- package/src/schema/playground.ts +43 -0
- package/src/schema/porter-transfer.ts +47 -0
- package/src/schema/realtime.ts +45 -0
- package/src/schema/releases.ts +95 -0
- package/src/schema/roles.ts +27 -0
- package/src/schema/saved-views.ts +33 -0
- package/src/schema/schema-pushes.ts +43 -0
- package/src/schema/search-documents.ts +47 -0
- package/src/schema/search-query-log.ts +24 -0
- package/src/schema/search-recommendations.ts +27 -0
- package/src/schema/search-synonyms.ts +23 -0
- package/src/schema/webhook-deliveries.ts +37 -0
- package/src/schema/webhooks.ts +36 -0
- package/src/schema/workspace-activity.ts +35 -0
- package/src/schema/workspace-domains.ts +24 -0
- package/src/schema/workspace-installed-apps.ts +48 -0
- package/src/schema/workspaces.ts +24 -0
- package/src/schema-kit.ts +64 -0
- package/src/sealed.ts +84 -0
- package/src/seed/skeleton.ts +77 -0
- package/src/sql/apply-gin-indexes.ts +31 -0
- package/src/sql/gin-indexes.sql +11 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
-- Jobs v2: durable Postgres queue, run history, scheduler state,
|
|
2
|
+
-- admin-managed cron entries, and the Postgres cache fallback.
|
|
3
|
+
|
|
4
|
+
CREATE TABLE IF NOT EXISTS job_queue (
|
|
5
|
+
id bigserial PRIMARY KEY,
|
|
6
|
+
task_id text NOT NULL,
|
|
7
|
+
workspace_id text NOT NULL,
|
|
8
|
+
env_id text NOT NULL,
|
|
9
|
+
payload jsonb,
|
|
10
|
+
status text NOT NULL DEFAULT 'queued',
|
|
11
|
+
attempts integer NOT NULL DEFAULT 0,
|
|
12
|
+
max_attempts integer NOT NULL DEFAULT 3,
|
|
13
|
+
run_at timestamptz NOT NULL DEFAULT now(),
|
|
14
|
+
locked_at timestamptz,
|
|
15
|
+
locked_by text,
|
|
16
|
+
last_error text,
|
|
17
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
18
|
+
finished_at timestamptz
|
|
19
|
+
);
|
|
20
|
+
CREATE INDEX IF NOT EXISTS job_queue_due_idx ON job_queue (status, run_at);
|
|
21
|
+
|
|
22
|
+
CREATE TABLE IF NOT EXISTS job_runs (
|
|
23
|
+
id bigserial PRIMARY KEY,
|
|
24
|
+
kind text NOT NULL, -- task | scheduled | cron
|
|
25
|
+
name text NOT NULL, -- task id / job id / cron entry name
|
|
26
|
+
workspace_id text NOT NULL,
|
|
27
|
+
env_id text NOT NULL,
|
|
28
|
+
status text NOT NULL, -- running | ok | error
|
|
29
|
+
started_at timestamptz NOT NULL DEFAULT now(),
|
|
30
|
+
finished_at timestamptz,
|
|
31
|
+
duration_ms integer,
|
|
32
|
+
error text,
|
|
33
|
+
meta jsonb
|
|
34
|
+
);
|
|
35
|
+
CREATE INDEX IF NOT EXISTS job_runs_scope_idx ON job_runs (workspace_id, env_id, started_at DESC);
|
|
36
|
+
|
|
37
|
+
CREATE TABLE IF NOT EXISTS scheduler_state (
|
|
38
|
+
workspace_id text NOT NULL,
|
|
39
|
+
env_id text NOT NULL,
|
|
40
|
+
job_id text NOT NULL,
|
|
41
|
+
last_run timestamptz NOT NULL,
|
|
42
|
+
PRIMARY KEY (workspace_id, env_id, job_id)
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
CREATE TABLE IF NOT EXISTS cron_jobs (
|
|
46
|
+
id text PRIMARY KEY,
|
|
47
|
+
workspace_id text NOT NULL,
|
|
48
|
+
env_id text NOT NULL,
|
|
49
|
+
name text NOT NULL,
|
|
50
|
+
schedule text NOT NULL,
|
|
51
|
+
action jsonb NOT NULL, -- {type:'url',...} | {type:'task',...}
|
|
52
|
+
enabled boolean NOT NULL DEFAULT true,
|
|
53
|
+
created_by text,
|
|
54
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
55
|
+
updated_at timestamptz NOT NULL DEFAULT now()
|
|
56
|
+
);
|
|
57
|
+
CREATE INDEX IF NOT EXISTS cron_jobs_scope_idx ON cron_jobs (workspace_id, env_id, enabled);
|
|
58
|
+
|
|
59
|
+
CREATE TABLE IF NOT EXISTS cache_entries (
|
|
60
|
+
key text PRIMARY KEY,
|
|
61
|
+
value text NOT NULL,
|
|
62
|
+
tags text[] NOT NULL DEFAULT '{}',
|
|
63
|
+
stored_at timestamptz NOT NULL DEFAULT now(),
|
|
64
|
+
expires_at timestamptz NOT NULL
|
|
65
|
+
);
|
|
66
|
+
CREATE INDEX IF NOT EXISTS cache_entries_tags_idx ON cache_entries USING gin (tags);
|
|
67
|
+
CREATE INDEX IF NOT EXISTS cache_entries_expiry_idx ON cache_entries (expires_at);
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
-- Search platform: query-log analytics (retention-bounded, no PII), marketing-
|
|
2
|
+
-- managed synonyms, and query curation/recommendations. All additive.
|
|
3
|
+
|
|
4
|
+
CREATE TABLE IF NOT EXISTS search_query_log (
|
|
5
|
+
id text PRIMARY KEY,
|
|
6
|
+
workspace_id text NOT NULL,
|
|
7
|
+
env_id text NOT NULL,
|
|
8
|
+
audience text NOT NULL, -- internal (Admin) | public (Site)
|
|
9
|
+
term text NOT NULL, -- the query; no PII beyond what was typed
|
|
10
|
+
result_count integer NOT NULL DEFAULT 0,
|
|
11
|
+
created_at timestamptz NOT NULL DEFAULT now()
|
|
12
|
+
);
|
|
13
|
+
CREATE INDEX IF NOT EXISTS search_query_log_ws_env_idx ON search_query_log (workspace_id, env_id, created_at);
|
|
14
|
+
|
|
15
|
+
CREATE TABLE IF NOT EXISTS search_synonyms (
|
|
16
|
+
id text PRIMARY KEY,
|
|
17
|
+
workspace_id text NOT NULL,
|
|
18
|
+
env_id text NOT NULL,
|
|
19
|
+
terms jsonb NOT NULL DEFAULT '[]', -- interchangeable terms (string[])
|
|
20
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
21
|
+
updated_at timestamptz NOT NULL DEFAULT now()
|
|
22
|
+
);
|
|
23
|
+
CREATE INDEX IF NOT EXISTS search_synonyms_ws_env_idx ON search_synonyms (workspace_id, env_id);
|
|
24
|
+
|
|
25
|
+
CREATE TABLE IF NOT EXISTS search_recommendations (
|
|
26
|
+
id text PRIMARY KEY,
|
|
27
|
+
workspace_id text NOT NULL,
|
|
28
|
+
env_id text NOT NULL,
|
|
29
|
+
query text NOT NULL, -- query this rule applies to (case-insensitive)
|
|
30
|
+
pins jsonb NOT NULL DEFAULT '[]', -- result ids/urls to pin, in order
|
|
31
|
+
hides jsonb NOT NULL DEFAULT '[]', -- result ids/urls to remove
|
|
32
|
+
audience text, -- internal | public | null (both)
|
|
33
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
34
|
+
updated_at timestamptz NOT NULL DEFAULT now()
|
|
35
|
+
);
|
|
36
|
+
CREATE INDEX IF NOT EXISTS search_recommendations_ws_env_idx ON search_recommendations (workspace_id, env_id);
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
-- PG-3: API Playground collections (saved requests, private/shared) and
|
|
2
|
+
-- per-member variable sets for {{name}} interpolation.
|
|
3
|
+
CREATE TABLE IF NOT EXISTS "playground_collections" (
|
|
4
|
+
"id" text PRIMARY KEY,
|
|
5
|
+
"workspace_id" text NOT NULL,
|
|
6
|
+
"env_id" text NOT NULL,
|
|
7
|
+
"name" text NOT NULL,
|
|
8
|
+
"requests" jsonb NOT NULL DEFAULT '[]',
|
|
9
|
+
"visibility" text NOT NULL DEFAULT 'private',
|
|
10
|
+
"created_by" text NOT NULL,
|
|
11
|
+
"created_at" timestamptz NOT NULL DEFAULT now(),
|
|
12
|
+
"updated_at" timestamptz NOT NULL DEFAULT now()
|
|
13
|
+
);
|
|
14
|
+
CREATE INDEX IF NOT EXISTS "playground_collections_scope_idx"
|
|
15
|
+
ON "playground_collections" ("workspace_id", "env_id");
|
|
16
|
+
|
|
17
|
+
CREATE TABLE IF NOT EXISTS "playground_variables" (
|
|
18
|
+
"id" text PRIMARY KEY,
|
|
19
|
+
"workspace_id" text NOT NULL,
|
|
20
|
+
"env_id" text NOT NULL,
|
|
21
|
+
"member_id" text NOT NULL,
|
|
22
|
+
"vars" jsonb NOT NULL DEFAULT '{}',
|
|
23
|
+
"updated_at" timestamptz NOT NULL DEFAULT now(),
|
|
24
|
+
CONSTRAINT "playground_variables_owner" UNIQUE ("workspace_id", "env_id", "member_id")
|
|
25
|
+
);
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
-- Catch-up migration: the monorepo evolves system tables via `drizzle-kit push`,
|
|
2
|
+
-- so these two never got a generated migration — consumer installs (which
|
|
3
|
+
-- migrate via @tangen/db/migrate) were missing them. Hand-rolled from the live
|
|
4
|
+
-- schema (drizzle-kit generate is currently incompatible with orm 0.45).
|
|
5
|
+
CREATE TABLE IF NOT EXISTS "workspace_activity" (
|
|
6
|
+
"id" text PRIMARY KEY NOT NULL,
|
|
7
|
+
"workspace_id" text NOT NULL,
|
|
8
|
+
"env_id" text,
|
|
9
|
+
"category" text NOT NULL,
|
|
10
|
+
"action" text NOT NULL,
|
|
11
|
+
"actor_id" text,
|
|
12
|
+
"actor_name" text,
|
|
13
|
+
"actor_type" text,
|
|
14
|
+
"source" text DEFAULT 'admin' NOT NULL,
|
|
15
|
+
"type_name" text,
|
|
16
|
+
"object_id" text,
|
|
17
|
+
"object_title" text,
|
|
18
|
+
"target_label" text,
|
|
19
|
+
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
|
20
|
+
);
|
|
21
|
+
--> statement-breakpoint
|
|
22
|
+
CREATE TABLE IF NOT EXISTS "promotion_log" (
|
|
23
|
+
"id" text PRIMARY KEY NOT NULL,
|
|
24
|
+
"object_id" text NOT NULL,
|
|
25
|
+
"from_env_id" text NOT NULL,
|
|
26
|
+
"to_env_id" text NOT NULL,
|
|
27
|
+
"release_id" text,
|
|
28
|
+
"promoted_by" text,
|
|
29
|
+
"promoted_at" timestamp with time zone DEFAULT now() NOT NULL
|
|
30
|
+
);
|
|
31
|
+
--> statement-breakpoint
|
|
32
|
+
CREATE INDEX IF NOT EXISTS "workspace_activity_workspace_created" ON "workspace_activity" ("workspace_id","created_at");
|
|
33
|
+
--> statement-breakpoint
|
|
34
|
+
CREATE INDEX IF NOT EXISTS "workspace_activity_env_created" ON "workspace_activity" ("env_id","created_at");
|
|
35
|
+
--> statement-breakpoint
|
|
36
|
+
CREATE INDEX IF NOT EXISTS "promotion_log_object_env_idx" ON "promotion_log" ("object_id","to_env_id");
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ALTER TABLE "object_types" ADD COLUMN IF NOT EXISTS "plural" text;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
CREATE TABLE IF NOT EXISTS "paused_jobs" (
|
|
2
|
+
"workspace_id" text NOT NULL,
|
|
3
|
+
"job_id" text NOT NULL,
|
|
4
|
+
"paused_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
5
|
+
"paused_by" text,
|
|
6
|
+
CONSTRAINT "paused_jobs_workspace_id_job_id_pk" PRIMARY KEY("workspace_id","job_id")
|
|
7
|
+
);
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
ALTER TABLE "paused_jobs" ADD COLUMN IF NOT EXISTS "env_id" text NOT NULL DEFAULT '*';
|
|
2
|
+
ALTER TABLE "paused_jobs" ADD COLUMN IF NOT EXISTS "reason" text;
|
|
3
|
+
ALTER TABLE "paused_jobs" DROP CONSTRAINT IF EXISTS "paused_jobs_workspace_id_job_id_pk";
|
|
4
|
+
ALTER TABLE "paused_jobs" ADD CONSTRAINT "paused_jobs_workspace_id_job_id_env_id_pk" PRIMARY KEY("workspace_id","job_id","env_id");
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
CREATE INDEX IF NOT EXISTS "auth_users_workspace_env_created_idx" ON "auth_users" ("workspace_id","env_id","created_at" DESC);
|
|
2
|
+
CREATE INDEX IF NOT EXISTS "form_submissions_form_created_idx" ON "form_submissions" ("form_id","created_at" DESC);
|
|
3
|
+
CREATE INDEX IF NOT EXISTS "extension_data_scope_prefix_idx" ON "extension_data" ("extension_id","workspace_id","env_id","object_id" text_pattern_ops);
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
CREATE TABLE IF NOT EXISTS "migration_runs" (
|
|
2
|
+
"id" text PRIMARY KEY NOT NULL,
|
|
3
|
+
"workspace_id" text NOT NULL,
|
|
4
|
+
"kind" text NOT NULL,
|
|
5
|
+
"source_env_id" text,
|
|
6
|
+
"target_env_id" text,
|
|
7
|
+
"status" text NOT NULL DEFAULT 'pending',
|
|
8
|
+
"scope" jsonb NOT NULL DEFAULT '{}',
|
|
9
|
+
"counts" jsonb NOT NULL DEFAULT '{}',
|
|
10
|
+
"total_items" integer NOT NULL DEFAULT 0,
|
|
11
|
+
"processed_items" integer NOT NULL DEFAULT 0,
|
|
12
|
+
"results_parts" integer NOT NULL DEFAULT 0,
|
|
13
|
+
"error" text,
|
|
14
|
+
"cancel_requested" boolean NOT NULL DEFAULT false,
|
|
15
|
+
"created_by" text,
|
|
16
|
+
"created_at" timestamp with time zone NOT NULL DEFAULT now(),
|
|
17
|
+
"started_at" timestamp with time zone,
|
|
18
|
+
"finished_at" timestamp with time zone,
|
|
19
|
+
"heartbeat_at" timestamp with time zone
|
|
20
|
+
);
|
|
21
|
+
--> statement-breakpoint
|
|
22
|
+
CREATE INDEX IF NOT EXISTS "migration_runs_workspace_idx" ON "migration_runs" ("workspace_id","created_at");
|
|
23
|
+
--> statement-breakpoint
|
|
24
|
+
CREATE INDEX IF NOT EXISTS "migration_runs_status_idx" ON "migration_runs" ("status");
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
CREATE TABLE IF NOT EXISTS "porter_transfer_codes" (
|
|
2
|
+
"id" text PRIMARY KEY NOT NULL,
|
|
3
|
+
"workspace_id" text NOT NULL,
|
|
4
|
+
"env_id" text NOT NULL,
|
|
5
|
+
"code_hash" text NOT NULL,
|
|
6
|
+
"expires_at" timestamp with time zone NOT NULL,
|
|
7
|
+
"redeemed_at" timestamp with time zone,
|
|
8
|
+
"revoked_at" timestamp with time zone,
|
|
9
|
+
"created_at" timestamp with time zone NOT NULL DEFAULT now(),
|
|
10
|
+
"created_by" text
|
|
11
|
+
);
|
|
12
|
+
--> statement-breakpoint
|
|
13
|
+
CREATE INDEX IF NOT EXISTS "porter_codes_hash_idx" ON "porter_transfer_codes" ("code_hash");
|
|
14
|
+
--> statement-breakpoint
|
|
15
|
+
CREATE INDEX IF NOT EXISTS "porter_codes_workspace_idx" ON "porter_transfer_codes" ("workspace_id","env_id");
|
|
16
|
+
--> statement-breakpoint
|
|
17
|
+
CREATE TABLE IF NOT EXISTS "porter_transfer_tokens" (
|
|
18
|
+
"id" text PRIMARY KEY NOT NULL,
|
|
19
|
+
"workspace_id" text NOT NULL,
|
|
20
|
+
"env_id" text NOT NULL,
|
|
21
|
+
"token_hash" text NOT NULL,
|
|
22
|
+
"code_id" text NOT NULL,
|
|
23
|
+
"run_id" text,
|
|
24
|
+
"expires_at" timestamp with time zone NOT NULL,
|
|
25
|
+
"revoked_at" timestamp with time zone,
|
|
26
|
+
"created_at" timestamp with time zone NOT NULL DEFAULT now(),
|
|
27
|
+
"sender_info" jsonb NOT NULL DEFAULT '{}'
|
|
28
|
+
);
|
|
29
|
+
--> statement-breakpoint
|
|
30
|
+
CREATE INDEX IF NOT EXISTS "porter_tokens_hash_idx" ON "porter_transfer_tokens" ("token_hash");
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ALTER TABLE "cron_jobs" ADD COLUMN IF NOT EXISTS "timezone" text;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
CREATE TABLE IF NOT EXISTS "rate_limit_counters" (
|
|
2
|
+
"key" text PRIMARY KEY NOT NULL,
|
|
3
|
+
"count" integer NOT NULL DEFAULT 0,
|
|
4
|
+
"reset_at" timestamp with time zone NOT NULL
|
|
5
|
+
);
|
|
6
|
+
CREATE INDEX IF NOT EXISTS "rate_limit_counters_reset_idx" ON "rate_limit_counters" ("reset_at");
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
CREATE TABLE IF NOT EXISTS "realtime_events" (
|
|
2
|
+
"seq" bigserial PRIMARY KEY,
|
|
3
|
+
"channel" text NOT NULL,
|
|
4
|
+
"id" text NOT NULL,
|
|
5
|
+
"t" bigint NOT NULL,
|
|
6
|
+
"type" text NOT NULL,
|
|
7
|
+
"data" jsonb NOT NULL DEFAULT '{}'::jsonb
|
|
8
|
+
);
|
|
9
|
+
CREATE INDEX IF NOT EXISTS "realtime_events_channel_seq_idx" ON "realtime_events" ("channel", "seq");
|
|
10
|
+
CREATE UNIQUE INDEX IF NOT EXISTS "realtime_events_channel_id_idx" ON "realtime_events" ("channel", "id");
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
CREATE TABLE IF NOT EXISTS "delivery_artifacts" (
|
|
2
|
+
"workspace_id" text NOT NULL,
|
|
3
|
+
"env_id" text NOT NULL,
|
|
4
|
+
"path" text NOT NULL,
|
|
5
|
+
"body" text NOT NULL,
|
|
6
|
+
"content_type" text NOT NULL DEFAULT 'application/octet-stream',
|
|
7
|
+
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
8
|
+
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
9
|
+
CONSTRAINT "delivery_artifacts_pk" PRIMARY KEY("workspace_id","env_id","path")
|
|
10
|
+
);
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
CREATE TABLE IF NOT EXISTS "realtime_presence" (
|
|
2
|
+
"channel" text NOT NULL,
|
|
3
|
+
"session_id" text NOT NULL,
|
|
4
|
+
"data" jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
5
|
+
"expires_at" bigint NOT NULL,
|
|
6
|
+
PRIMARY KEY ("channel", "session_id")
|
|
7
|
+
);
|
|
8
|
+
CREATE INDEX IF NOT EXISTS "realtime_presence_expires_idx" ON "realtime_presence" ("expires_at");
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
CREATE TABLE IF NOT EXISTS "schema_pushes" (
|
|
2
|
+
"id" text PRIMARY KEY NOT NULL,
|
|
3
|
+
"env_id" text NOT NULL,
|
|
4
|
+
"workspace_id" text NOT NULL,
|
|
5
|
+
"started_at" timestamp with time zone NOT NULL DEFAULT now(),
|
|
6
|
+
"finished_at" timestamp with time zone,
|
|
7
|
+
"status" text NOT NULL DEFAULT 'running',
|
|
8
|
+
"git_sha" text,
|
|
9
|
+
"cli_version" text,
|
|
10
|
+
"plan_json" jsonb NOT NULL DEFAULT '{}',
|
|
11
|
+
"statements_json" jsonb NOT NULL DEFAULT '[]',
|
|
12
|
+
"error" text
|
|
13
|
+
);
|
|
14
|
+
--> statement-breakpoint
|
|
15
|
+
CREATE INDEX IF NOT EXISTS "schema_pushes_env_idx" ON "schema_pushes" ("env_id","started_at");
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
-- Studio decision 3 (2026-08-25): `studio:design` splits class, tag, token,
|
|
2
|
+
-- theme and stylesheet work off `studio:edit`.
|
|
3
|
+
--
|
|
4
|
+
-- Every custom role that could style a page BEFORE this release did so through
|
|
5
|
+
-- one of three grants: `studio:edit` directly, or `studio:manage` /
|
|
6
|
+
-- `studio:creative`, both of which implied `studio:edit`. Shipping the split
|
|
7
|
+
-- without this statement would take styling away from all of them silently, at
|
|
8
|
+
-- the moment of deploy, with no admin action and no notice.
|
|
9
|
+
--
|
|
10
|
+
-- So the grandfather is written down as data, not hidden in the capability
|
|
11
|
+
-- resolver: each of those roles gets `studio:design` (and `studio:edit`, when
|
|
12
|
+
-- it was only ever implied) added to its stored capability list, where an
|
|
13
|
+
-- owner can SEE it in the roles editor and remove it from the marketer roles
|
|
14
|
+
-- that should not have kept it. A silent implication would have been
|
|
15
|
+
-- invisible forever.
|
|
16
|
+
--
|
|
17
|
+
-- Owners and admins are unaffected: they never carry a capability list.
|
|
18
|
+
-- Code-defined roles (defineRole() in a site's config) are not stored here and
|
|
19
|
+
-- must add `studio:design` in code - see docs/rbac.md.
|
|
20
|
+
UPDATE "roles"
|
|
21
|
+
SET "permissions" = jsonb_set(
|
|
22
|
+
"permissions",
|
|
23
|
+
'{capabilities}',
|
|
24
|
+
COALESCE("permissions"->'capabilities', '[]'::jsonb)
|
|
25
|
+
|| (CASE WHEN "permissions"->'capabilities' @> '"studio:design"'::jsonb
|
|
26
|
+
THEN '[]'::jsonb ELSE '["studio:design"]'::jsonb END)
|
|
27
|
+
|| (CASE WHEN "permissions"->'capabilities' @> '"studio:edit"'::jsonb
|
|
28
|
+
THEN '[]'::jsonb ELSE '["studio:edit"]'::jsonb END),
|
|
29
|
+
true
|
|
30
|
+
),
|
|
31
|
+
"updated_at" = now()
|
|
32
|
+
WHERE jsonb_typeof("permissions"->'capabilities') = 'array'
|
|
33
|
+
AND ("permissions"->'capabilities' @> '"studio:edit"'::jsonb
|
|
34
|
+
OR "permissions"->'capabilities' @> '"studio:manage"'::jsonb
|
|
35
|
+
OR "permissions"->'capabilities' @> '"studio:creative"'::jsonb)
|
|
36
|
+
AND NOT ("permissions"->'capabilities' @> '"studio:design"'::jsonb
|
|
37
|
+
AND "permissions"->'capabilities' @> '"studio:edit"'::jsonb);
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
-- Extension-provisioned content types (2026-08-26): a registry row remembers
|
|
2
|
+
-- WHO declared it.
|
|
3
|
+
--
|
|
4
|
+
-- Nullable, and NULL means what it always meant: the site's own code declares
|
|
5
|
+
-- this type, so `tangen push` may offer to drop it (with --confirm) when the
|
|
6
|
+
-- code stops declaring it. A non-null value names the extension that provides
|
|
7
|
+
-- the type, and buys the row one guarantee - a diff never turns it into a
|
|
8
|
+
-- DROP_TYPE. An extension that is removed, disabled, or simply stops declaring
|
|
9
|
+
-- the type leaves an ORPHAN: the table and its rows stay, push says so, doctor
|
|
10
|
+
-- says so. A site takes ownership by declaring the same name in its own
|
|
11
|
+
-- schema, which clears this column, and only then can drop it the ordinary way.
|
|
12
|
+
ALTER TABLE "object_types" ADD COLUMN IF NOT EXISTS "provided_by" text;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
-- Extension-declared custom tables (2026-08-28): a registry row remembers WHO
|
|
2
|
+
-- declared each custom table.
|
|
3
|
+
--
|
|
4
|
+
-- Keyed by table name alone, and that key is the honest description of the
|
|
5
|
+
-- architecture: a physical table is instance-global. Installing an extension in
|
|
6
|
+
-- a second workspace does not create a second table, it finds the one already
|
|
7
|
+
-- there. The rows inside stay workspace- and environment-scoped, as they were.
|
|
8
|
+
--
|
|
9
|
+
-- `owner_kind` is 'site' when the site's own config declares the table, or
|
|
10
|
+
-- 'extension' with the extension id and the manifest version it was claimed at.
|
|
11
|
+
-- A table with NO row here is unclaimed rather than unowned: every custom table
|
|
12
|
+
-- that predates this registry is unclaimed, and an extension declaring one of
|
|
13
|
+
-- those names is refused rather than handed somebody else's data. Ownership is
|
|
14
|
+
-- never transferred by declaring a name.
|
|
15
|
+
CREATE TABLE IF NOT EXISTS "custom_table_owners" (
|
|
16
|
+
"table_name" text PRIMARY KEY,
|
|
17
|
+
"owner_kind" text NOT NULL,
|
|
18
|
+
"owner_id" text,
|
|
19
|
+
"owner_version" text,
|
|
20
|
+
"workspace_id" text,
|
|
21
|
+
"env_id" text,
|
|
22
|
+
"created_at" timestamptz NOT NULL DEFAULT now(),
|
|
23
|
+
"updated_at" timestamptz NOT NULL DEFAULT now()
|
|
24
|
+
);
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
-- Revision version numbers must be unique per object (2026-08-28).
|
|
2
|
+
--
|
|
3
|
+
-- `createRevision` allocated with `SELECT COALESCE(MAX(version),0)` followed by
|
|
4
|
+
-- a separate INSERT, with no transaction, no lock and no constraint. Two
|
|
5
|
+
-- concurrent writers to one object both read the same maximum and both inserted
|
|
6
|
+
-- the same version. Allocation is now a single INSERT ... SELECT that computes
|
|
7
|
+
-- the maximum inline and retries on conflict, and THIS index is what makes the
|
|
8
|
+
-- invariant real: without it the retry has nothing to catch.
|
|
9
|
+
--
|
|
10
|
+
-- The scope is (workspace_id, object_id) and deliberately NOT env_id, because
|
|
11
|
+
-- that is the scope the previous MAX() used. Widening it would change numbering
|
|
12
|
+
-- semantics; matching it changes nothing except that duplicates become
|
|
13
|
+
-- impossible.
|
|
14
|
+
--
|
|
15
|
+
-- A database that already contains duplicates cannot take this index. Rather
|
|
16
|
+
-- than fail with a bare "could not create unique index", stop first and say
|
|
17
|
+
-- exactly what is wrong and how to look at it: this is corrupt revision
|
|
18
|
+
-- history, and repairing it by renumbering rewrites a record the product
|
|
19
|
+
-- promises to keep, so it is an operator decision rather than a migration's.
|
|
20
|
+
DO $$
|
|
21
|
+
DECLARE dup_count integer; sample text;
|
|
22
|
+
BEGIN
|
|
23
|
+
SELECT count(*), min(workspace_id || ' / ' || object_id || ' / v' || version)
|
|
24
|
+
INTO dup_count, sample
|
|
25
|
+
FROM (
|
|
26
|
+
SELECT workspace_id, object_id, version
|
|
27
|
+
FROM "_revisions"
|
|
28
|
+
GROUP BY workspace_id, object_id, version
|
|
29
|
+
HAVING count(*) > 1
|
|
30
|
+
) d;
|
|
31
|
+
|
|
32
|
+
IF dup_count > 0 THEN
|
|
33
|
+
RAISE EXCEPTION
|
|
34
|
+
'Cannot add the revision uniqueness index: % object/version pair(s) already have duplicate revisions (for example %). '
|
|
35
|
+
'This is pre-existing corrupt revision history from the unsafe allocator. Inspect with: '
|
|
36
|
+
'SELECT workspace_id, object_id, version, count(*) FROM "_revisions" GROUP BY 1,2,3 HAVING count(*) > 1; '
|
|
37
|
+
'Decide how to renumber or remove the duplicates, then re-run the migration.',
|
|
38
|
+
dup_count, sample;
|
|
39
|
+
END IF;
|
|
40
|
+
END $$;
|
|
41
|
+
--> statement-breakpoint
|
|
42
|
+
CREATE UNIQUE INDEX IF NOT EXISTS "_revisions_object_version_uniq" ON "_revisions" ("workspace_id","object_id","version");
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
-- One pending job per logical target.
|
|
2
|
+
--
|
|
3
|
+
-- The search indexer enqueues a job per content mutation, and those jobs carry
|
|
4
|
+
-- no snapshot: each one re-reads the object and reconciles the index to
|
|
5
|
+
-- whatever it finds. So while a job is still QUEUED, a second job for the same
|
|
6
|
+
-- object is pure duplicate work - a hundred autosaves produced a hundred jobs
|
|
7
|
+
-- that all indexed the same final state.
|
|
8
|
+
--
|
|
9
|
+
-- The index is deliberately PARTIAL on status='queued'. A claimed (running)
|
|
10
|
+
-- row leaves the index, so a mutation arriving mid-job can always enqueue the
|
|
11
|
+
-- follow-up reconciliation it needs; without that, a change made while a job
|
|
12
|
+
-- was running would be silently dropped. A failed row leaves it too, so one
|
|
13
|
+
-- poisoned job cannot block current state from ever being reconciled.
|
|
14
|
+
--
|
|
15
|
+
-- dedupe_key is NULL for every other task, and NULL is not deduplicated.
|
|
16
|
+
ALTER TABLE "job_queue" ADD COLUMN IF NOT EXISTS "dedupe_key" TEXT;
|
|
17
|
+
|
|
18
|
+
CREATE UNIQUE INDEX IF NOT EXISTS "job_queue_dedupe_uniq"
|
|
19
|
+
ON "job_queue" ("dedupe_key")
|
|
20
|
+
WHERE "status" = 'queued' AND "dedupe_key" IS NOT NULL;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
-- btree_gin lets a GIN index carry ordinary scalar columns alongside the
|
|
2
|
+
-- tsvector, which is what keeps the scope filters INSIDE the index. It is a
|
|
3
|
+
-- standard contrib extension and is TRUSTED from PostgreSQL 13, so the database
|
|
4
|
+
-- owner can install it without superuser. If this fails, the deployment is on a
|
|
5
|
+
-- managed service that withholds contrib, and search must be told so plainly
|
|
6
|
+
-- rather than silently building an index that filters after the fact.
|
|
7
|
+
DO $$
|
|
8
|
+
BEGIN
|
|
9
|
+
CREATE EXTENSION IF NOT EXISTS btree_gin;
|
|
10
|
+
EXCEPTION WHEN OTHERS THEN
|
|
11
|
+
RAISE EXCEPTION 'Tangen search needs the btree_gin extension (standard PostgreSQL contrib, trusted since 13). Install it as the database owner: CREATE EXTENSION btree_gin; Underlying error: %', SQLERRM;
|
|
12
|
+
END $$;
|
|
13
|
+
|
|
14
|
+
-- The search index, in the database that was already mandatory.
|
|
15
|
+
--
|
|
16
|
+
-- One row per (workspace, environment, source, type, object, LOCALE). The
|
|
17
|
+
-- locale is part of the identity because the text-search configuration is per
|
|
18
|
+
-- row: a French document has to be stemmed and queried as French, and one row
|
|
19
|
+
-- carrying every translation could only ever be stemmed once.
|
|
20
|
+
--
|
|
21
|
+
-- `search_vector` is GENERATED, and its configuration comes from the row's own
|
|
22
|
+
-- `cfg` column - PostgreSQL accepts `to_tsvector(cfg, ...)` as immutable. So the
|
|
23
|
+
-- language is data, the vector maintains itself, and no application code or
|
|
24
|
+
-- trigger ever computes it.
|
|
25
|
+
--
|
|
26
|
+
-- No pg_trgm here on purpose. Measured at a million documents, the trigram index
|
|
27
|
+
-- cost 36% of write throughput and 175% more WAL than the FTS index it
|
|
28
|
+
-- accompanied - more than full-text search itself. Typo tolerance is an opt-in
|
|
29
|
+
-- capability, not a default, and lives in its own migration.
|
|
30
|
+
CREATE TABLE IF NOT EXISTS "search_documents" (
|
|
31
|
+
"workspace_id" text NOT NULL,
|
|
32
|
+
"env_id" text NOT NULL,
|
|
33
|
+
"source" text NOT NULL, -- 'content' | 'media' | 'ext:<id>:<src>'
|
|
34
|
+
"type_slug" text NOT NULL,
|
|
35
|
+
"object_id" text NOT NULL,
|
|
36
|
+
"locale" text NOT NULL DEFAULT '',
|
|
37
|
+
"cfg" regconfig NOT NULL DEFAULT 'simple',
|
|
38
|
+
"title" text NOT NULL DEFAULT '',
|
|
39
|
+
"slug" text,
|
|
40
|
+
"body" text NOT NULL DEFAULT '',
|
|
41
|
+
-- Bounded snippet source. ts_headline is linear in the text it is given:
|
|
42
|
+
-- 101 ms to snippet twenty 100 KB documents, against 1 ms for 1 KB ones.
|
|
43
|
+
"excerpt" text NOT NULL DEFAULT '',
|
|
44
|
+
"status" text NOT NULL DEFAULT 'draft',
|
|
45
|
+
"is_published" boolean NOT NULL DEFAULT false,
|
|
46
|
+
"metadata" jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
47
|
+
"updated_at" timestamptz NOT NULL DEFAULT now(),
|
|
48
|
+
"search_vector" tsvector GENERATED ALWAYS AS (
|
|
49
|
+
setweight(to_tsvector("cfg", coalesce("title", '')), 'A')
|
|
50
|
+
|| setweight(to_tsvector("cfg", coalesce("slug", '')), 'B')
|
|
51
|
+
|| setweight(to_tsvector("cfg", coalesce("excerpt", '')), 'B')
|
|
52
|
+
|| setweight(to_tsvector("cfg", coalesce("body", '')), 'C')
|
|
53
|
+
) STORED,
|
|
54
|
+
CONSTRAINT "search_documents_pkey"
|
|
55
|
+
PRIMARY KEY ("workspace_id", "env_id", "source", "type_slug", "object_id", "locale")
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
-- Every constraint a search actually applies lives INSIDE the index.
|
|
59
|
+
--
|
|
60
|
+
-- The prototype put only the vector in the GIN index and filtered
|
|
61
|
+
-- `is_published` afterwards: PostgreSQL fetched 50,000 matching heap rows and
|
|
62
|
+
-- then discarded every one of them, for 2.3 seconds. A filter outside the index
|
|
63
|
+
-- is not a filter, it is a post-processing step that pays full price.
|
|
64
|
+
CREATE INDEX IF NOT EXISTS "search_documents_fts"
|
|
65
|
+
ON "search_documents"
|
|
66
|
+
USING gin ("workspace_id", "env_id", "locale", "is_published", "source", "type_slug", "search_vector");
|
|
67
|
+
|
|
68
|
+
-- Autocomplete. An anchored B-tree, NOT trigram: measured across five prefixes
|
|
69
|
+
-- at a million documents, trigram ranged from 0.04 ms to 11.6 ms with a 137 ms
|
|
70
|
+
-- tail, while this is 0.015-0.104 ms p50 and under 1.2 ms at p95 for every
|
|
71
|
+
-- prefix tried. `text_pattern_ops` is what makes LIKE 'x%' an index range scan.
|
|
72
|
+
CREATE INDEX IF NOT EXISTS "search_documents_title_prefix"
|
|
73
|
+
ON "search_documents" ("workspace_id", "env_id", lower("title") text_pattern_ops);
|
|
74
|
+
CREATE INDEX IF NOT EXISTS "search_documents_slug_prefix"
|
|
75
|
+
ON "search_documents" ("workspace_id", "env_id", lower("slug") text_pattern_ops);
|
|
76
|
+
|
|
77
|
+
-- Exact identifier lookup: the CMS case, and the fastest path in the design.
|
|
78
|
+
CREATE INDEX IF NOT EXISTS "search_documents_slug"
|
|
79
|
+
ON "search_documents" ("workspace_id", "env_id", "slug");
|
|
80
|
+
-- Facet and filter dimensions that are not text.
|
|
81
|
+
CREATE INDEX IF NOT EXISTS "search_documents_scope"
|
|
82
|
+
ON "search_documents" ("workspace_id", "env_id", "is_published", "type_slug");
|
|
83
|
+
CREATE INDEX IF NOT EXISTS "search_documents_metadata"
|
|
84
|
+
ON "search_documents" USING gin ("metadata" jsonb_path_ops);
|
|
85
|
+
-- Backfill progress scans by object identity.
|
|
86
|
+
CREATE INDEX IF NOT EXISTS "search_documents_object"
|
|
87
|
+
ON "search_documents" ("workspace_id", "env_id", "source", "object_id");
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
-- Housekeeping must not scale with retained history.
|
|
2
|
+
--
|
|
3
|
+
-- Every five minutes the queue worker prunes finished jobs older than the
|
|
4
|
+
-- retention window. Neither prune had an index on the column it filters by, so
|
|
5
|
+
-- each one was a full sequential scan of the retained table - 15k rows of
|
|
6
|
+
-- job_queue and 46k rows of job_runs, every pass, to delete nothing at all on
|
|
7
|
+
-- a quiet server. That was the whole of an idle server's periodic read volume
|
|
8
|
+
-- once the claim query was fixed, and it grew with site activity: the busier a
|
|
9
|
+
-- site had been, the more an idle server paid to confirm there was nothing to
|
|
10
|
+
-- do. Same class of defect as the claim's ORDER BY id, one layer down.
|
|
11
|
+
--
|
|
12
|
+
-- With the column indexed, a prune with nothing old enough to delete positions
|
|
13
|
+
-- at the cutoff and stops: two buffers regardless of how much history is kept.
|
|
14
|
+
--
|
|
15
|
+
-- job_queue: finished_at is NULL until a job finishes, and the prune filters on
|
|
16
|
+
-- status IN ('done','failed') as well. A partial index matching that predicate
|
|
17
|
+
-- measured identically to a plain one (same size, same two buffers), so the
|
|
18
|
+
-- plain index is used: it does not depend on the planner proving the partial
|
|
19
|
+
-- predicate, and it stays useful if the prune's status list ever changes.
|
|
20
|
+
--
|
|
21
|
+
-- job_runs: job_runs_scope_idx is (workspace_id, env_id, started_at DESC), and
|
|
22
|
+
-- a bare started_at range cannot use it with started_at in third position.
|
|
23
|
+
CREATE INDEX IF NOT EXISTS "job_queue_finished_idx" ON "job_queue" ("finished_at");
|
|
24
|
+
|
|
25
|
+
CREATE INDEX IF NOT EXISTS "job_runs_started_idx" ON "job_runs" ("started_at");
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
-- Extension data by object.
|
|
2
|
+
--
|
|
3
|
+
-- Every admin list asks extension_data which of the page's objects carry
|
|
4
|
+
-- unpublished extension data (the draft-changes indicator). Both existing
|
|
5
|
+
-- indexes lead with extension_id, so that lookup - by workspace, environment
|
|
6
|
+
-- and object - had no index at all and scanned the whole table: measured at
|
|
7
|
+
-- 37 ms over 120k rows, on every admin list, growing with every extension row
|
|
8
|
+
-- a site accumulates. This index makes it a handful of probes, and lets the
|
|
9
|
+
-- indicator ride the page query itself instead of costing a round trip.
|
|
10
|
+
CREATE INDEX IF NOT EXISTS "extension_data_object_idx" ON "extension_data" ("workspace_id", "env_id", "object_id");
|