@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.
Files changed (127) hide show
  1. package/README.md +73 -2
  2. package/drizzle/0000_flimsy_shotgun.sql +339 -0
  3. package/drizzle/0001_app_data.sql +11 -0
  4. package/drizzle/0002_add_indexes.sql +36 -0
  5. package/drizzle/0003_extension_data_rename.sql +3 -0
  6. package/drizzle/0004_extension_data_published_globals.sql +12 -0
  7. package/drizzle/0005_auth.sql +51 -0
  8. package/drizzle/0006_auth_identities.sql +13 -0
  9. package/drizzle/0007_ai.sql +18 -0
  10. package/drizzle/0008_ai_chat_threads.sql +5 -0
  11. package/drizzle/0009_forms_source_slug.sql +11 -0
  12. package/drizzle/0010_rbac_roles_status.sql +14 -0
  13. package/drizzle/0011_jobs_v2.sql +67 -0
  14. package/drizzle/0012_search_platform.sql +36 -0
  15. package/drizzle/0013_search_synonym_label.sql +2 -0
  16. package/drizzle/0014_object_types_disabled.sql +2 -0
  17. package/drizzle/0015_playground_collections.sql +25 -0
  18. package/drizzle/0016_catchup_activity_promotion.sql +36 -0
  19. package/drizzle/0017_fields_disabled.sql +3 -0
  20. package/drizzle/0018_object_types_plural.sql +1 -0
  21. package/drizzle/0019_member_profile.sql +2 -0
  22. package/drizzle/0020_paused_jobs.sql +7 -0
  23. package/drizzle/0021_form_submission_notes.sql +2 -0
  24. package/drizzle/0022_paused_jobs_env_reason.sql +4 -0
  25. package/drizzle/0023_scale_indexes.sql +3 -0
  26. package/drizzle/0024_migration_runs.sql +24 -0
  27. package/drizzle/0025_porter_transfer.sql +30 -0
  28. package/drizzle/0026_cron_timezone.sql +1 -0
  29. package/drizzle/0027_rate_limit_counters.sql +6 -0
  30. package/drizzle/0028_realtime_events.sql +10 -0
  31. package/drizzle/0030_delivery_artifacts.sql +10 -0
  32. package/drizzle/0031_realtime_presence.sql +8 -0
  33. package/drizzle/0032_schema_pushes.sql +15 -0
  34. package/drizzle/0033_studio_design_capability.sql +37 -0
  35. package/drizzle/0034_object_types_provided_by.sql +12 -0
  36. package/drizzle/0035_custom_table_owners.sql +24 -0
  37. package/drizzle/0036_revision_version_unique.sql +42 -0
  38. package/drizzle/0037_job_queue_dedupe.sql +20 -0
  39. package/drizzle/0038_search_documents.sql +87 -0
  40. package/drizzle/0039_job_prune_indexes.sql +25 -0
  41. package/drizzle/0040_extension_data_object_idx.sql +10 -0
  42. package/drizzle/0041_knowledge.sql +79 -0
  43. package/drizzle/0042_schema_pushes_checksum.sql +7 -0
  44. package/drizzle/0043_guard_adoption.sql +12 -0
  45. package/drizzle/0044_retention_indexes.sql +7 -0
  46. package/drizzle/0045_extension_install_per_env.sql +40 -0
  47. package/drizzle/0046_extension_migrations.sql +18 -0
  48. package/drizzle/0047_environment_role.sql +25 -0
  49. package/drizzle/0048_environment_role_adoption.sql +30 -0
  50. package/drizzle/0049_release_lifecycle.sql +55 -0
  51. package/drizzle/0050_member_env_membership.sql +21 -0
  52. package/drizzle/0051_remove_environments.sql +350 -0
  53. package/drizzle/0052_release_actor_columns.sql +9 -0
  54. package/drizzle/0053_kernel_event_record.sql +26 -0
  55. package/drizzle/0054_kernel_run_identity.sql +13 -0
  56. package/drizzle/0055_kernel_identities.sql +19 -0
  57. package/drizzle/0056_forms_leave_core.sql +7 -0
  58. package/drizzle/0057_auth_leave_core.sql +8 -0
  59. package/drizzle/0058_member_grants.sql +11 -0
  60. package/drizzle/0059_extension_granted_grants.sql +7 -0
  61. package/drizzle/0060_connections.sql +21 -0
  62. package/drizzle/meta/0000_snapshot.json +1692 -0
  63. package/drizzle/meta/0002_snapshot.json +1935 -0
  64. package/drizzle/meta/0003_snapshot.json +1935 -0
  65. package/drizzle/meta/_journal.json +426 -0
  66. package/package.json +85 -4
  67. package/src/cache-keys.ts +43 -0
  68. package/src/client.ts +136 -0
  69. package/src/content-ddl.ts +70 -0
  70. package/src/content-tables.ts +110 -0
  71. package/src/custom-tables-loader.ts +65 -0
  72. package/src/ensure-system-schema.ts +195 -0
  73. package/src/extension-install-state.ts +220 -0
  74. package/src/extension-migrations.ts +270 -0
  75. package/src/generated/content-tables.ts +271 -0
  76. package/src/generated/custom-tables.ts +18 -0
  77. package/src/index.ts +24 -0
  78. package/src/instrument.ts +139 -0
  79. package/src/knowledge-schema.ts +232 -0
  80. package/src/migrate.ts +67 -0
  81. package/src/prepare-policy.ts +118 -0
  82. package/src/reconcile.ts +115 -0
  83. package/src/schema/ai-chats.ts +36 -0
  84. package/src/schema/ai-settings.ts +24 -0
  85. package/src/schema/api-keys.ts +37 -0
  86. package/src/schema/audit-log.ts +54 -0
  87. package/src/schema/connections.ts +35 -0
  88. package/src/schema/custom-table-owners.ts +32 -0
  89. package/src/schema/delivery-artifacts.ts +33 -0
  90. package/src/schema/entity-tags.ts +33 -0
  91. package/src/schema/extension-data.ts +38 -0
  92. package/src/schema/extension-migrations.ts +42 -0
  93. package/src/schema/fields.ts +37 -0
  94. package/src/schema/globals.ts +35 -0
  95. package/src/schema/identities.ts +27 -0
  96. package/src/schema/index.ts +51 -0
  97. package/src/schema/jobs.ts +104 -0
  98. package/src/schema/knowledge.ts +85 -0
  99. package/src/schema/media.ts +52 -0
  100. package/src/schema/member-grants.ts +34 -0
  101. package/src/schema/members.ts +49 -0
  102. package/src/schema/migration-runs.ts +46 -0
  103. package/src/schema/object-types.ts +41 -0
  104. package/src/schema/objects.ts +38 -0
  105. package/src/schema/paused-jobs.ts +25 -0
  106. package/src/schema/playground.ts +43 -0
  107. package/src/schema/porter-transfer.ts +47 -0
  108. package/src/schema/realtime.ts +45 -0
  109. package/src/schema/releases.ts +95 -0
  110. package/src/schema/roles.ts +27 -0
  111. package/src/schema/saved-views.ts +33 -0
  112. package/src/schema/schema-pushes.ts +43 -0
  113. package/src/schema/search-documents.ts +47 -0
  114. package/src/schema/search-query-log.ts +24 -0
  115. package/src/schema/search-recommendations.ts +27 -0
  116. package/src/schema/search-synonyms.ts +23 -0
  117. package/src/schema/webhook-deliveries.ts +37 -0
  118. package/src/schema/webhooks.ts +36 -0
  119. package/src/schema/workspace-activity.ts +35 -0
  120. package/src/schema/workspace-domains.ts +24 -0
  121. package/src/schema/workspace-installed-apps.ts +48 -0
  122. package/src/schema/workspaces.ts +24 -0
  123. package/src/schema-kit.ts +64 -0
  124. package/src/sealed.ts +84 -0
  125. package/src/seed/skeleton.ts +77 -0
  126. package/src/sql/apply-gin-indexes.ts +31 -0
  127. 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,2 @@
