fix: deployment errors

This commit is contained in:
Rotimi Best
2026-05-19 04:32:32 +01:00
parent ac3a2c14a7
commit 0048f267e2
19 changed files with 2270 additions and 502 deletions
+3
View File
@@ -2,6 +2,9 @@
"plugins": {
"svelte": {
"enabled": true
},
"supabase": {
"enabled": true
}
}
}
@@ -1,5 +1,4 @@
import { BaseApi, classroomio } from '$lib/utils/services/api';
import { isOrgStudent } from '$lib/utils/store/app';
import { currentOrg, mergeAccountOrgFromServer, orgs } from '$lib/utils/store/org';
import { defaultProfileState, defaultUserState, profile, user } from '$lib/utils/store/user';
@@ -10,6 +9,7 @@ import { get } from 'svelte/store';
import { goto } from '$app/navigation';
import { handleLocaleChange } from '$lib/utils/functions/translations';
import { identifyPosthogUser } from '$lib/utils/services/posthog';
import { isOrgStudent } from '$lib/utils/store/app';
import { isPublicRoute } from '$lib/utils/functions/routes/isPublicRoute';
import { licenseApi } from '$features/license/api/license.svelte';
import { logout } from '$lib/utils/functions/logout';
+4 -1
View File
@@ -56,7 +56,10 @@
onMount(async () => {
if (!data.isOrgSite || !data.org) {
if (!appInitApi.loading) {
appInitApi.routeUserToNextPage({ isOrgSite: false, orgSiteName: '' });
appInitApi.setupApp(data.locals, {
isOrgSite: data.isOrgSite,
orgSiteName: data.orgSiteName
});
}
return;
+1 -1
View File
@@ -13,7 +13,7 @@
"postinstall": "test -d .git && lefthook install || true",
"release": "standard-version",
"db:reset": "pnpm --filter @cio/db db:reset",
"dashboard:dev": "pnpm --filter=@cio/dashboard --filter=@cio/ui run dev",
"dashboard:dev": "pnpm --filter=@cio/dashboard run dev",
"api:dev": "pnpm --filter=@cio/api --filter=@cio/jobs-worker run dev",
"jobs:dev": "pnpm --filter=@cio/jobs-worker run dev",
"dev:all": "pnpm concurrently -n dash,api,jobs -c green,blue,yellow \"pnpm dashboard:dev\" \"pnpm api:dev\" \"pnpm jobs:dev\"",
+1
View File
@@ -6,6 +6,7 @@
"rootDir": "./src",
"module": "ES2022",
"target": "ES2020",
"lib": ["ES2020", "DOM"],
"moduleResolution": "bundler",
"declaration": true,
"declarationMap": true
+2
View File
@@ -1,3 +1,5 @@
backups/
dist
node_modules
*.log
+6 -1
View File
@@ -24,7 +24,12 @@
"generate-types": "tsx scripts/generate-types.ts && pnpm format",
"seed": "tsx src/scripts/seed.ts",
"format": "prettier . --write",
"db:supabase-to-betterauth": "tsx src/scripts/supabase-to-betterauth.ts"
"db:supabase-to-betterauth": "tsx src/scripts/supabase-to-betterauth.ts",
"db:dump-backup": "tsx src/scripts/pg-dump-backup.ts",
"db:push-dump-to-new-db": "tsx src/scripts/push-dump-to-new-db.ts",
"db:restore-lesson-section-from-dump": "tsx src/scripts/restore-lesson-section-from-dump.ts",
"db:restore-organizationmember-from-dump": "tsx src/scripts/restore-organizationmember-from-dump.ts",
"db:disable-v1-content-grouping-from-dump": "tsx src/scripts/disable-v1-content-grouping-from-dump.ts"
},
"dependencies": {
"@better-auth/sso": "^1.4.18",
@@ -1,4 +1,4 @@
CREATE TABLE "account" (
CREATE TABLE "public"."account" (
"id" text PRIMARY KEY NOT NULL,
"account_id" text NOT NULL,
"provider_id" text NOT NULL,
@@ -14,7 +14,7 @@ CREATE TABLE "account" (
"updated_at" timestamp NOT NULL
);
--> statement-breakpoint
CREATE TABLE "session" (
CREATE TABLE "public"."session" (
"id" text PRIMARY KEY NOT NULL,
"expires_at" timestamp NOT NULL,
"token" text NOT NULL,
@@ -27,7 +27,7 @@ CREATE TABLE "session" (
CONSTRAINT "session_token_unique" UNIQUE("token")
);
--> statement-breakpoint
CREATE TABLE "user" (
CREATE TABLE "public"."user" (
"id" text PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"email" text NOT NULL,
@@ -43,5 +43,5 @@ CREATE TABLE "user" (
CONSTRAINT "user_email_unique" UNIQUE("email")
);
--> statement-breakpoint
ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "public"."account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "public"."session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
@@ -1,4 +1,4 @@
CREATE TABLE IF NOT EXISTS "verification" (
CREATE TABLE IF NOT EXISTS "public"."verification" (
"id" uuid PRIMARY KEY DEFAULT uuid_generate_v4() NOT NULL,
"identifier" text NOT NULL,
"value" text NOT NULL,
@@ -7,16 +7,14 @@ CREATE TABLE IF NOT EXISTS "verification" (
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "account" DROP CONSTRAINT "account_user_id_user_id_fk";--> statement-breakpoint
ALTER TABLE "account" ALTER COLUMN "id" SET DEFAULT uuid_generate_v4();--> statement-breakpoint
ALTER TABLE "account" ALTER COLUMN "user_id" SET DATA TYPE uuid USING user_id::uuid;--> statement-breakpoint
ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user" ALTER COLUMN "id" SET DATA TYPE uuid USING id::uuid;--> statement-breakpoint
ALTER TABLE "user" ALTER COLUMN "id" SET DEFAULT uuid_generate_v4();--> statement-breakpoint
ALTER TABLE "session" DROP CONSTRAINT "session_user_id_user_id_fk";--> statement-breakpoint
ALTER TABLE "session" ALTER COLUMN "id" SET DATA TYPE uuid USING id::uuid;--> statement-breakpoint
ALTER TABLE "session" ALTER COLUMN "id" SET DEFAULT uuid_generate_v4();--> statement-breakpoint
ALTER TABLE "session" ALTER COLUMN "user_id" SET DATA TYPE uuid USING user_id::uuid;--> statement-breakpoint
ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "public"."account" DROP CONSTRAINT IF EXISTS "account_user_id_user_id_fk";--> statement-breakpoint
ALTER TABLE "public"."session" DROP CONSTRAINT IF EXISTS "session_user_id_user_id_fk";--> statement-breakpoint
ALTER TABLE "public"."user" ALTER COLUMN "id" SET DATA TYPE uuid USING id::uuid;--> statement-breakpoint
ALTER TABLE "public"."user" ALTER COLUMN "id" SET DEFAULT uuid_generate_v4();--> statement-breakpoint
ALTER TABLE "public"."account" ALTER COLUMN "id" SET DEFAULT uuid_generate_v4();--> statement-breakpoint
ALTER TABLE "public"."account" ALTER COLUMN "user_id" SET DATA TYPE uuid USING user_id::uuid;--> statement-breakpoint
ALTER TABLE "public"."account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "public"."session" ALTER COLUMN "id" SET DATA TYPE uuid USING id::uuid;--> statement-breakpoint
ALTER TABLE "public"."session" ALTER COLUMN "id" SET DEFAULT uuid_generate_v4();--> statement-breakpoint
ALTER TABLE "public"."session" ALTER COLUMN "user_id" SET DATA TYPE uuid USING user_id::uuid;--> statement-breakpoint
ALTER TABLE "public"."session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
@@ -1,4 +1,4 @@
CREATE TABLE IF NOT EXISTS "exercise_template" (
CREATE TABLE IF NOT EXISTS "public"."exercise_template" (
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "exercise_template_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"title" text,
@@ -7,237 +7,300 @@ CREATE TABLE IF NOT EXISTS "exercise_template" (
"questionnaire" jsonb DEFAULT '{}'::jsonb
);
--> statement-breakpoint
ALTER TABLE "analytics_login_events" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
ALTER TABLE "apps_poll" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
ALTER TABLE "apps_poll_option" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
ALTER TABLE "apps_poll_submission" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
ALTER TABLE "community_answer" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
ALTER TABLE "community_question" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
ALTER TABLE "course" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
ALTER TABLE "course_newsfeed" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
ALTER TABLE "course_newsfeed_comment" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
ALTER TABLE "lesson_section" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
ALTER TABLE "exercise" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
ALTER TABLE "group" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
ALTER TABLE "group_attendance" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
ALTER TABLE "groupmember" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
ALTER TABLE "lesson" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
ALTER TABLE "lesson_comment" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
ALTER TABLE "lesson_completion" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
ALTER TABLE "lesson_language" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
ALTER TABLE "lesson_language_history" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
ALTER TABLE "option" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
ALTER TABLE "organization" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
ALTER TABLE "organization_contacts" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
ALTER TABLE "organization_emaillist" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
ALTER TABLE "organization_plan" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
ALTER TABLE "organizationmember" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
ALTER TABLE "profile" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
ALTER TABLE "question" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
ALTER TABLE "question_answer" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
ALTER TABLE "question_type" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
ALTER TABLE "quiz" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
ALTER TABLE "quiz_play" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
ALTER TABLE "role" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
ALTER TABLE "submission" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
ALTER TABLE "submissionstatus" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
ALTER TABLE "lesson_section" RENAME TO "course_section";--> statement-breakpoint
ALTER TABLE "course_newsfeed_comment" DROP CONSTRAINT "course_newsfeed_comment_id_key";--> statement-breakpoint
ALTER TABLE "lesson" DROP CONSTRAINT "public_lesson_section_id_fkey";
ALTER TABLE IF EXISTS "public"."analytics_login_events" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
ALTER TABLE IF EXISTS "public"."apps_poll" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
ALTER TABLE IF EXISTS "public"."apps_poll_option" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
ALTER TABLE IF EXISTS "public"."apps_poll_submission" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
ALTER TABLE IF EXISTS "public"."community_answer" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
ALTER TABLE IF EXISTS "public"."community_question" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
ALTER TABLE IF EXISTS "public"."course" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
ALTER TABLE IF EXISTS "public"."course_newsfeed" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
ALTER TABLE IF EXISTS "public"."course_newsfeed_comment" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
ALTER TABLE IF EXISTS "public"."lesson_section" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
ALTER TABLE IF EXISTS "public"."course_section" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
ALTER TABLE IF EXISTS "public"."exercise" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
ALTER TABLE IF EXISTS "public"."group" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
ALTER TABLE IF EXISTS "public"."group_attendance" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
ALTER TABLE IF EXISTS "public"."groupmember" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
ALTER TABLE IF EXISTS "public"."lesson" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
ALTER TABLE IF EXISTS "public"."lesson_comment" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
ALTER TABLE IF EXISTS "public"."lesson_completion" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
ALTER TABLE IF EXISTS "public"."lesson_language" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
ALTER TABLE IF EXISTS "public"."lesson_language_history" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
ALTER TABLE IF EXISTS "public"."option" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
ALTER TABLE IF EXISTS "public"."organization" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
ALTER TABLE IF EXISTS "public"."organization_contacts" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
ALTER TABLE IF EXISTS "public"."organization_emaillist" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
ALTER TABLE IF EXISTS "public"."organization_plan" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
ALTER TABLE IF EXISTS "public"."organizationmember" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
ALTER TABLE IF EXISTS "public"."profile" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
ALTER TABLE IF EXISTS "public"."question" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
ALTER TABLE IF EXISTS "public"."question_answer" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
ALTER TABLE IF EXISTS "public"."question_type" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
ALTER TABLE IF EXISTS "public"."quiz" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
ALTER TABLE IF EXISTS "public"."quiz_play" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
ALTER TABLE IF EXISTS "public"."role" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
ALTER TABLE IF EXISTS "public"."submission" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
ALTER TABLE IF EXISTS "public"."submissionstatus" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
ALTER TABLE "public"."lesson" DROP CONSTRAINT IF EXISTS "public_lesson_section_id_fkey";
--> statement-breakpoint
ALTER TABLE "course_section" DROP CONSTRAINT "public_lesson_section_course_id_fkey";
--> statement-breakpoint
ALTER TABLE "account" ALTER COLUMN "id" SET DATA TYPE uuid USING id::uuid;--> statement-breakpoint
ALTER TABLE "account" ALTER COLUMN "id" SET DEFAULT gen_random_uuid();--> statement-breakpoint
ALTER TABLE "analytics_login_events" ALTER COLUMN "id" SET DEFAULT gen_random_uuid();--> statement-breakpoint
ALTER TABLE "community_answer" ALTER COLUMN "id" SET DEFAULT gen_random_uuid();--> statement-breakpoint
ALTER TABLE "course" ALTER COLUMN "id" SET DEFAULT gen_random_uuid();--> statement-breakpoint
ALTER TABLE "exercise" ALTER COLUMN "id" SET DEFAULT gen_random_uuid();--> statement-breakpoint
ALTER TABLE "group" ALTER COLUMN "id" SET DEFAULT gen_random_uuid();--> statement-breakpoint
ALTER TABLE "groupmember" ALTER COLUMN "id" SET DEFAULT gen_random_uuid();--> statement-breakpoint
ALTER TABLE "lesson" ALTER COLUMN "id" SET DEFAULT gen_random_uuid();--> statement-breakpoint
ALTER TABLE "option" ALTER COLUMN "value" SET DEFAULT gen_random_uuid();--> statement-breakpoint
ALTER TABLE "organization" ALTER COLUMN "id" SET DEFAULT gen_random_uuid();--> statement-breakpoint
ALTER TABLE "question" ALTER COLUMN "name" SET DEFAULT gen_random_uuid();--> statement-breakpoint
ALTER TABLE "quiz" ALTER COLUMN "id" SET DEFAULT gen_random_uuid();--> statement-breakpoint
ALTER TABLE "session" ALTER COLUMN "id" SET DEFAULT gen_random_uuid();--> statement-breakpoint
ALTER TABLE "submission" ALTER COLUMN "id" SET DEFAULT gen_random_uuid();--> statement-breakpoint
ALTER TABLE "user" ALTER COLUMN "id" SET DEFAULT gen_random_uuid();--> statement-breakpoint
ALTER TABLE "verification" ALTER COLUMN "id" SET DATA TYPE uuid USING id::uuid;--> statement-breakpoint
ALTER TABLE "verification" ALTER COLUMN "id" SET DEFAULT gen_random_uuid();--> statement-breakpoint
ALTER TABLE "exercise" ADD COLUMN "course_id" uuid;--> statement-breakpoint
ALTER TABLE "exercise" ADD COLUMN "section_id" uuid;--> statement-breakpoint
ALTER TABLE "exercise" ADD COLUMN "order" bigint;--> statement-breakpoint
ALTER TABLE "exercise" ADD COLUMN "is_unlocked" boolean DEFAULT true;--> statement-breakpoint
ALTER TABLE "exercise" ADD CONSTRAINT "exercise_course_id_fkey" FOREIGN KEY ("course_id") REFERENCES "public"."course"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "exercise" ADD CONSTRAINT "exercise_section_id_fkey" FOREIGN KEY ("section_id") REFERENCES "public"."course_section"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "lesson" ADD CONSTRAINT "public_course_section_id_fkey" FOREIGN KEY ("section_id") REFERENCES "public"."course_section"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
ALTER TABLE "course_section" ADD CONSTRAINT "public_course_section_course_id_fkey" FOREIGN KEY ("course_id") REFERENCES "public"."course"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
ALTER TABLE "course" DROP COLUMN "version";--> statement-breakpoint
DROP POLICY IF EXISTS "Enable delete for users based on user_id" ON "analytics_login_events" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Users can insert their own login events" ON "analytics_login_events" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Delete only your own poll" ON "apps_poll" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Update only your own" ON "apps_poll" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be a course member to INSERT" ON "apps_poll" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be course member to SELECT" ON "apps_poll" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be a course member to INSERT" ON "apps_poll_option" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be a course member to UPDATE" ON "apps_poll_option" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be a teacher to DELETE" ON "apps_poll_option" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be course member to SELECT" ON "apps_poll_option" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Authenticated users can read" ON "apps_poll_submission" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Delete your own submission" ON "apps_poll_submission" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Enable insert for authenticated users only" ON "apps_poll_submission" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Update your own submission" ON "apps_poll_submission" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Authenticated users can SELECT" ON "community_answer" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Delete your own answer" ON "community_answer" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Enable insert for authenticated users only" ON "community_answer" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Update your own answer" ON "community_answer" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Authenticated users can SELECT" ON "community_question" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Delete your own question" ON "community_question" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Enable insert for authenticated users only" ON "community_question" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Update your own question" ON "community_question" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Enable access to all users if PUBLIC or to course members when " ON "course" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be an org member to DELETE" ON "course" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be an org member to INSERT" ON "course" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be an org member to UPDATE" ON "course" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Delete your own comment" ON "course_newsfeed" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Update only your own" ON "course_newsfeed" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be a course member to INSERT" ON "course_newsfeed" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be a course member to SELECT" ON "course_newsfeed" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Delete your own" ON "course_newsfeed_comment" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Update only your own" ON "course_newsfeed_comment" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be a course member to INSERT" ON "course_newsfeed_comment" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be a course member to SELECT" ON "course_newsfeed_comment" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Enable read access for all users" ON "exercise" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be an org member to DELETE" ON "exercise" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be an org member to INSERT" ON "exercise" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be an org member to UPDATE" ON "exercise" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Enable insert for authenticated users only" ON "group" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Enable read access for all users" ON "group" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Only org admins can delete" ON "group" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Only org admins can update" ON "group" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be a course member to INSERT" ON "group_attendance" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be a course member to SELECT" ON "group_attendance" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be a course member to UPDATE" ON "group_attendance" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be an org member to DELETE" ON "group_attendance" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Enable insert for authenticated users only" ON "groupmember" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Enable read access for all users" ON "groupmember" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be an org member to DELETE" ON "groupmember" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be an org member to UPDATE" ON "groupmember" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Enable read access for all users" ON "lesson" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be an org member to DELETE" ON "lesson" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be an org member to INSERT" ON "lesson" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be an org member to UPDATE" ON "lesson" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Delete only your own comment" ON "lesson_comment" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Enable read access for all users" ON "lesson_comment" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Update only your own" ON "lesson_comment" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be in course group to INSERT" ON "lesson_comment" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Enable read access for all users" ON "lesson_completion" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be an course member or Admin to perform ALL operation" ON "lesson_completion" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be an org member to DELETE" ON "lesson_completion" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be an org member to UPDATE" ON "lesson_completion" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Enable read access for all users" ON "lesson_language" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be an org member to DELETE" ON "lesson_language" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be an org member to INSERT" ON "lesson_language" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be an org member to UPDATE" ON "lesson_language" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Authenticated users can SELECT" ON "lesson_language_history" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be an org member to DELETE" ON "lesson_language_history" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be an org member to INSERT" ON "lesson_language_history" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be an org member to UPDATE" ON "lesson_language_history" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Enable read access for all users" ON "course_section" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be an org member to DELETE" ON "course_section" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be an org member to INSERT" ON "course_section" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be an org member to UPDATE" ON "course_section" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Allow authenticated users to SELECT" ON "option" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be an org member to DELETE" ON "option" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be an org member to INSERT" ON "option" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be an org member to UPDATE" ON "option" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Authenticated users can delete" ON "organization" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Enable insert for authenticated users only" ON "organization" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Enable read access for all users" ON "organization" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be admin to UPDATE" ON "organization" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Enable read access for all users" ON "organization_contacts" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Enable read access for all users" ON "organization_emaillist" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Enable read access for all users" ON "organization_plan" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be an org member to DELETE" ON "organization_plan" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be an org member to INSERT" ON "organization_plan" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be an org member to UPDATE" ON "organization_plan" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Allow authenticated users to read." ON "organizationmember" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Enable insert for authenticated users only" ON "organizationmember" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Only admin can delete" ON "organizationmember" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Only admin can update" ON "organizationmember" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Only user can update their account via email" ON "organizationmember" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be an admin to INSERT or allow if no existing member" ON "organizationmember" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Only auth users can read profile" ON "profile" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Public profiles are viewable by everyone." ON "profile" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User can only delete their profiles" ON "profile" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Users can insert their own profile." ON "profile" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Users can update own profile." ON "profile" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Allow authenticated users to SELECT" ON "question" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be an org member to DELETE" ON "question" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be an org member to INSERT" ON "question" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be an org member to UPDATE" ON "question" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Only authenticated users can select." ON "question_answer" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be an course member to DELETE" ON "question_answer" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be an course member to INSERT" ON "question_answer" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be an course member to UPDATE" ON "question_answer" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Enable read access for all users" ON "question_type" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Authenticated users can SELECT" ON "quiz" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Authenticated users can SELECT" ON "quiz_play" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Enable read access for all users" ON "role" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Only authenticated users can SELECT" ON "submission" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be a course member to DELETE" ON "submission" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be a course member to INSERT" ON "submission" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be a course member to UPDATE" ON "submission" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Authenticated users can SELECT" ON "submissionstatus" CASCADE;--> statement-breakpoint
ALTER TABLE IF EXISTS "public"."lesson_section" DROP CONSTRAINT IF EXISTS "public_lesson_section_course_id_fkey";--> statement-breakpoint
DO $migrate_rename_lesson_section_to_course_section$ BEGIN
IF EXISTS (
SELECT 1
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public' AND c.relkind = 'r' AND c.relname = 'lesson_section'
) AND NOT EXISTS (
SELECT 1
FROM pg_class c2
JOIN pg_namespace n2 ON n2.oid = c2.relnamespace
WHERE n2.nspname = 'public' AND c2.relkind = 'r' AND c2.relname = 'course_section'
) THEN
ALTER TABLE public.lesson_section RENAME TO course_section;
END IF;
END;
$migrate_rename_lesson_section_to_course_section$;--> statement-breakpoint
ALTER TABLE IF EXISTS "public"."course_newsfeed_comment" DROP CONSTRAINT IF EXISTS "course_newsfeed_comment_id_key";--> statement-breakpoint
ALTER TABLE "public"."account" ALTER COLUMN "id" SET DATA TYPE uuid USING id::uuid;--> statement-breakpoint
ALTER TABLE "public"."account" ALTER COLUMN "id" SET DEFAULT gen_random_uuid();--> statement-breakpoint
ALTER TABLE "public"."analytics_login_events" ALTER COLUMN "id" SET DEFAULT gen_random_uuid();--> statement-breakpoint
ALTER TABLE "public"."community_answer" ALTER COLUMN "id" SET DEFAULT gen_random_uuid();--> statement-breakpoint
ALTER TABLE "public"."course" ALTER COLUMN "id" SET DEFAULT gen_random_uuid();--> statement-breakpoint
ALTER TABLE "public"."exercise" ALTER COLUMN "id" SET DEFAULT gen_random_uuid();--> statement-breakpoint
ALTER TABLE "public"."group" ALTER COLUMN "id" SET DEFAULT gen_random_uuid();--> statement-breakpoint
ALTER TABLE "public"."groupmember" ALTER COLUMN "id" SET DEFAULT gen_random_uuid();--> statement-breakpoint
ALTER TABLE "public"."lesson" ALTER COLUMN "id" SET DEFAULT gen_random_uuid();--> statement-breakpoint
ALTER TABLE "public"."option" ALTER COLUMN "value" SET DEFAULT gen_random_uuid();--> statement-breakpoint
ALTER TABLE "public"."organization" ALTER COLUMN "id" SET DEFAULT gen_random_uuid();--> statement-breakpoint
ALTER TABLE "public"."question" ALTER COLUMN "name" SET DEFAULT gen_random_uuid();--> statement-breakpoint
ALTER TABLE "public"."quiz" ALTER COLUMN "id" SET DEFAULT gen_random_uuid();--> statement-breakpoint
ALTER TABLE "public"."session" ALTER COLUMN "id" SET DEFAULT gen_random_uuid();--> statement-breakpoint
ALTER TABLE "public"."submission" ALTER COLUMN "id" SET DEFAULT gen_random_uuid();--> statement-breakpoint
ALTER TABLE "public"."user" ALTER COLUMN "id" SET DEFAULT gen_random_uuid();--> statement-breakpoint
ALTER TABLE "public"."verification" ALTER COLUMN "id" SET DATA TYPE uuid USING id::uuid;--> statement-breakpoint
ALTER TABLE "public"."verification" ALTER COLUMN "id" SET DEFAULT gen_random_uuid();--> statement-breakpoint
ALTER TABLE "public"."exercise" ADD COLUMN "course_id" uuid;--> statement-breakpoint
ALTER TABLE "public"."exercise" ADD COLUMN "section_id" uuid;--> statement-breakpoint
ALTER TABLE "public"."exercise" ADD COLUMN "order" bigint;--> statement-breakpoint
ALTER TABLE "public"."exercise" ADD COLUMN "is_unlocked" boolean DEFAULT true;--> statement-breakpoint
DO $add_exercise_course_fk$
BEGIN
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'exercise' AND schemaname = 'public')
AND NOT EXISTS (
SELECT 1 FROM pg_constraint co
JOIN pg_class rel ON rel.oid = co.conrelid
JOIN pg_namespace n ON n.oid = rel.relnamespace
WHERE n.nspname = 'public' AND rel.relname = 'exercise' AND co.contype = 'f' AND co.conname = 'exercise_course_id_fkey'
) THEN
ALTER TABLE public.exercise ADD CONSTRAINT exercise_course_id_fkey FOREIGN KEY ("course_id") REFERENCES public.course("id") ON DELETE no action ON UPDATE no action;
END IF;
END;
$add_exercise_course_fk$;--> statement-breakpoint
DO $add_exercise_section_fk$
BEGIN
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'exercise' AND schemaname = 'public')
AND NOT EXISTS (
SELECT 1 FROM pg_constraint co
JOIN pg_class rel ON rel.oid = co.conrelid
JOIN pg_namespace n ON n.oid = rel.relnamespace
WHERE n.nspname = 'public' AND rel.relname = 'exercise' AND co.contype = 'f' AND co.conname = 'exercise_section_id_fkey'
) THEN
ALTER TABLE public.exercise ADD CONSTRAINT exercise_section_id_fkey FOREIGN KEY ("section_id") REFERENCES public.course_section("id") ON DELETE no action ON UPDATE no action;
END IF;
END;
$add_exercise_section_fk$;--> statement-breakpoint
DO $add_lesson_course_section_fk$
BEGIN
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'lesson' AND schemaname = 'public')
AND NOT EXISTS (
SELECT 1 FROM pg_constraint co
JOIN pg_class rel ON rel.oid = co.conrelid
JOIN pg_namespace n ON n.oid = rel.relnamespace
WHERE n.nspname = 'public' AND rel.relname = 'lesson' AND co.contype = 'f' AND co.conname = 'public_course_section_id_fkey'
) THEN
ALTER TABLE public.lesson ADD CONSTRAINT public_course_section_id_fkey FOREIGN KEY ("section_id") REFERENCES public.course_section("id") ON DELETE cascade ON UPDATE cascade;
END IF;
END;
$add_lesson_course_section_fk$;--> statement-breakpoint
DO $add_course_section_course_fk$
BEGIN
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'course_section' AND schemaname = 'public')
AND NOT EXISTS (
SELECT 1 FROM pg_constraint co
JOIN pg_class rel ON rel.oid = co.conrelid
JOIN pg_namespace n ON n.oid = rel.relnamespace
WHERE n.nspname = 'public' AND rel.relname = 'course_section' AND co.contype = 'f' AND co.conname = 'public_course_section_course_id_fkey'
) THEN
ALTER TABLE public.course_section ADD CONSTRAINT public_course_section_course_id_fkey FOREIGN KEY ("course_id") REFERENCES public.course("id") ON DELETE cascade ON UPDATE cascade;
END IF;
END;
$add_course_section_course_fk$;--> statement-breakpoint
ALTER TABLE "public"."course" DROP COLUMN "version";--> statement-breakpoint
DROP POLICY IF EXISTS "Enable delete for users based on user_id" ON "public"."analytics_login_events" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Users can insert their own login events" ON "public"."analytics_login_events" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Delete only your own poll" ON "public"."apps_poll" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Update only your own" ON "public"."apps_poll" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be a course member to INSERT" ON "public"."apps_poll" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be course member to SELECT" ON "public"."apps_poll" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be a course member to INSERT" ON "public"."apps_poll_option" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be a course member to UPDATE" ON "public"."apps_poll_option" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be a teacher to DELETE" ON "public"."apps_poll_option" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be course member to SELECT" ON "public"."apps_poll_option" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Authenticated users can read" ON "public"."apps_poll_submission" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Delete your own submission" ON "public"."apps_poll_submission" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Enable insert for authenticated users only" ON "public"."apps_poll_submission" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Update your own submission" ON "public"."apps_poll_submission" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Authenticated users can SELECT" ON "public"."community_answer" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Delete your own answer" ON "public"."community_answer" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Enable insert for authenticated users only" ON "public"."community_answer" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Update your own answer" ON "public"."community_answer" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Authenticated users can SELECT" ON "public"."community_question" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Delete your own question" ON "public"."community_question" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Enable insert for authenticated users only" ON "public"."community_question" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Update your own question" ON "public"."community_question" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Enable access to all users if PUBLIC or to course members when " ON "public"."course" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be an org member to DELETE" ON "public"."course" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be an org member to INSERT" ON "public"."course" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be an org member to UPDATE" ON "public"."course" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Delete your own comment" ON "public"."course_newsfeed" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Update only your own" ON "public"."course_newsfeed" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be a course member to INSERT" ON "public"."course_newsfeed" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be a course member to SELECT" ON "public"."course_newsfeed" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Delete your own" ON "public"."course_newsfeed_comment" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Update only your own" ON "public"."course_newsfeed_comment" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be a course member to INSERT" ON "public"."course_newsfeed_comment" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be a course member to SELECT" ON "public"."course_newsfeed_comment" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Enable read access for all users" ON "public"."exercise" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be an org member to DELETE" ON "public"."exercise" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be an org member to INSERT" ON "public"."exercise" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be an org member to UPDATE" ON "public"."exercise" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Enable insert for authenticated users only" ON "public"."group" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Enable read access for all users" ON "public"."group" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Only org admins can delete" ON "public"."group" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Only org admins can update" ON "public"."group" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be a course member to INSERT" ON "public"."group_attendance" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be a course member to SELECT" ON "public"."group_attendance" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be a course member to UPDATE" ON "public"."group_attendance" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be an org member to DELETE" ON "public"."group_attendance" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Enable insert for authenticated users only" ON "public"."groupmember" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Enable read access for all users" ON "public"."groupmember" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be an org member to DELETE" ON "public"."groupmember" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be an org member to UPDATE" ON "public"."groupmember" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Enable read access for all users" ON "public"."lesson" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be an org member to DELETE" ON "public"."lesson" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be an org member to INSERT" ON "public"."lesson" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be an org member to UPDATE" ON "public"."lesson" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Delete only your own comment" ON "public"."lesson_comment" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Enable read access for all users" ON "public"."lesson_comment" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Update only your own" ON "public"."lesson_comment" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be in course group to INSERT" ON "public"."lesson_comment" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Enable read access for all users" ON "public"."lesson_completion" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be an course member or Admin to perform ALL operation" ON "public"."lesson_completion" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be an org member to DELETE" ON "public"."lesson_completion" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be an org member to UPDATE" ON "public"."lesson_completion" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Enable read access for all users" ON "public"."lesson_language" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be an org member to DELETE" ON "public"."lesson_language" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be an org member to INSERT" ON "public"."lesson_language" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be an org member to UPDATE" ON "public"."lesson_language" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Authenticated users can SELECT" ON "public"."lesson_language_history" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be an org member to DELETE" ON "public"."lesson_language_history" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be an org member to INSERT" ON "public"."lesson_language_history" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be an org member to UPDATE" ON "public"."lesson_language_history" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Enable read access for all users" ON "public"."course_section" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be an org member to DELETE" ON "public"."course_section" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be an org member to INSERT" ON "public"."course_section" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be an org member to UPDATE" ON "public"."course_section" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Allow authenticated users to SELECT" ON "public"."option" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be an org member to DELETE" ON "public"."option" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be an org member to INSERT" ON "public"."option" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be an org member to UPDATE" ON "public"."option" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Authenticated users can delete" ON "public"."organization" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Enable insert for authenticated users only" ON "public"."organization" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Enable read access for all users" ON "public"."organization" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be admin to UPDATE" ON "public"."organization" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Enable read access for all users" ON "public"."organization_contacts" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Enable read access for all users" ON "public"."organization_emaillist" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Enable read access for all users" ON "public"."organization_plan" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be an org member to DELETE" ON "public"."organization_plan" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be an org member to INSERT" ON "public"."organization_plan" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be an org member to UPDATE" ON "public"."organization_plan" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Allow authenticated users to read." ON "public"."organizationmember" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Enable insert for authenticated users only" ON "public"."organizationmember" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Only admin can delete" ON "public"."organizationmember" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Only admin can update" ON "public"."organizationmember" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Only user can update their account via email" ON "public"."organizationmember" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be an admin to INSERT or allow if no existing member" ON "public"."organizationmember" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Only auth users can read profile" ON "public"."profile" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Public profiles are viewable by everyone." ON "public"."profile" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User can only delete their profiles" ON "public"."profile" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Users can insert their own profile." ON "public"."profile" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Users can update own profile." ON "public"."profile" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Allow authenticated users to SELECT" ON "public"."question" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be an org member to DELETE" ON "public"."question" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be an org member to INSERT" ON "public"."question" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be an org member to UPDATE" ON "public"."question" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Only authenticated users can select." ON "public"."question_answer" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be an course member to DELETE" ON "public"."question_answer" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be an course member to INSERT" ON "public"."question_answer" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be an course member to UPDATE" ON "public"."question_answer" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Enable read access for all users" ON "public"."question_type" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Authenticated users can SELECT" ON "public"."quiz" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Authenticated users can SELECT" ON "public"."quiz_play" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Enable read access for all users" ON "public"."role" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Only authenticated users can SELECT" ON "public"."submission" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be a course member to DELETE" ON "public"."submission" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be a course member to INSERT" ON "public"."submission" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "User must be a course member to UPDATE" ON "public"."submission" CASCADE;--> statement-breakpoint
DROP POLICY IF EXISTS "Authenticated users can SELECT" ON "public"."submissionstatus" CASCADE;--> statement-breakpoint
DROP TYPE "public"."COURSE_VERSION";
--> statement-breakpoint
SELECT setval(pg_get_serial_sequence('group_attendance', 'id'), COALESCE((SELECT MAX(id) FROM group_attendance), 1), true);
SELECT setval(pg_get_serial_sequence('public.group_attendance', 'id'), COALESCE((SELECT MAX(id) FROM public.group_attendance), 1), true);
--> statement-breakpoint
SELECT setval(pg_get_serial_sequence('currency', 'id'), COALESCE((SELECT MAX(id) FROM currency), 1), true);
SELECT setval(pg_get_serial_sequence('public.currency', 'id'), COALESCE((SELECT MAX(id) FROM public.currency), 1), true);
--> statement-breakpoint
SELECT setval(pg_get_serial_sequence('apps_poll_option', 'id'), COALESCE((SELECT MAX(id) FROM apps_poll_option), 1), true);
SELECT setval(pg_get_serial_sequence('public.apps_poll_option', 'id'), COALESCE((SELECT MAX(id) FROM public.apps_poll_option), 1), true);
--> statement-breakpoint
SELECT setval(pg_get_serial_sequence('option', 'id'), COALESCE((SELECT MAX(id) FROM option), 1), true);
SELECT setval(pg_get_serial_sequence('public.option', 'id'), COALESCE((SELECT MAX(id) FROM public.option), 1), true);
--> statement-breakpoint
SELECT setval(pg_get_serial_sequence('quiz_play', 'id'), COALESCE((SELECT MAX(id) FROM quiz_play), 1), true);
SELECT setval(pg_get_serial_sequence('public.quiz_play', 'id'), COALESCE((SELECT MAX(id) FROM public.quiz_play), 1), true);
--> statement-breakpoint
SELECT setval(pg_get_serial_sequence('organization_contacts', 'id'), COALESCE((SELECT MAX(id) FROM organization_contacts), 1), true);
SELECT setval(pg_get_serial_sequence('public.organization_contacts', 'id'), COALESCE((SELECT MAX(id) FROM public.organization_contacts), 1), true);
--> statement-breakpoint
SELECT setval(pg_get_serial_sequence('lesson_comment', 'id'), COALESCE((SELECT MAX(id) FROM lesson_comment), 1), true);
SELECT setval(pg_get_serial_sequence('public.lesson_comment', 'id'), COALESCE((SELECT MAX(id) FROM public.lesson_comment), 1), true);
--> statement-breakpoint
SELECT setval(pg_get_serial_sequence('submissionstatus', 'id'), COALESCE((SELECT MAX(id) FROM submissionstatus), 1), true);
SELECT setval(pg_get_serial_sequence('public.submissionstatus', 'id'), COALESCE((SELECT MAX(id) FROM public.submissionstatus), 1), true);
--> statement-breakpoint
SELECT setval(pg_get_serial_sequence('video_transcripts', 'id'), COALESCE((SELECT MAX(id) FROM video_transcripts), 1), true);
SELECT setval(pg_get_serial_sequence('public.video_transcripts', 'id'), COALESCE((SELECT MAX(id) FROM public.video_transcripts), 1), true);
--> statement-breakpoint
SELECT setval(pg_get_serial_sequence('organization_emaillist', 'id'), COALESCE((SELECT MAX(id) FROM organization_emaillist), 1), true);
SELECT setval(pg_get_serial_sequence('public.organization_emaillist', 'id'), COALESCE((SELECT MAX(id) FROM public.organization_emaillist), 1), true);
--> statement-breakpoint
SELECT setval(pg_get_serial_sequence('apps_poll_submission', 'id'), COALESCE((SELECT MAX(id) FROM apps_poll_submission), 1), true);
SELECT setval(pg_get_serial_sequence('public.apps_poll_submission', 'id'), COALESCE((SELECT MAX(id) FROM public.apps_poll_submission), 1), true);
--> statement-breakpoint
SELECT setval(pg_get_serial_sequence('lesson_completion', 'id'), COALESCE((SELECT MAX(id) FROM lesson_completion), 1), true);
SELECT setval(pg_get_serial_sequence('public.lesson_completion', 'id'), COALESCE((SELECT MAX(id) FROM public.lesson_completion), 1), true);
--> statement-breakpoint
SELECT setval(pg_get_serial_sequence('community_question', 'id'), COALESCE((SELECT MAX(id) FROM community_question), 1), true);
SELECT setval(pg_get_serial_sequence('public.community_question', 'id'), COALESCE((SELECT MAX(id) FROM public.community_question), 1), true);
--> statement-breakpoint
SELECT setval(pg_get_serial_sequence('course_newsfeed_comment', 'id'), COALESCE((SELECT MAX(id) FROM course_newsfeed_comment), 1), true);
SELECT setval(pg_get_serial_sequence('public.course_newsfeed_comment', 'id'), COALESCE((SELECT MAX(id) FROM public.course_newsfeed_comment), 1), true);
--> statement-breakpoint
SELECT setval(pg_get_serial_sequence('organization_plan', 'id'), COALESCE((SELECT MAX(id) FROM organization_plan), 1), true);
SELECT setval(pg_get_serial_sequence('public.organization_plan', 'id'), COALESCE((SELECT MAX(id) FROM public.organization_plan), 1), true);
--> statement-breakpoint
SELECT setval(pg_get_serial_sequence('lesson_language', 'id'), COALESCE((SELECT MAX(id) FROM lesson_language), 1), true);
SELECT setval(pg_get_serial_sequence('public.lesson_language', 'id'), COALESCE((SELECT MAX(id) FROM public.lesson_language), 1), true);
--> statement-breakpoint
SELECT setval(pg_get_serial_sequence('lesson_language_history', 'id'), COALESCE((SELECT MAX(id) FROM lesson_language_history), 1), true);
SELECT setval(pg_get_serial_sequence('public.lesson_language_history', 'id'), COALESCE((SELECT MAX(id) FROM public.lesson_language_history), 1), true);
--> statement-breakpoint
SELECT setval(pg_get_serial_sequence('organizationmember', 'id'), COALESCE((SELECT MAX(id) FROM organizationmember), 1), true);
SELECT setval(pg_get_serial_sequence('public.organizationmember', 'id'), COALESCE((SELECT MAX(id) FROM public.organizationmember), 1), true);
--> statement-breakpoint
SELECT setval(pg_get_serial_sequence('question', 'id'), COALESCE((SELECT MAX(id) FROM question), 1), true);
SELECT setval(pg_get_serial_sequence('public.question', 'id'), COALESCE((SELECT MAX(id) FROM public.question), 1), true);
--> statement-breakpoint
SELECT setval(pg_get_serial_sequence('question_answer', 'id'), COALESCE((SELECT MAX(id) FROM question_answer), 1), true);
SELECT setval(pg_get_serial_sequence('public.question_answer', 'id'), COALESCE((SELECT MAX(id) FROM public.question_answer), 1), true);
--> statement-breakpoint
SELECT setval(pg_get_serial_sequence('question_type', 'id'), COALESCE((SELECT MAX(id) FROM question_type), 1), true);
SELECT setval(pg_get_serial_sequence('public.question_type', 'id'), COALESCE((SELECT MAX(id) FROM public.question_type), 1), true);
--> statement-breakpoint
SELECT setval(pg_get_serial_sequence('role', 'id'), COALESCE((SELECT MAX(id) FROM role), 1), true);
SELECT setval(pg_get_serial_sequence('public.role', 'id'), COALESCE((SELECT MAX(id) FROM public.role), 1), true);
--> statement-breakpoint
SELECT setval(pg_get_serial_sequence('waitinglist', 'id'), COALESCE((SELECT MAX(id) FROM waitinglist), 1), true);
SELECT setval(pg_get_serial_sequence('public.waitinglist', 'id'), COALESCE((SELECT MAX(id) FROM public.waitinglist), 1), true);
--> statement-breakpoint
SELECT setval(pg_get_serial_sequence('test_tenant', 'id'), COALESCE((SELECT MAX(id) FROM test_tenant), 1), true);
SELECT setval(pg_get_serial_sequence('public.test_tenant', 'id'), COALESCE((SELECT MAX(id) FROM public.test_tenant), 1), true);
--> statement-breakpoint
SELECT setval(pg_get_serial_sequence('exercise_template', 'id'), COALESCE((SELECT MAX(id) FROM exercise_template), 1), true);
SELECT setval(pg_get_serial_sequence('public.exercise_template', 'id'), COALESCE((SELECT MAX(id) FROM public.exercise_template), 1), true);
+16 -29
View File
@@ -272,27 +272,18 @@ export const analyticsCountryDaily = pgTable(
]
);
export const courseSection = pgTable(
'course_section',
{
id: uuid().defaultRandom().primaryKey().notNull(),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }).defaultNow(),
title: varchar(),
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
order: bigint({ mode: 'number' }).default(sql`'0'`),
courseId: uuid('course_id')
},
(table) => [
foreignKey({
columns: [table.courseId],
foreignColumns: [course.id],
name: 'public_course_section_course_id_fkey'
})
.onUpdate('cascade')
.onDelete('cascade')
]
);
export const courseSection = pgTable('course_section', {
id: uuid().defaultRandom().primaryKey().notNull(),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }).defaultNow(),
title: varchar(),
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
order: bigint({ mode: 'number' }).default(sql`'0'`),
courseId: uuid('course_id').references(() => course.id, {
onDelete: 'cascade',
onUpdate: 'cascade'
})
});
export const group = pgTable(
'group',
@@ -1001,7 +992,10 @@ export const lesson = pgTable(
assetId?: string;
}[]
>(),
sectionId: uuid('section_id'),
sectionId: uuid('section_id').references(() => courseSection.id, {
onDelete: 'cascade',
onUpdate: 'cascade'
}),
slug: varchar()
},
(table) => [
@@ -1015,13 +1009,6 @@ export const lesson = pgTable(
foreignColumns: [profile.id],
name: 'lesson_teacher_id_fkey'
}),
foreignKey({
columns: [table.sectionId],
foreignColumns: [courseSection.id],
name: 'public_course_section_id_fkey'
})
.onUpdate('cascade')
.onDelete('cascade'),
index('idx_lesson_course_slug').on(table.courseId, table.slug)
]
);
@@ -1,203 +1,98 @@
import { eq, isNotNull } from 'drizzle-orm';
import { and, eq, inArray, isNotNull, or, sql } from 'drizzle-orm';
import { exercise, lesson } from '../schema';
import { db } from '../drizzle';
import { sql } from 'drizzle-orm';
import { mkdirSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const backupDir = path.resolve(scriptDir, '../../backups');
const orderBatchSize = Number.parseInt(process.env.EXERCISE_BACKFILL_BATCH_SIZE ?? '200', 10) || 200;
/** Optional course scope: `pnpm db:exercise-backfill [courseId]` */
const scopedCourseId = process.argv[2]?.trim() || null;
type LessonRow = {
id: string;
courseId: string;
sectionId: string | null;
order: number | null;
createdAt: string | null;
};
type ExerciseRow = {
id: string;
lessonId: string | null;
courseId: string | null;
sectionId: string | null;
order: number | null;
createdAt: string | null;
};
type OrderUpdate = { id: string; order: number };
type FailedOrderBatch = {
table: 'lesson' | 'exercise';
batchIndex: number;
rowCount: number;
ids: string[];
error: string;
};
type OrderBackfillReport = {
startedAt: string;
completedAt: string;
courseId: string | null;
batchSize: number;
summary: {
lessonsAttempted: number;
lessonsUpdated: number;
lessonsFailed: number;
exercisesAttempted: number;
exercisesUpdated: number;
exercisesFailed: number;
};
failures: FailedOrderBatch[];
reportPath: string;
};
/**
* Migrates lesson-attached exercises from the V1 model to the V2 linear content model.
*
* **V1:** exercises nested under a lesson (`exercise.lesson_id`); lesson `order` was the
* only sequence that mattered for navigation.
*
* **V2:** lessons and exercises share one `order` column per course (and per section when
* `section_id` is set). The UI sorts both tables by that single sequence.
*
* Runs two phases in order:
* 1. {@link setExerciseCourseAndSectionId} — copy `course_id` / `section_id` from the parent lesson.
* 2. {@link assignLinearContentOrder} — renumber `lesson.order` and `exercise.order` in one shared sequence.
*
* @example
* // Single course (dry-run scope while validating):
* pnpm --filter @cio/db db:exercise-backfill 73432009-4c40-457d-9e90-6b48b18b0e23
*
* @example
* // All courses:
* pnpm --filter @cio/db db:exercise-backfill
*/
async function backfillExerciseCourse() {
try {
const result = await setExerciseCourseAndSectionId();
const courseSectionResult = await setExerciseCourseAndSectionId(scopedCourseId);
const orderResult = await assignLinearContentOrder(scopedCourseId);
// TypeScript implementation of exercise ordering logic
// Algorithm:
// 1. Fetch each lesson (grouped by course_id, section_id, ordered by lesson order or created_at)
// 2. For each lesson, fetch its related exercises (ordered by createdAt)
// 3. Create a linear sequence: lesson, then its exercises, then next lesson, etc.
// 4. Assign sequential order numbers
// 5. Update exercises with their new order values
console.log('Exercise course/section backfill completed', courseSectionResult);
console.log('Linear content order backfill completed', orderResult.summary);
console.log('Report written to', orderResult.reportPath);
// Step 1: Fetch all lessons with their order (or calculate it from created_at)
const allLessons = await db
.select({
id: lesson.id,
courseId: lesson.courseId,
sectionId: lesson.sectionId,
order: lesson.order,
createdAt: lesson.createdAt
})
.from(lesson);
// Group lessons by course_id and section_id
type LessonWithOrder = (typeof allLessons)[0] & { calculatedOrder: number };
const lessonsByGroup = new Map<string, LessonWithOrder[]>();
for (const lessonItem of allLessons) {
const groupKey = `${lessonItem.courseId || 'null'}-${lessonItem.sectionId || 'null'}`;
if (!lessonsByGroup.has(groupKey)) {
lessonsByGroup.set(groupKey, []);
}
// Add calculatedOrder property when pushing
lessonsByGroup.get(groupKey)!.push({
...lessonItem,
calculatedOrder: lessonItem.order ?? 0 // Temporary, will be recalculated
});
if (orderResult.failures.length > 0) {
console.error(`${orderResult.failures.length} batch(es) failed — see report for ids and errors.`);
process.exit(1);
}
// Calculate order for lessons that don't have one, within each group
for (const [groupKey, groupLessons] of lessonsByGroup.entries()) {
// Sort by existing order (nulls last), then by created_at
groupLessons.sort((a, b) => {
if (a.order !== null && b.order !== null) {
return a.order - b.order;
}
if (a.order !== null) return -1;
if (b.order !== null) return 1;
return new Date(a.createdAt || '').getTime() - new Date(b.createdAt || '').getTime();
});
// Assign calculated order
groupLessons.forEach((lessonItem, index) => {
lessonItem.calculatedOrder = lessonItem.order ?? index + 1;
});
}
// Step 2: Fetch all exercises that have a lesson_id
const allExercises = await db
.select({
id: exercise.id,
lessonId: exercise.lessonId,
courseId: exercise.courseId,
sectionId: exercise.sectionId,
createdAt: exercise.createdAt
})
.from(exercise)
.where(isNotNull(exercise.lessonId));
// Group exercises by lesson_id
const exercisesByLesson = new Map<string, typeof allExercises>();
for (const exerciseItem of allExercises) {
if (!exerciseItem.lessonId) continue;
if (!exercisesByLesson.has(exerciseItem.lessonId)) {
exercisesByLesson.set(exerciseItem.lessonId, []);
}
exercisesByLesson.get(exerciseItem.lessonId)!.push(exerciseItem);
}
// Sort exercises within each lesson by createdAt
for (const exercises of exercisesByLesson.values()) {
exercises.sort((a, b) => {
return new Date(a.createdAt || '').getTime() - new Date(b.createdAt || '').getTime();
});
}
// Step 3: Create linear sequence - lesson, then its exercises, then next lesson, etc.
interface ContentItem {
type: 'lesson' | 'exercise';
lessonId: string | null;
exerciseId: string | null;
courseId: string;
sectionId: string | null;
lessonOrder: number;
sortOffset: number;
}
const allItems: ContentItem[] = [];
// Process each group (course_id + section_id combination)
for (const [groupKey, groupLessons] of lessonsByGroup.entries()) {
// Sort lessons by their calculated order
const sortedLessons = [...groupLessons].sort((a, b) => a.calculatedOrder - b.calculatedOrder);
for (const lessonItem of sortedLessons) {
// Add the lesson itself
allItems.push({
type: 'lesson',
lessonId: lessonItem.id,
exerciseId: null,
courseId: lessonItem.courseId,
sectionId: lessonItem.sectionId,
lessonOrder: lessonItem.calculatedOrder,
sortOffset: 0 // Lessons always have offset 0
});
// Add exercises for this lesson (sorted by createdAt)
const lessonExercises = exercisesByLesson.get(lessonItem.id) || [];
for (let i = 0; i < lessonExercises.length; i++) {
const exerciseItem = lessonExercises[i];
allItems.push({
type: 'exercise',
lessonId: exerciseItem.lessonId,
exerciseId: exerciseItem.id,
courseId: exerciseItem.courseId || lessonItem.courseId,
sectionId: exerciseItem.sectionId || lessonItem.sectionId,
lessonOrder: lessonItem.calculatedOrder,
sortOffset: i + 1 // Exercises have offset 1, 2, 3, etc.
});
}
}
}
// Step 4: Sort all items by lesson_order, then sort_offset (within each course_id + section_id group)
allItems.sort((a, b) => {
const groupKeyA = `${a.courseId}-${a.sectionId || 'null'}`;
const groupKeyB = `${b.courseId}-${b.sectionId || 'null'}`;
if (groupKeyA !== groupKeyB) {
return groupKeyA.localeCompare(groupKeyB);
}
if (a.lessonOrder !== b.lessonOrder) {
return a.lessonOrder - b.lessonOrder;
}
return a.sortOffset - b.sortOffset;
});
// Step 5: Assign sequential order numbers within each group and update exercises
const orderUpdates: Array<{ exerciseId: string; order: number }> = [];
let currentOrder = 1;
let lastGroupKey = '';
for (const item of allItems) {
const groupKey = `${item.courseId}-${item.sectionId || 'null'}`;
// Reset order counter when we move to a new group
if (groupKey !== lastGroupKey) {
currentOrder = 1;
lastGroupKey = groupKey;
}
// Only update exercises (not lessons)
if (item.type === 'exercise' && item.exerciseId) {
orderUpdates.push({
exerciseId: item.exerciseId,
order: currentOrder
});
}
currentOrder++;
}
// Batch update exercises
let exerciseOrderResult;
if (orderUpdates.length > 0) {
// Use a transaction to update all exercises
exerciseOrderResult = await db.transaction(async (tx) => {
const updatePromises = orderUpdates.map((update) =>
tx.update(exercise).set({ order: update.order }).where(eq(exercise.id, update.exerciseId))
);
await Promise.all(updatePromises);
return { rowCount: orderUpdates.length };
});
} else {
exerciseOrderResult = { rowCount: 0 };
}
console.log('Exercise backfill completed', result);
console.log('Exercise order backfill completed', exerciseOrderResult);
process.exit(0);
} catch (error) {
console.error('Failed to backfill exercise course data', error);
@@ -205,7 +100,154 @@ async function backfillExerciseCourse() {
}
}
async function setExerciseCourseAndSectionId() {
/**
* Assigns a single 1-based `order` sequence to lessons and their child exercises.
*
* **Grouping** — Items are processed independently per `(course_id, section_id)` bucket.
* Unsectioned content uses `section_id = null`. Section order is not changed here.
*
* **Lesson ordering** — Within each bucket, lessons are sorted by existing `order`
* (ascending), with `null` orders last; ties break on `created_at`.
*
* **Exercise ordering** — Exercises with a `lesson_id` are grouped under that lesson.
* Within a lesson, exercises sort by `order` then `created_at` (same comparator as lessons).
*
* **Linear sequence** — For each lesson in sorted order, emit one slot for the lesson,
* then one slot per child exercise (in exercise sort order). Assign `order = 1..n` across
* that combined list and persist both tables in batched `CASE` updates
* (`EXERCISE_BACKFILL_BATCH_SIZE`, default 200). Failed batches are logged and skipped;
* the run continues until all batches are attempted. See `packages/db/backups/exercise-backfill-report-*.json`.
*
* Example (one section bucket):
*
* ```
* Before (lesson order only): After (shared order):
* L1 order=1 L1 order=1
* └ Ea (nested) Ea order=2
* └ Eb Eb order=3
* L2 order=2 L2 order=4
* └ Ec Ec order=5
* ```
*
* Only exercises that still have `lesson_id` are included. Course-level exercises
* (`lesson_id IS NULL`) are left unchanged.
*
* @param courseId — When set, only lessons/exercises for this course are loaded and updated.
*/
async function assignLinearContentOrder(courseId: string | null) {
const lessonWhere = courseId ? eq(lesson.courseId, courseId) : undefined;
const allLessons = await db
.select({
id: lesson.id,
courseId: lesson.courseId,
sectionId: lesson.sectionId,
order: lesson.order,
createdAt: lesson.createdAt
})
.from(lesson)
.where(lessonWhere);
const allExercises = await db
.select({
id: exercise.id,
lessonId: exercise.lessonId,
courseId: exercise.courseId,
sectionId: exercise.sectionId,
order: exercise.order,
createdAt: exercise.createdAt
})
.from(exercise)
.leftJoin(lesson, eq(exercise.lessonId, lesson.id))
.where(
courseId
? and(or(eq(exercise.courseId, courseId), eq(lesson.courseId, courseId)), isNotNull(exercise.lessonId))
: isNotNull(exercise.lessonId)
);
const lessonsByGroup = new Map<string, LessonRow[]>();
for (const lessonRow of allLessons) {
const key = groupKey(lessonRow.courseId, lessonRow.sectionId);
const group = lessonsByGroup.get(key) ?? [];
group.push(lessonRow);
lessonsByGroup.set(key, group);
}
const exercisesByLesson = new Map<string, ExerciseRow[]>();
for (const exerciseRow of allExercises) {
if (!exerciseRow.lessonId) {
continue;
}
const lessonExercises = exercisesByLesson.get(exerciseRow.lessonId) ?? [];
lessonExercises.push(exerciseRow);
exercisesByLesson.set(exerciseRow.lessonId, lessonExercises);
}
for (const lessonExercises of exercisesByLesson.values()) {
sortExercises(lessonExercises);
}
const lessonOrderUpdates: Array<{ id: string; order: number }> = [];
const exerciseOrderUpdates: Array<{ id: string; order: number }> = [];
for (const [, groupLessons] of lessonsByGroup) {
const sortedLessons = sortLessons(groupLessons);
let nextOrder = 1;
for (const lessonRow of sortedLessons) {
lessonOrderUpdates.push({ id: lessonRow.id, order: nextOrder });
nextOrder += 1;
const lessonExercises = exercisesByLesson.get(lessonRow.id) ?? [];
for (const exerciseRow of lessonExercises) {
exerciseOrderUpdates.push({ id: exerciseRow.id, order: nextOrder });
nextOrder += 1;
}
}
}
const startedAt = new Date().toISOString();
const failures: FailedOrderBatch[] = [];
const lessonsUpdated = await applyBatchedOrderUpdates('lesson', lessonOrderUpdates, failures);
const exercisesUpdated = await applyBatchedOrderUpdates('exercise', exerciseOrderUpdates, failures);
const report = buildOrderBackfillReport({
startedAt,
courseId,
lessonOrderUpdates,
exerciseOrderUpdates,
lessonsUpdated,
exercisesUpdated,
failures
});
mkdirSync(backupDir, { recursive: true });
writeFileSync(report.reportPath, JSON.stringify(report, null, 2));
return report;
}
/**
* Denormalizes `course_id` and `section_id` onto exercises from their parent lesson.
*
* Updates rows where `lesson_id` is set and either `course_id` or `section_id` is still null,
* so later queries can filter exercises by course without joining `lesson`.
*/
async function setExerciseCourseAndSectionId(courseId: string | null) {
if (courseId) {
return db.execute(sql`
UPDATE exercise AS e
SET course_id = l.course_id,
section_id = l.section_id
FROM lesson AS l
WHERE e.lesson_id = l.id
AND l.course_id = ${courseId}
AND (e.course_id IS NULL OR e.section_id IS NULL)
`);
}
return db.execute(sql`
UPDATE exercise AS e
SET course_id = l.course_id,
@@ -216,4 +258,131 @@ async function setExerciseCourseAndSectionId() {
`);
}
async function applyBatchedOrderUpdates(
table: 'lesson' | 'exercise',
updates: OrderUpdate[],
failures: FailedOrderBatch[]
) {
if (updates.length === 0) {
return 0;
}
let updatedCount = 0;
const totalBatches = Math.ceil(updates.length / orderBatchSize);
for (let batchIndex = 0; batchIndex < totalBatches; batchIndex += 1) {
const batchStart = batchIndex * orderBatchSize;
const batch = updates.slice(batchStart, batchStart + orderBatchSize);
const batchIds = batch.map((update) => update.id);
try {
if (table === 'lesson') {
await db
.update(lesson)
.set({
order: sql`case ${lesson.id} ${sql.join(
batch.map((update) => sql`when ${update.id} then ${update.order}`),
sql` `
)} else ${lesson.order} end`
})
.where(inArray(lesson.id, batchIds));
} else {
await db
.update(exercise)
.set({
order: sql`case ${exercise.id} ${sql.join(
batch.map((update) => sql`when ${update.id} then ${update.order}`),
sql` `
)} else ${exercise.order} end`
})
.where(inArray(exercise.id, batchIds));
}
updatedCount += batch.length;
console.log(`Updated ${table} batch ${batchIndex + 1}/${totalBatches} (${batch.length} rows)`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
failures.push({
table,
batchIndex,
rowCount: batch.length,
ids: batchIds,
error: message
});
console.error(`Failed ${table} batch ${batchIndex + 1}/${totalBatches} (${batch.length} rows):`, message);
}
}
return updatedCount;
}
function buildOrderBackfillReport(input: {
startedAt: string;
courseId: string | null;
lessonOrderUpdates: OrderUpdate[];
exerciseOrderUpdates: OrderUpdate[];
lessonsUpdated: number;
exercisesUpdated: number;
failures: FailedOrderBatch[];
}): OrderBackfillReport {
const stamp = input.startedAt.replace(/[:.]/g, '-');
const reportPath = path.join(backupDir, `exercise-backfill-report-${stamp}.json`);
const lessonsAttempted = input.lessonOrderUpdates.length;
const exercisesAttempted = input.exerciseOrderUpdates.length;
const lessonsFailed = input.failures
.filter((failure) => failure.table === 'lesson')
.reduce((count, failure) => count + failure.rowCount, 0);
const exercisesFailed = input.failures
.filter((failure) => failure.table === 'exercise')
.reduce((count, failure) => count + failure.rowCount, 0);
return {
startedAt: input.startedAt,
completedAt: new Date().toISOString(),
courseId: input.courseId,
batchSize: orderBatchSize,
summary: {
lessonsAttempted,
lessonsUpdated: input.lessonsUpdated,
lessonsFailed,
exercisesAttempted,
exercisesUpdated: input.exercisesUpdated,
exercisesFailed
},
failures: input.failures,
reportPath
};
}
function groupKey(courseId: string, sectionId: string | null) {
return `${courseId}-${sectionId ?? 'null'}`;
}
function compareByOrderThenCreatedAt<T extends { order: number | null; createdAt: string | null }>(left: T, right: T) {
if (left.order !== null && right.order !== null) {
return left.order - right.order;
}
if (left.order !== null) {
return -1;
}
if (right.order !== null) {
return 1;
}
return new Date(left.createdAt || '').getTime() - new Date(right.createdAt || '').getTime();
}
function sortLessons(lessons: LessonRow[]) {
return [...lessons].sort(compareByOrderThenCreatedAt);
}
function sortExercises(exercises: ExerciseRow[]) {
return [...exercises].sort(compareByOrderThenCreatedAt);
}
backfillExerciseCourse();
@@ -0,0 +1,348 @@
/**
* Reads **`course`** table data from a **legacy** Postgres dump (before `course.version` was dropped)
* and disables content grouping for former **V1** courses on the live DB (`DATABASE_URL`).
*
* Legacy model (see commented `0000_stale_storm.sql` and `DROP COLUMN "version"` in
* `0003_fancy_harry_osborn.sql`):
* - Column **`version`** had type **`"COURSE_VERSION"`** enum with values **`V1`** and **`V2`**.
*
* App stores grouping in **`course.metadata` → `isContentGroupingEnabled`**. This script sets
* **`metadata.isContentGroupingEnabled = false`** for every course id whose dump row has **`version = V1`**.
* Rows with **`V2`** or other values are unchanged.
*
* **Your dump must still contain the `version` column** (pg_dump from before that migration, or a
* full schema+data backup from that era). Dumps taken after `version` was dropped cannot be used—
* the script exits with a clear error if `COPY ... course (...)` has no `version` column.
*
* **Plain SQL** (`.sql`): finds `COPY public.course` / `COPY course` with column list, parses tab rows.
* **Custom** (`.dump`): `pg_restore --data-only -t course`.
*
* Limitation: COPY rows are split on **tab** boundaries. If a text/json column contains a literal tab,
* parsing may mis-align; use a plain-format dump from pg_dump defaults (usually fine).
*
* Usage:
* DATABASE_URL="..." pnpm exec tsx src/scripts/disable-v1-content-grouping-from-dump.ts path/to/backup.sql
* pnpm exec tsx src/scripts/disable-v1-content-grouping-from-dump.ts --dry-run path/to/backup.dump
*
* Env: `DATABASE_URL`, `PG_RESTORE_BIN`, `PSQL_BIN` (same as other restore scripts).
*/
import 'dotenv/config';
import { spawnSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const backupDir = path.resolve(scriptDir, '../../backups');
const args = process.argv.slice(2);
const dryRun = args.includes('--dry-run');
const dumpPathRaw = args.find((argument) => !argument.startsWith('-'));
const databaseUrl = process.env.DATABASE_URL;
const pgRestoreExecutable = process.env.PG_RESTORE_BIN ?? 'pg_restore';
const psqlExecutable = process.env.PSQL_BIN ?? 'psql';
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
function slicePlainCopyCourse(fileText: string) {
const copyPublic = /\bCOPY\s+public\.course\b/i.exec(fileText);
const copyBare = /\bCOPY\s+course\b/i.exec(fileText);
const matchIndex = copyPublic?.index ?? copyBare?.index;
if (matchIndex === undefined) {
return null;
}
const fromStdinIdx = fileText.indexOf('FROM stdin', matchIndex);
if (fromStdinIdx === -1) {
return null;
}
let lineEnd = fileText.indexOf('\n', fromStdinIdx);
if (lineEnd === -1) {
return null;
}
const terminator = /\n\\\.\s*(?:\n|\r?\n|$)/;
const dataRegion = fileText.slice(lineEnd);
const terminatorMatch = terminator.exec(dataRegion);
if (!terminatorMatch) {
return null;
}
const terminatorEndAbs = lineEnd + terminatorMatch.index + terminatorMatch[0].length;
return fileText.slice(matchIndex, terminatorEndAbs);
}
function pgRestoreCourseExtract(dumpFilePath: string) {
const tempDir = mkdtempSync(path.join(tmpdir(), 'v1-course-copy-'));
const extractedPath = path.join(tempDir, 'course.sql');
try {
const flags = spawnSync(
pgRestoreExecutable,
[
'--data-only',
'--no-owner',
'--no-privileges',
'-n',
'public',
'-t',
'course',
'-f',
extractedPath,
dumpFilePath
],
{ encoding: 'utf8' }
);
if (flags.status !== 0) {
console.error(pgRestoreExecutable, '--data-only extraction failed:', (flags.stderr ?? '').trim() || flags.status);
return null;
}
return readFileSync(extractedPath, 'utf8');
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
}
/** Parse `COPY ... course ( a, b, "c" ) FROM stdin;` */
function parseCopyColumnList(copyBlock: string): string[] | null {
const lineEnd = copyBlock.indexOf('\n');
const firstLine = lineEnd === -1 ? copyBlock : copyBlock.slice(0, lineEnd);
const withCols = /COPY\s+(?:public\.)?course\s*\(\s*([^)]+)\s*\)\s*FROM\s+stdin\s*;?\s*$/im.exec(firstLine.trim());
if (!withCols) {
return null;
}
const raw = withCols[1];
const columns: string[] = [];
let current = '';
let inQuote = false;
for (let index = 0; index < raw.length; index++) {
const character = raw[index];
if (character === '"') {
inQuote = !inQuote;
current += character;
continue;
}
if (character === ',' && !inQuote) {
columns.push(stripIdent(current.trim()));
current = '';
continue;
}
current += character;
}
if (current.trim()) {
columns.push(stripIdent(current.trim()));
}
return columns;
}
function stripIdent(identifier: string) {
return identifier.replace(/^"|"$/g, '');
}
function extractV1CourseIdsFromCopyBlock(copyBlock: string): string[] | null {
const columns = parseCopyColumnList(copyBlock);
if (!columns?.length) {
console.error(
'Could not parse COPY column list for course. Use a dump where pg_dump included explicit columns, e.g. `COPY public.course (id, …, version, …) FROM stdin;`'
);
return null;
}
const idIndex = columns.findIndex((columnName) => columnName === 'id');
const versionIndex = columns.findIndex((columnName) => columnName === 'version');
if (idIndex === -1) {
console.error('COPY column list for course has no `id` column.');
return null;
}
if (versionIndex === -1) {
console.error(
'COPY column list for course has no `version` column. This script needs a **legacy** dump from before `course.version` was dropped (migration 0003).'
);
return null;
}
const newlineAfterHeader = copyBlock.indexOf('\n');
const dataPart = newlineAfterHeader === -1 ? '' : copyBlock.slice(newlineAfterHeader + 1);
const lines = dataPart.split(/\r?\n/);
const v1Ids = new Set<string>();
for (const line of lines) {
if (line === '\\.') {
break;
}
if (line === '') {
continue;
}
/** Postgres COPY text format: tab-separated; `\N` is null */
const fields = line.split('\t');
if (fields.length < Math.max(idIndex, versionIndex) + 1) {
continue;
}
const rawId = fields[idIndex]?.trim() ?? '';
const rawVersion = fields[versionIndex]?.trim() ?? '';
if (rawVersion === '\\N' || rawVersion === '') {
continue;
}
const versionNormalized = rawVersion.replace(/^"|"$/g, '').toUpperCase();
if (versionNormalized !== 'V1') {
continue;
}
if (!UUID_RE.test(rawId)) {
continue;
}
v1Ids.add(rawId);
}
return [...v1Ids];
}
function buildUpdateSql(courseIds: string[]) {
const list = courseIds.map((courseId) => `'${courseId.replace(/'/g, "''")}'`).join(',\n ');
return `-- Former COURSE_VERSION = V1 → metadata.isContentGroupingEnabled = false
BEGIN;
UPDATE public.course
SET metadata = jsonb_set(
COALESCE(metadata, '{}'::jsonb),
'{isContentGroupingEnabled}',
'false'::jsonb,
true
)
WHERE id IN (
${list}
);
COMMIT;
`;
}
function printUsage() {
console.error(`Usage:
DATABASE_URL="postgres://..." pnpm exec tsx src/scripts/disable-v1-content-grouping-from-dump.ts <dump.dump|.sql>
Flags:
--dry-run Write SQL to packages/db/backups/disable-v1-content-grouping-preview.sql only`);
process.exitCode = 1;
}
function main() {
if (!dumpPathRaw) {
console.error('Missing dump file path.');
printUsage();
return;
}
if (!dryRun && !databaseUrl) {
console.error('Set DATABASE_URL (or use --dry-run).');
printUsage();
return;
}
const resolvedDump = path.isAbsolute(dumpPathRaw) ? dumpPathRaw : path.resolve(process.cwd(), dumpPathRaw);
let copyBlock: string | null;
if (resolvedDump.endsWith('.sql')) {
const text = readFileSync(resolvedDump, 'utf8');
copyBlock = slicePlainCopyCourse(text);
} else {
const extracted = pgRestoreCourseExtract(resolvedDump);
copyBlock = extracted ? slicePlainCopyCourse(extracted) : null;
}
if (!copyBlock) {
console.error('Could not find COPY block for public.course. Try: pg_restore -l your.dump | grep course');
process.exitCode = 1;
return;
}
const v1Ids = extractV1CourseIdsFromCopyBlock(copyBlock);
if (v1Ids === null) {
process.exitCode = 1;
return;
}
if (v1Ids.length === 0) {
console.log('No courses with version V1 found in dump (or no valid rows). Nothing to update.');
return;
}
console.log(`Found ${v1Ids.length} course id(s) with legacy version V1 in dump.`);
const sql = buildUpdateSql(v1Ids);
mkdirSync(backupDir, { recursive: true });
const previewFile = path.join(backupDir, 'disable-v1-content-grouping-preview.sql');
writeFileSync(previewFile, sql, 'utf8');
console.log('Wrote SQL:', previewFile);
if (dryRun) {
console.log('Dry run: not executing psql.');
return;
}
const invoke = spawnSync(psqlExecutable, [databaseUrl!, '-v', 'ON_ERROR_STOP=1', '-f', previewFile], {
stdio: 'inherit'
});
if (invoke.error) {
console.error('psql spawn failed:', invoke.error.message);
process.exitCode = 1;
return;
}
if (invoke.status !== 0) {
process.exitCode = invoke.status ?? 1;
return;
}
console.log('Done: metadata.isContentGroupingEnabled set to false for V1 courses (where ids exist).');
}
main();
+86
View File
@@ -0,0 +1,86 @@
/**
* Full logical backup of the database into `packages/db/backups/` using `pg_dump`.
*
* Requires PostgreSQL client tools (`pg_dump`) on PATH unless `PG_DUMP_BIN` is set.
*
* **Server/client major version:** `pg_dump` must match the server's major version or be newer (e.g.
* PostgreSQL **15** server needs `pg_dump` **15+**, not 14). Postgres.app 14 ships `pg_dump` 14 —
* upgrade Postgres.app, install `postgresql@15` / `libpq` from Homebrew, or set `PG_DUMP_BIN`.
*
* Env:
* - `DATABASE_URL` — connection string (default source).
* - `PGDUMP_URL` — overrides `DATABASE_URL` (use Supabase **direct** Postgres `5432` if pooler rejects dump).
* - `PG_DUMP_BIN` — path to `pg_dump` executable (defaults to `pg_dump` on `PATH`).
* - `PG_DUMP_FORMAT=plain` — SQL text `.sql`; default is custom `-Fc` `.dump` (`pg_restore` compatible).
*/
import 'dotenv/config';
import { spawnSync } from 'node:child_process';
import { mkdirSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const connectionUrl = process.env.PGDUMP_URL ?? process.env.DATABASE_URL;
if (!connectionUrl) {
console.error('Set DATABASE_URL (or PGDUMP_URL) to run a backup.');
process.exitCode = 1;
}
const usePlainSql = process.env.PG_DUMP_FORMAT === 'plain';
const verbose = process.env.PG_DUMP_VERBOSE !== '0';
const pgDumpExecutable = process.env.PG_DUMP_BIN ?? 'pg_dump';
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const backupDir = path.resolve(scriptDir, '../../backups');
function versionMismatchHint() {
return [
'',
'If the error mentions server/client version mismatch, use a newer pg_dump (major >= server), e.g.:',
' brew install postgresql@15',
` PG_DUMP_BIN=$(brew --prefix postgresql@15)/bin/pg_dump pnpm --filter @cio/db db:dump-backup`,
'(Or upgrade Postgres.app to a bundle that ships PostgreSQL 15+ client tools.)',
''
].join('\n');
}
function main() {
if (!connectionUrl) {
return;
}
mkdirSync(backupDir, { recursive: true });
const outfile = path.join(backupDir, usePlainSql ? `full-${stamp}.sql` : `full-${stamp}.dump`);
const args = usePlainSql
? [...(verbose ? ['-v'] : []), '--dbname', connectionUrl, '-F', 'p', '-f', outfile]
: [...(verbose ? ['-v'] : []), '--dbname', connectionUrl, '-F', 'c', '-Z', '9', '--file', outfile];
console.log('Using:', pgDumpExecutable);
console.log('pg_dump →', outfile);
const result = spawnSync(pgDumpExecutable, args, { stdio: 'inherit' });
if (result.error) {
console.error('pg_dump spawn failed:', result.error.message);
console.error(versionMismatchHint());
process.exitCode = 1;
return;
}
if (result.status !== 0) {
process.exitCode = result.status ?? 1;
console.error(versionMismatchHint());
return;
}
console.log('Backup finished:', outfile);
}
main();
@@ -0,0 +1,431 @@
/**
* Pushes the **`public` schema DDL** from a `pg_dump` backup into a fresh Postgres database
* (e.g. [PlanetScale Postgres](https://planetscale.com/docs/postgres/imports/postgres-migrate-dumprestore)).
* Does **not** load table data (`COPY` / `--data-only` rows).
*
* **Custom format** (`.dump` / `-Fc`, default from `db:dump-backup`): `pg_restore --schema-only -n public`,
* with `--no-publications` / `--no-subscriptions` (Supabase `supabase_realtime` is not on PlanetScale).
*
* **Plain SQL** (`.sql`): runs the file with `psql` when it looks like a schema-only dump (no `COPY public.`
* data blocks). Full plain dumps with data are rejected — re-dump with `-Fc` or `pg_dump --schema-only`.
*
* Before push, creates `authenticated` / `anon` roles and a minimal `extensions` schema
* (`moddatetime`, `gen_random_uuid`) so Supabase-sourced dumps restore on PlanetScale.
*
* @example
* DATABASE_URL="postgres://[email protected]/..." \
* pnpm --filter @cio/db db:push-dump-to-new-db -- packages/db/backups/full-….dump
*
* @example
* pnpm --filter @cio/db db:push-dump-to-new-db -- --dry-run path/to/backup.dump
*
* Env:
* - `DATABASE_URL` — target DB (required unless `--dry-run`).
* - `PG_RESTORE_BIN` — default `pg_restore`.
* - `PSQL_BIN` — default `psql`.
* - `PG_RESTORE_JOBS` — parallel workers for custom-format restore (e.g. `4`).
* - `PUSH_DUMP_SSLMODE` — override `sslmode` for `pg_restore` / `psql` only (e.g. `require`).
* - `PGSSLROOTCERT` — path to CA bundle for libpq (alternative to `sslrootcert=system` in the URL).
*
* PlanetScale URLs often use `sslmode=verify-full`. Node `postgres` handles that; libpq often does not
* unless `PGSSLROOTCERT` points at a CA file. This script downgrades to `sslmode=require` for
* `pg_restore` / `psql` when verify modes are set without a cert path (encrypted, no CA verify).
*/
import 'dotenv/config';
import { spawnSync } from 'node:child_process';
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import postgres from 'postgres';
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const backupDir = path.resolve(scriptDir, '../../backups');
const args = process.argv.slice(2);
const dryRun = args.includes('--dry-run');
const force = args.includes('--force');
const cleanFirst = args.includes('--clean');
const skipRoles = args.includes('--skip-roles');
const skipSupabaseExtensions = args.includes('--skip-supabase-extensions');
const sslRequireOnly = args.includes('--ssl-require');
const jobsRaw = args.find((argument) => argument.startsWith('--jobs='));
const parallelJobs = jobsRaw ? Number(jobsRaw.slice('--jobs='.length)) : Number(process.env.PG_RESTORE_JOBS ?? 0);
const dumpPathRaw = args.find((argument) => !argument.startsWith('-'));
const databaseUrl = process.env.DATABASE_URL;
const pgRestoreExecutable = process.env.PG_RESTORE_BIN ?? 'pg_restore';
const psqlExecutable = process.env.PSQL_BIN ?? 'psql';
async function main() {
if (!dumpPathRaw) {
printUsage();
return;
}
if (!dryRun && !databaseUrl) {
console.error('Set DATABASE_URL (or pass --dry-run to only generate a SQL preview).');
printUsage();
return;
}
const resolvedDump = path.isAbsolute(dumpPathRaw) ? dumpPathRaw : path.resolve(process.cwd(), dumpPathRaw);
let dumpKind: DumpKind;
try {
dumpKind = detectDumpKind(resolvedDump);
} catch (error) {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
return;
}
console.log('Dump:', resolvedDump);
console.log('Format:', dumpKind === 'custom' ? 'custom (pg_restore)' : 'plain SQL (psql)');
try {
if (dryRun) {
mkdirSync(backupDir, { recursive: true });
const previewFile = path.join(backupDir, 'push-dump-to-new-db-preview.sql');
if (dumpKind === 'custom') {
extractCustomFormatSchemaPreview(resolvedDump, previewFile);
} else {
writeFileSync(previewFile, readFileSync(resolvedDump, 'utf8'));
}
console.log('Dry run: wrote preview SQL to', previewFile);
console.log('Apply manually with psql, or re-run without --dry-run and DATABASE_URL set.');
return;
}
await ensureTargetReady(databaseUrl!, force);
if (!skipRoles) {
await ensureRlsRoles(databaseUrl!);
}
if (!skipSupabaseExtensions) {
await ensureSupabaseExtensionCompat(databaseUrl!);
}
const pgCliDatabaseUrl = connectionStringForPgCli(databaseUrl!, sslRequireOnly);
if (dumpKind === 'custom') {
pushCustomFormatSchema(resolvedDump, pgCliDatabaseUrl);
} else {
pushPlainSchemaSql(resolvedDump, pgCliDatabaseUrl);
}
} catch (error) {
console.error('Push dump to new DB failed:', error);
process.exitCode = 1;
}
}
type DumpKind = 'custom' | 'plain';
function printUsage() {
console.error(`Usage:
DATABASE_URL="postgres://..." pnpm --filter @cio/db db:push-dump-to-new-db -- <dump.dump|.sql>
Flags:
--dry-run Write SQL preview to packages/db/backups/push-dump-to-new-db-preview.sql
--force Push even when public already has tables
--clean Drop existing public objects before push (pg_restore --clean --if-exists)
--skip-roles Do not create authenticated / anon roles before push
--skip-supabase-extensions Skip creating extensions.moddatetime / gen_random_uuid stubs
--jobs=N Parallel pg_restore workers (custom .dump only)
--ssl-require Force sslmode=require for pg_restore/psql (same as the default when no CA file)`);
process.exitCode = 1;
}
function detectDumpKind(dumpFilePath: string): DumpKind {
const header = readFileSync(dumpFilePath).subarray(0, 5).toString();
if (header === 'PGDMP') {
return 'custom';
}
const plainText = readFileSync(dumpFilePath, 'utf8');
if (/\bCOPY\s+public\./i.test(plainText)) {
throw new Error(
'Plain SQL dump contains public COPY data blocks. Use a custom-format .dump from db:dump-backup, or pg_dump --schema-only -n public.'
);
}
return 'plain';
}
async function ensureTargetReady(connectionString: string, allowExistingTables: boolean) {
const sql = postgres(connectionString, { max: 1 });
try {
const existingTables = await sql<{ tablename: string }[]>`
SELECT tablename
FROM pg_tables
WHERE schemaname = 'public'
ORDER BY tablename
`;
if (existingTables.length === 0) {
console.log('Target public schema is empty.');
return;
}
if (!allowExistingTables) {
throw new Error(
`Target already has ${existingTables.length} table(s) in public (e.g. ${existingTables[0]?.tablename}). ` +
'Use --force to push anyway, or provision a fresh database branch.'
);
}
console.warn(
`--force: continuing despite ${existingTables.length} existing public table(s).`,
cleanFirst ? ' (--clean will drop objects first)' : ''
);
} finally {
await sql.end();
}
}
/**
* Supabase dumps reference `extensions.moddatetime` triggers and sometimes `extensions.gen_random_uuid`.
* PlanetScale has no `extensions` schema; create minimal compatible stubs before pg_restore.
*/
async function ensureSupabaseExtensionCompat(connectionString: string) {
const sql = postgres(connectionString, { max: 1 });
try {
await sql.unsafe(`
CREATE SCHEMA IF NOT EXISTS extensions;
CREATE OR REPLACE FUNCTION extensions.gen_random_uuid()
RETURNS uuid
LANGUAGE sql
PARALLEL SAFE
AS $$ SELECT gen_random_uuid(); $$;
CREATE OR REPLACE FUNCTION extensions.moddatetime()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$;
CREATE OR REPLACE FUNCTION extensions.moddatetime(column_name text)
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF column_name = 'updated_at' THEN
NEW.updated_at = NOW();
ELSE
NEW := jsonb_populate_record(NEW, jsonb_build_object(column_name, NOW()));
END IF;
RETURN NEW;
END;
$$;
`);
console.log('Ensured extensions schema (moddatetime, gen_random_uuid) for Supabase-compatible dumps');
} finally {
await sql.end();
}
}
async function ensureRlsRoles(connectionString: string) {
const sql = postgres(connectionString, { max: 1 });
try {
const authenticatedExists = await sql`SELECT 1 FROM pg_roles WHERE rolname = 'authenticated'`;
if (authenticatedExists.length === 0) {
await sql`CREATE ROLE authenticated`;
console.log('Created role: authenticated');
} else {
console.log('Role already exists: authenticated');
}
const anonExists = await sql`SELECT 1 FROM pg_roles WHERE rolname = 'anon'`;
if (anonExists.length === 0) {
await sql`CREATE ROLE anon`;
console.log('Created role: anon');
} else {
console.log('Role already exists: anon');
}
} finally {
await sql.end();
}
}
function pushCustomFormatSchema(dumpFilePath: string, connectionString: string) {
const restoreArgs = [
'--dbname',
connectionString,
'--schema-only',
'--no-owner',
'--no-privileges',
'--no-publications',
'--no-subscriptions',
'--exit-on-error',
'-n',
'public',
...(cleanFirst ? ['--clean', '--if-exists'] : []),
...(parallelJobs > 0 ? ['--jobs', String(parallelJobs)] : []),
'--verbose',
dumpFilePath
];
console.log('Running:', pgRestoreExecutable, restoreArgs.filter((argument) => !argument.includes('://')).join(' '));
const result = spawnSync(pgRestoreExecutable, restoreArgs, { stdio: 'inherit' });
if (result.error) {
throw new Error(`pg_restore spawn failed: ${result.error.message}`);
}
if (result.status !== 0) {
throw new Error(`pg_restore exited with status ${result.status ?? 'unknown'}`);
}
console.log('Public schema push finished.');
console.log(
'Next: run pnpm --filter @cio/db db:setup --skip-schema-sync (roles + reference seed), then data restores as needed.'
);
}
function pushPlainSchemaSql(dumpFilePath: string, connectionString: string) {
console.log('Running:', psqlExecutable, '<DATABASE_URL> -v ON_ERROR_STOP=1 -f', dumpFilePath);
const result = spawnSync(psqlExecutable, [connectionString, '-v', 'ON_ERROR_STOP=1', '-f', dumpFilePath], {
stdio: 'inherit'
});
if (result.error) {
throw new Error(`psql spawn failed: ${result.error.message}`);
}
if (result.status !== 0) {
throw new Error(`psql exited with status ${result.status ?? 'unknown'}`);
}
console.log('Public schema SQL applied.');
}
function extractCustomFormatSchemaPreview(dumpFilePath: string, previewFilePath: string) {
const extractArgs = [
'--schema-only',
'--no-owner',
'--no-privileges',
'-n',
'public',
'-f',
previewFilePath,
dumpFilePath
];
const result = spawnSync(pgRestoreExecutable, extractArgs, { encoding: 'utf8' });
if (result.error) {
throw new Error(`pg_restore preview spawn failed: ${result.error.message}`);
}
if (result.status !== 0) {
throw new Error(
`pg_restore preview failed: ${(result.stderr ?? '').trim() || `exit ${result.status}`}. ` +
'Ensure pg_restore major version matches the dump.'
);
}
}
/**
* libpq (`pg_restore`, `psql`) does not use Node's TLS stack. `verify-full` without a CA file makes
* libpq look for `~/.postgresql/root.crt`. Older libpq also treats `sslrootcert=system` as a path.
* Use `sslmode=require` unless the URL or `PGSSLROOTCERT` supplies a real certificate file.
*/
function connectionStringForPgCli(rawUrl: string, useSslRequireOnly: boolean): string {
const url = new URL(rawUrl.replace(/^postgres:/, 'postgresql:'));
const envSslMode = process.env.PUSH_DUMP_SSLMODE?.trim();
if (envSslMode) {
applyPgCliSslMode(url, envSslMode);
console.log(`pg_restore/psql: using PUSH_DUMP_SSLMODE=${envSslMode}`);
return url.toString();
}
if (useSslRequireOnly) {
applyPgCliSslMode(url, 'require');
console.log('pg_restore/psql: using sslmode=require (--ssl-require)');
return url.toString();
}
const sslmode = url.searchParams.get('sslmode') ?? '';
const rootCertPath = resolvePgCliRootCertPath(url);
if (rootCertPath) {
url.searchParams.set('sslrootcert', rootCertPath);
if (!sslmode) {
url.searchParams.set('sslmode', 'verify-full');
}
console.log(`pg_restore/psql: using sslrootcert=${rootCertPath}`);
return url.toString();
}
const needsEncryptedConnection =
sslmode === 'verify-full' ||
sslmode === 'verify-ca' ||
sslmode === 'require' ||
url.hostname.endsWith('.psdb.cloud');
if (needsEncryptedConnection && sslmode !== 'require') {
applyPgCliSslMode(url, 'require');
console.log(
'pg_restore/psql: using sslmode=require (libpq has no CA file; connection is still TLS-encrypted). ' +
'Set PGSSLROOTCERT or PUSH_DUMP_SSLMODE=verify-full to verify the server certificate.'
);
}
return url.toString();
}
function applyPgCliSslMode(url: URL, sslmode: string) {
url.searchParams.set('sslmode', sslmode);
if (sslmode === 'require' || sslmode === 'prefer' || sslmode === 'disable') {
url.searchParams.delete('sslrootcert');
}
}
function resolvePgCliRootCertPath(url: URL): string | null {
const envPath = process.env.PGSSLROOTCERT?.trim();
if (envPath) {
return envPath;
}
const paramPath = url.searchParams.get('sslrootcert')?.trim();
if (!paramPath || paramPath === 'system') {
return null;
}
return paramPath;
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
@@ -0,0 +1,235 @@
/**
* Copy `public.lesson_section` **data** from a `pg_dump` backup into **live** `public.course_section`
* on `DATABASE_URL` (UPSERT on `id`).
*
* **Custom format** (`.dump` / `-Fc`): uses `pg_restore --data-only -t lesson_section` (needs version
* compatible with the dump, same as `pg_dump`).
*
* **Plain SQL** (`-Fp` `.sql`): scans for a `COPY ... lesson_section ... FROM stdin` block.
*
* Usage:
* pnpm --filter @cio/db db:restore-lesson-section-from-dump -- path/to/backup.dump
* pnpm exec tsx src/scripts/restore-lesson-section-from-dump.ts --dry-run path/to/backup.sql
*
* Workflow: If `pnpm db migrate` halted on FK errors because `course_section` had no rows yet, restore
* sections from your dump with this script, then run `pnpm db migrate` again—the migration uses conditional
* `DO` blocks that add missing FKs when referenced ids exist.
*
* Env:
* - `DATABASE_URL` — target DB (required unless `--dry-run`).
* - `PG_RESTORE_BIN` — `pg_restore` binary (default `pg_restore`).
* - `PSQL_BIN` — `psql` binary (default `psql`).
*/
import 'dotenv/config';
import { spawnSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const backupDir = path.resolve(scriptDir, '../../backups');
const args = process.argv.slice(2);
const dryRun = args.includes('--dry-run');
const dumpPathRaw = args.find((argument) => !argument.startsWith('-'));
const databaseUrl = process.env.DATABASE_URL;
const pgRestoreExecutable = process.env.PG_RESTORE_BIN ?? 'pg_restore';
const psqlExecutable = process.env.PSQL_BIN ?? 'psql';
const prelude = `
BEGIN;
CREATE TEMP TABLE _lesson_section_import (
id uuid NOT NULL PRIMARY KEY,
created_at timestamptz NOT NULL,
updated_at timestamptz,
title varchar,
"order" bigint,
course_id uuid
);
`;
const mergeSql = `
INSERT INTO public.course_section (id, created_at, updated_at, title, "order", course_id)
SELECT id, created_at, updated_at, title, "order", course_id
FROM _lesson_section_import
ON CONFLICT (id) DO UPDATE SET
created_at = EXCLUDED.created_at,
updated_at = EXCLUDED.updated_at,
title = EXCLUDED.title,
"order" = EXCLUDED."order",
course_id = EXCLUDED.course_id;
COMMIT;
`;
function renameCopyTargets(body: string) {
let out = body;
out = out.replace(/\bCOPY\s+public\.lesson_section\b/i, 'COPY _lesson_section_import');
out = out.replace(/\bCOPY\s+lesson_section\b/i, 'COPY _lesson_section_import');
return out;
}
/** Plain-format dump: one COPY ... lesson_section ... FROM stdin; … \\. block */
function slicePlainCopyLessonSection(fileText: string) {
const copyPublic = /\bCOPY\s+public\.lesson_section\b/i.exec(fileText);
const copyBare = /\bCOPY\s+lesson_section\b/i.exec(fileText);
const matchIndex = copyPublic?.index ?? copyBare?.index;
if (matchIndex === undefined) {
return null;
}
const fromStdinIdx = fileText.indexOf('FROM stdin;', matchIndex);
if (fromStdinIdx === -1) {
return null;
}
let lineEnd = fileText.indexOf('\n', fromStdinIdx + 'FROM stdin;'.length);
if (lineEnd === -1) {
return null;
}
/** First line after DATA is first row; terminator is a line `\`. */
const terminator = /\n\\\.\s*(?:\n|\r?\n|$)/;
const dataRegion = fileText.slice(lineEnd);
const terminatorMatch = terminator.exec(dataRegion);
if (!terminatorMatch) {
return null;
}
const terminatorEndAbs = lineEnd + terminatorMatch.index + terminatorMatch[0].length;
return fileText.slice(matchIndex, terminatorEndAbs);
}
function pgRestoreLessonSectionExtract(dumpFilePath: string) {
const tempDir = mkdtempSync(path.join(tmpdir(), 'lesson-section-restore-'));
const extractedPath = path.join(tempDir, 'lesson_section.sql');
try {
const flags = spawnSync(
pgRestoreExecutable,
[
'--data-only',
'--no-owner',
'--no-privileges',
'-n',
'public',
'-t',
'lesson_section',
'-f',
extractedPath,
dumpFilePath
],
{ encoding: 'utf8' }
);
if (flags.status !== 0) {
console.error(pgRestoreExecutable, '--data-only extraction failed:', (flags.stderr ?? '').trim() || flags.status);
return null;
}
return renameCopyTargets(readFileSync(extractedPath, 'utf8'));
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
}
function printUsage() {
console.error(`Usage:
DATABASE_URL="postgres://..." pnpm exec tsx src/scripts/restore-lesson-section-from-dump.ts <dump.dump|.sql>
Flags:
--dry-run Write merged SQL preview to packages/db/backups/restore-lesson-section-preview.sql`);
process.exitCode = 1;
}
function main() {
if (!dumpPathRaw) {
console.error('Missing dump file path.');
printUsage();
return;
}
if (!dryRun && !databaseUrl) {
console.error('Set DATABASE_URL (or pass --dry-run to only generate SQL preview).');
printUsage();
return;
}
const resolvedDump = path.isAbsolute(dumpPathRaw) ? dumpPathRaw : path.resolve(process.cwd(), dumpPathRaw);
let lessonSectionChunk: string | null;
if (resolvedDump.endsWith('.sql')) {
const text = readFileSync(resolvedDump, 'utf8');
lessonSectionChunk = slicePlainCopyLessonSection(text);
if (lessonSectionChunk) {
lessonSectionChunk = renameCopyTargets(lessonSectionChunk);
}
} else {
lessonSectionChunk = pgRestoreLessonSectionExtract(resolvedDump);
}
if (!lessonSectionChunk) {
console.error(
'Could not extract lesson_section data. Check:',
`\n - Table exists in backup as public.lesson_section`,
`\n - For custom dumps: pg_restore -l "${resolvedDump}" | grep lesson_section`,
`\n - pg_restore major version matches the dump`
);
process.exitCode = 1;
return;
}
const fullScript = prelude + lessonSectionChunk + mergeSql;
mkdirSync(backupDir, { recursive: true });
const previewFile = path.join(backupDir, 'restore-lesson-section-preview.sql');
writeFileSync(previewFile, fullScript, 'utf8');
console.log('Wrote SQL preview:', previewFile);
if (dryRun) {
console.log('Dry run: skipping psql execution.');
return;
}
const invoke = spawnSync(psqlExecutable, [databaseUrl!, '-v', 'ON_ERROR_STOP=1', '-f', previewFile], {
stdio: 'inherit'
});
if (invoke.error) {
console.error('psql spawn failed:', invoke.error.message);
process.exitCode = 1;
return;
}
if (invoke.status !== 0) {
process.exitCode = invoke.status ?? 1;
return;
}
console.log('Done: lesson_section rows from dump were UPSERT-ed into course_section via', previewFile);
}
main();
@@ -0,0 +1,272 @@
/**
* Insert `public.organizationmember` **rows from a dump** into the DB on `DATABASE_URL` **only when
* missing** relative to existing data:
* - skip if **`id`** already exists;
* - skip if **`(organization_id, profile_id)`** already exists (**`profile_id IS NOT NULL`**, aligned with
* partial unique `organizationmember_org_profile_unique`);
* - skip if **`(organization_id, email)`** already exists (**`email IS NOT NULL`**, aligned with
* `organizationmember_org_email_unique`).
*
* **Custom format** (`.dump` / `-Fc`): `pg_restore --data-only -t organizationmember`.
* **Plain SQL** (`-Fp` `.sql`): finds `COPY ... organizationmember ... FROM stdin` block.
*
* Usage:
* DATABASE_URL="postgres://..." pnpm exec tsx src/scripts/restore-organizationmember-from-dump.ts path/to/backup.dump
* pnpm exec tsx src/scripts/restore-organizationmember-from-dump.ts --dry-run path/to/backup.sql
*
* Requires existing `organization`, `profile` (when `profile_id` set), and `role` rows for FKs.
*
* Env:
* - `DATABASE_URL` — target DB (required unless `--dry-run`).
* - `PG_RESTORE_BIN` — default `pg_restore`.
* - `PSQL_BIN` — default `psql`.
*/
import 'dotenv/config';
import { spawnSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const backupDir = path.resolve(scriptDir, '../../backups');
const args = process.argv.slice(2);
const dryRun = args.includes('--dry-run');
const dumpPathRaw = args.find((argument) => !argument.startsWith('-'));
const databaseUrl = process.env.DATABASE_URL;
const pgRestoreExecutable = process.env.PG_RESTORE_BIN ?? 'pg_restore';
const psqlExecutable = process.env.PSQL_BIN ?? 'psql';
/** Column order matches `organizationmember` in schema / typical pg_dump. */
const prelude = `
BEGIN;
CREATE TEMP TABLE _organizationmember_import (
id bigint NOT NULL PRIMARY KEY,
organization_id uuid NOT NULL,
role_id bigint NOT NULL,
profile_id uuid,
email text,
verified boolean,
created_at timestamptz NOT NULL
);
`;
const mergeSql = `
INSERT INTO public.organizationmember (
id,
organization_id,
role_id,
profile_id,
email,
verified,
created_at
)
SELECT
i.id,
i.organization_id,
i.role_id,
i.profile_id,
i.email,
COALESCE(i.verified, false),
i.created_at
FROM _organizationmember_import i
WHERE NOT EXISTS (SELECT 1 FROM public.organizationmember om WHERE om.id = i.id)
AND (
i.profile_id IS NULL
OR NOT EXISTS (
SELECT 1
FROM public.organizationmember om
WHERE om.organization_id = i.organization_id
AND om.profile_id IS NOT NULL
AND om.profile_id = i.profile_id
)
)
AND (
i.email IS NULL
OR NOT EXISTS (
SELECT 1
FROM public.organizationmember om
WHERE om.organization_id = i.organization_id
AND om.email IS NOT NULL
AND om.email = i.email
)
);
SELECT setval(
pg_get_serial_sequence('public.organizationmember', 'id'),
COALESCE((SELECT MAX(id)::bigint FROM public.organizationmember), 1),
true
);
COMMIT;
`;
function renameCopyTargets(body: string) {
let out = body;
out = out.replace(/\bCOPY\s+public\.organizationmember\b/i, 'COPY _organizationmember_import');
out = out.replace(/\bCOPY\s+organizationmember\b/i, 'COPY _organizationmember_import');
return out;
}
function slicePlainCopyOrganizationMember(fileText: string) {
const copyPublic = /\bCOPY\s+public\.organizationmember\b/i.exec(fileText);
const copyBare = /\bCOPY\s+organizationmember\b/i.exec(fileText);
const matchIndex = copyPublic?.index ?? copyBare?.index;
if (matchIndex === undefined) {
return null;
}
const fromStdinIdx = fileText.indexOf('FROM stdin;', matchIndex);
if (fromStdinIdx === -1) {
return null;
}
let lineEnd = fileText.indexOf('\n', fromStdinIdx + 'FROM stdin;'.length);
if (lineEnd === -1) {
return null;
}
const terminator = /\n\\\.\s*(?:\n|\r?\n|$)/;
const dataRegion = fileText.slice(lineEnd);
const terminatorMatch = terminator.exec(dataRegion);
if (!terminatorMatch) {
return null;
}
const terminatorEndAbs = lineEnd + terminatorMatch.index + terminatorMatch[0].length;
return fileText.slice(matchIndex, terminatorEndAbs);
}
function pgRestoreOrganizationMemberExtract(dumpFilePath: string) {
const tempDir = mkdtempSync(path.join(tmpdir(), 'organizationmember-restore-'));
const extractedPath = path.join(tempDir, 'organizationmember.sql');
try {
const flags = spawnSync(
pgRestoreExecutable,
[
'--data-only',
'--no-owner',
'--no-privileges',
'-n',
'public',
'-t',
'organizationmember',
'-f',
extractedPath,
dumpFilePath
],
{ encoding: 'utf8' }
);
if (flags.status !== 0) {
console.error(pgRestoreExecutable, '--data-only extraction failed:', (flags.stderr ?? '').trim() || flags.status);
return null;
}
return renameCopyTargets(readFileSync(extractedPath, 'utf8'));
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
}
function printUsage() {
console.error(`Usage:
DATABASE_URL="postgres://..." pnpm exec tsx src/scripts/restore-organizationmember-from-dump.ts <dump.dump|.sql>
Flags:
--dry-run Write merged SQL preview to packages/db/backups/restore-organizationmember-preview.sql`);
process.exitCode = 1;
}
function main() {
if (!dumpPathRaw) {
console.error('Missing dump file path.');
printUsage();
return;
}
if (!dryRun && !databaseUrl) {
console.error('Set DATABASE_URL (or pass --dry-run to only generate SQL preview).');
printUsage();
return;
}
const resolvedDump = path.isAbsolute(dumpPathRaw) ? dumpPathRaw : path.resolve(process.cwd(), dumpPathRaw);
let organizationMemberChunk: string | null;
if (resolvedDump.endsWith('.sql')) {
const text = readFileSync(resolvedDump, 'utf8');
organizationMemberChunk = slicePlainCopyOrganizationMember(text);
if (organizationMemberChunk) {
organizationMemberChunk = renameCopyTargets(organizationMemberChunk);
}
} else {
organizationMemberChunk = pgRestoreOrganizationMemberExtract(resolvedDump);
}
if (!organizationMemberChunk) {
console.error(
'Could not extract organizationmember data. Check:',
`\n - Table exists in backup as public.organizationmember`,
`\n - For custom dumps: pg_restore -l "${resolvedDump}" | grep organizationmember`,
`\n - pg_restore major version matches the dump`
);
process.exitCode = 1;
return;
}
const fullScript = prelude + organizationMemberChunk + mergeSql;
mkdirSync(backupDir, { recursive: true });
const previewFile = path.join(backupDir, 'restore-organizationmember-preview.sql');
writeFileSync(previewFile, fullScript, 'utf8');
console.log('Wrote SQL preview:', previewFile);
if (dryRun) {
console.log('Dry run: skipping psql execution.');
return;
}
const invoke = spawnSync(psqlExecutable, [databaseUrl!, '-v', 'ON_ERROR_STOP=1', '-f', previewFile], {
stdio: 'inherit'
});
if (invoke.error) {
console.error('psql spawn failed:', invoke.error.message);
process.exitCode = 1;
return;
}
if (invoke.status !== 0) {
process.exitCode = invoke.status ?? 1;
return;
}
console.log('Done: organizationmember rows from dump were merged (missing-only) via', previewFile);
}
main();
+215 -50
View File
@@ -1,5 +1,13 @@
/**
* Commenting this script out.
* One-off migration: copies `auth.users` + identities into Better Auth `"user"` and `account`.
*
* Optional reset: when `RESET_BETTER_AUTH_BEFORE_SUPABASE_MIGRATE=1`, clears Better Auth targets
* in a transaction (see `resetBetterAuthTables`) before re-import.
*
* Optional backfill: when `BACKFILL_AUTH_ACCOUNTS_FROM_SUPABASE=1`, only processes Supabase users
* that already exist in `"user"` but have no rows in `account`, and creates missing account rows
* from `auth.identities` (does not create or update `"user"` rows). Incompatible with reset;
* reset is skipped if both flags are set (with a warning).
*/
import 'dotenv/config';
@@ -9,6 +17,87 @@ import { admin, anonymous } from 'better-auth/plugins';
import { Pool } from 'pg';
import { betterAuth } from 'better-auth';
const RESET_FLAG = process.env.RESET_BETTER_AUTH_BEFORE_SUPABASE_MIGRATE === '1';
const BACKFILL_ACCOUNTS_ONLY = process.env.BACKFILL_AUTH_ACCOUNTS_FROM_SUPABASE === '1';
console.log('RESET_FLAG', RESET_FLAG);
console.log('BACKFILL_AUTH_ACCOUNTS_FROM_SUPABASE', BACKFILL_ACCOUNTS_ONLY);
if (RESET_FLAG && BACKFILL_ACCOUNTS_ONLY) {
console.warn(
'Both RESET_BETTER_AUTH_BEFORE_SUPABASE_MIGRATE and BACKFILL_AUTH_ACCOUNTS_FROM_SUPABASE are set; skipping reset and running account backfill only.'
);
}
async function resetBetterAuthTables(pool: Pool) {
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query('TRUNCATE TABLE analytics_login_events');
await client.query('ALTER TABLE profile DROP CONSTRAINT IF EXISTS profile_id_fkey');
await client.query('TRUNCATE TABLE verification');
await client.query('DELETE FROM "user"');
await client.query('COMMIT');
console.log(
'Reset complete: truncated analytics_login_events and verification; dropped profile_id_fkey; deleted all \"user\" rows (account/session cascade).'
);
} catch (error) {
await client.query('ROLLBACK');
console.error('resetBetterAuthTables error:', error);
throw error;
} finally {
client.release();
}
}
async function restoreProfileUserFkey(pool: Pool) {
console.log('restoreProfileUserFkey');
const client = await pool.connect();
try {
const orphanResult = await client.query(`
SELECT COUNT(*)::bigint AS cnt
FROM profile p
WHERE NOT EXISTS (
SELECT 1
FROM "user" u
WHERE u.id = p.id
)
`);
const orphanCount = Number(orphanResult.rows[0]?.cnt ?? 0);
if (orphanCount > 0) {
throw new Error(
`Cannot re-add profile_id_fkey: ${orphanCount} profile row(s) have no matching \"user\". Remove orphan profiles or create missing users before re-running.`
);
}
try {
await client.query(`
ALTER TABLE profile
ADD CONSTRAINT profile_id_fkey FOREIGN KEY (id) REFERENCES "user"(id)
`);
console.log('Restored constraint profile_id_fkey on profile(id) -> \"user\"(id).');
} catch (addConstraintError: unknown) {
const pgError = addConstraintError as { code?: string; message?: string };
// Avoid failing if FK already exists from a retry or interrupted run
if (pgError.code === '42710' || pgError.message?.includes('already exists')) {
console.warn('profile_id_fkey already present; skipping ADD CONSTRAINT.', pgError.message);
return;
}
throw addConstraintError;
}
} finally {
client.release();
}
}
// We create a custom betterAuth here because this code works well with pg
const auth = betterAuth({
database: new Pool({
@@ -25,7 +114,32 @@ const auth = betterAuth({
clientSecret: process.env.GOOGLE_CLIENT_SECRET!
}
},
plugins: [admin(), anonymous()],
plugins: [
admin({
schema: {
user: {
fields: {
banReason: 'ban_reason',
banExpires: 'ban_expires'
}
},
session: {
fields: {
impersonatedBy: 'impersonated_by'
}
}
}
}),
anonymous({
schema: {
user: {
fields: {
isAnonymous: 'is_anonymous'
}
}
}
})
],
user: {
modelName: 'user',
@@ -99,24 +213,62 @@ type User = SupabaseUser & {
updated_at: string;
is_anonymous: boolean;
profile_fullname: string;
identities: {
id: string; // auth.identities.id (UUID) - use as account.id so DB accepts it
provider: string;
identity_data: {
sub: string;
email: string;
};
created_at: string;
updated_at: string;
};
identities:
| {
id: string; // auth.identities.id (UUID) - use as account.id so DB accepts it
provider: string;
identity_data: {
sub: string;
email: string;
};
created_at: string;
updated_at: string;
}[]
| string;
};
function parseIdentities(value: User['identities']) {
if (Array.isArray(value)) {
return value;
}
try {
const parsed = JSON.parse(value as string);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
const migrateFromSupabase = async () => {
const ctx = await auth.$context;
const db = ctx.options.database;
const users = await db
.query(
`
const db = ctx.options.database as Pool;
const effectiveReset = RESET_FLAG && !BACKFILL_ACCOUNTS_ONLY;
if (effectiveReset) {
await resetBetterAuthTables(db);
}
const migrationWhere = `
WHERE NOT EXISTS (
SELECT 1
FROM "user" existing_user
WHERE existing_user.id = u.id
)
`;
const backfillAccountsWhere = `
INNER JOIN "user" existing_ba_user ON existing_ba_user.id = u.id
WHERE NOT EXISTS (
SELECT 1
FROM account a
WHERE a.user_id = u.id
)
`;
const usersQuery = `
SELECT
u.*,
COALESCE(
@@ -129,52 +281,58 @@ const migrateFromSupabase = async () => {
FROM auth.users u
LEFT JOIN auth.identities i ON u.id = i.user_id
LEFT JOIN public.profile p ON u.id = p.id -- LEFT JOIN with public.profile
${BACKFILL_ACCOUNTS_ONLY ? backfillAccountsWhere : migrationWhere}
GROUP BY u.id, p.fullname
`
)
.then((res: { rows: User[] }) => res.rows);
`;
console.log('users length', users.length);
const users = await db.query(usersQuery).then((res: { rows: User[] }) => res.rows);
console.log(
BACKFILL_ACCOUNTS_ONLY
? 'users needing account backfill (have "user", no account rows)'
: 'users to migrate (not yet in "user")',
users.length
);
for (const user of users) {
if (!user.email) {
continue;
}
const userName = user.profile_fullname || user.email;
console.log('inserting user', user.email);
if (!BACKFILL_ACCOUNTS_ONLY) {
const userName = user.profile_fullname || user.email;
console.log('inserting user', user.email);
let shouldContinue = false;
await ctx.adapter
.create({
model: 'user',
forceAllowId: true,
data: {
id: user.id,
email: user.email,
name: userName,
role: user.is_super_admin ? 'admin' : user.role,
emailVerified: !!user.email_confirmed_at,
image: user.raw_user_meta_data?.avatar_url || '',
createdAt: new Date(user.created_at),
updatedAt: new Date(user.updated_at)
}
})
.catch((e) => {
console.log('catch');
shouldContinue = true;
});
if (shouldContinue) {
console.log('Continue');
// continue;
try {
await ctx.adapter.create({
model: 'user',
forceAllowId: true,
data: {
id: user.id,
email: user.email,
name: userName,
role: user.is_super_admin ? 'admin' : user.role,
emailVerified: !!user.email_confirmed_at,
image: user.raw_user_meta_data?.avatar_url || '',
createdAt: new Date(user.created_at),
updatedAt: new Date(user.updated_at)
}
});
} catch (createUserError) {
console.error('create user failed, skipping identities', user.email, createUserError);
continue;
}
} else {
console.log('backfilling accounts only for', user.email);
}
for (const identity of user.identities) {
const identities = parseIdentities(user.identities);
for (const identity of identities) {
const existingAccounts = await ctx.internalAdapter.findAccounts(user.id);
if (identity.provider === 'email') {
console.log('email provider');
const hasCredential = existingAccounts.find((account) => account.providerId === 'credential');
const hasCredential = existingAccounts.find((accountRow) => accountRow.providerId === 'credential');
console.log('hasCredential', hasCredential);
if (!hasCredential) {
await ctx.adapter
@@ -198,7 +356,7 @@ const migrateFromSupabase = async () => {
}
const supportedProviders = Object.keys(ctx.options.socialProviders || {});
if (supportedProviders.includes(identity.provider)) {
const hasAccount = existingAccounts.find((account) => account.providerId === identity.provider);
const hasAccount = existingAccounts.find((accountRow) => accountRow.providerId === identity.provider);
if (!hasAccount) {
await ctx.adapter.create({
model: 'account',
@@ -216,6 +374,13 @@ const migrateFromSupabase = async () => {
}
}
}
if (effectiveReset) {
await restoreProfileUserFkey(db);
}
};
migrateFromSupabase();
migrateFromSupabase().catch((migrationError) => {
console.error('migrateFromSupabase failed:', migrationError);
process.exitCode = 1;
});
File diff suppressed because one or more lines are too long