1
+ -- Search synonyms: a human title/label per group so marketing knows its purpose.
2
+ ALTER TABLE search_synonyms ADD COLUMN IF NOT EXISTS label text;
@@ -0,0 +1,2 @@
1
+ -- TL-1: type-level soft delete — content APIs 404 for disabled types
2
+ ALTER TABLE "object_types" ADD COLUMN IF NOT EXISTS "disabled" boolean NOT NULL DEFAULT false;
@@ -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,3 @@
1
+ -- #42 D5: field-level soft-disable — hidden from every runtime surface (admin,
2
+ -- REST, TangenQL, GraphQL); column + data preserved, fully reversible.
3
+ ALTER TABLE "fields" ADD COLUMN IF NOT EXISTS "disabled" boolean NOT NULL DEFAULT false;
@@ -0,0 +1 @@
1
+ ALTER TABLE "object_types" ADD COLUMN IF NOT EXISTS "plural" text;
@@ -0,0 +1,2 @@
1
+ ALTER TABLE "members" ADD COLUMN IF NOT EXISTS "designation" text;
2
+ ALTER TABLE "members" ADD COLUMN IF NOT EXISTS "avatar" 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,2 @@
1
+ -- #50: internal admin notes per form submission (lead management)
2
+ ALTER TABLE "form_submissions" ADD COLUMN IF NOT EXISTS "notes" text;
@@ -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");