From b7f111cd8551600a9a094430136ef99338a5bc7c Mon Sep 17 00:00:00 2001 From: Robert Andreas Kyllo Date: Wed, 14 Oct 2026 13:53:40 +0300 Subject: [PATCH 1/2] feat: #5 add user data schema and FTS triggers --- docs/AI-USAGE.md | 1 + .../drizzle/0001_user-content-and-tracks.sql | 128 ++++ server/drizzle/meta/0001_snapshot.json | 579 ++++++++++++++++++ server/drizzle/meta/_journal.json | 7 + server/src/db/schema.ts | 80 ++- server/src/db/seed.ts | 11 - 6 files changed, 787 insertions(+), 19 deletions(-) create mode 100644 server/drizzle/0001_user-content-and-tracks.sql create mode 100644 server/drizzle/meta/0001_snapshot.json diff --git a/docs/AI-USAGE.md b/docs/AI-USAGE.md index d919b9e..565badd 100644 --- a/docs/AI-USAGE.md +++ b/docs/AI-USAGE.md @@ -37,6 +37,7 @@ the part that matters most. | 2026-10-01 | robertky | Codex (ChatGPT) | Assisted with formulating good reviews of the architecture and UI-specification PRs. The group decided to include decade filtering; Rachel implemented the corresponding schema and documentation update in #38. | | 2026-10-10 | evenkkl | Claude | Assisted with implementing the GraphQL Yoga server with `searchAlbums` for #6, and formulated an easy to understand PR description. Guided the reset of the prototype tables in the VM database (backup taken first). | | 2026-10-13 | evenkkl | Claude | Fixed the review feedback on #6: cursor values validated against the sort, `reviewCount` returns 0 until reviews exist. The group decided genre filters combine with AND; Claude made the filter and the genre counts match that. | +| 2026-10-09 | robertky | Codex (ChatGPT) | AI was a good sparring partner when I drafted the initial #5 PostgreSQL migration. It gave me ideas on how to structure and improve the tables, relation tables, constraints, indexes, full-text search triggers and TypeScript migration runner. | ## Notes diff --git a/server/drizzle/0001_user-content-and-tracks.sql b/server/drizzle/0001_user-content-and-tracks.sql new file mode 100644 index 0000000..0965cbd --- /dev/null +++ b/server/drizzle/0001_user-content-and-tracks.sql @@ -0,0 +1,128 @@ +CREATE TABLE "app_user" ( + "id" uuid PRIMARY KEY NOT NULL, + "display_name" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "crate_item" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" uuid NOT NULL, + "album_id" integer NOT NULL, + "added_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "review" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" uuid NOT NULL, + "album_id" integer NOT NULL, + "rating" smallint NOT NULL, + "comment" text DEFAULT '' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "review_rating_range" CHECK ("review"."rating" BETWEEN 1 AND 5) +); +--> statement-breakpoint +CREATE TABLE "track" ( + "id" serial PRIMARY KEY NOT NULL, + "album_id" integer NOT NULL, + "title" text NOT NULL, + "track_number" integer NOT NULL, + "duration_seconds" integer +); +--> statement-breakpoint +ALTER TABLE "crate_item" ADD CONSTRAINT "crate_item_user_id_app_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."app_user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "crate_item" ADD CONSTRAINT "crate_item_album_id_album_id_fk" FOREIGN KEY ("album_id") REFERENCES "public"."album"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "review" ADD CONSTRAINT "review_user_id_app_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."app_user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "review" ADD CONSTRAINT "review_album_id_album_id_fk" FOREIGN KEY ("album_id") REFERENCES "public"."album"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "track" ADD CONSTRAINT "track_album_id_album_id_fk" FOREIGN KEY ("album_id") REFERENCES "public"."album"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "crate_item_user_album_unique" ON "crate_item" USING btree ("user_id","album_id");--> statement-breakpoint +CREATE INDEX "crate_item_user_added_at_idx" ON "crate_item" USING btree ("user_id","added_at","id");--> statement-breakpoint +CREATE UNIQUE INDEX "review_user_album_unique" ON "review" USING btree ("user_id","album_id");--> statement-breakpoint +CREATE INDEX "review_album_idx" ON "review" USING btree ("album_id");--> statement-breakpoint +CREATE UNIQUE INDEX "track_album_track_number_unique" ON "track" USING btree ("album_id","track_number");--> statement-breakpoint +CREATE INDEX "track_album_track_number_idx" ON "track" USING btree ("album_id","track_number"); +--> statement-breakpoint +CREATE FUNCTION set_album_search_vector() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + NEW.search_vector := + setweight(to_tsvector('simple', NEW.title), 'A') || + setweight( + to_tsvector( + 'simple', + COALESCE(( + SELECT string_agg(artist.name, ' ') + FROM album_artist + JOIN artist ON artist.id = album_artist.artist_id + WHERE album_artist.album_id = NEW.id + ), '') + ), + 'B' + ); + RETURN NEW; +END; +$$; +--> statement-breakpoint +CREATE TRIGGER album_search_vector_before_write +BEFORE INSERT OR UPDATE OF title ON album +FOR EACH ROW EXECUTE FUNCTION set_album_search_vector(); +--> statement-breakpoint +CREATE FUNCTION refresh_album_search_vector_from_artist_link() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +DECLARE + target_album_id integer; +BEGIN + IF TG_OP = 'DELETE' THEN + target_album_id := OLD.album_id; + ELSE + target_album_id := NEW.album_id; + END IF; + + UPDATE album + SET title = title + WHERE id = target_album_id; + RETURN NULL; +END; +$$; +--> statement-breakpoint +CREATE TRIGGER album_artist_refresh_search_vector +AFTER INSERT OR UPDATE OR DELETE ON album_artist +FOR EACH ROW EXECUTE FUNCTION refresh_album_search_vector_from_artist_link(); +--> statement-breakpoint +CREATE FUNCTION refresh_album_search_vector_from_artist() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + UPDATE album + SET title = title + WHERE id IN ( + SELECT album_id FROM album_artist WHERE artist_id = NEW.id + ); + RETURN NEW; +END; +$$; +--> statement-breakpoint +CREATE TRIGGER artist_refresh_search_vector +AFTER UPDATE OF name ON artist +FOR EACH ROW EXECUTE FUNCTION refresh_album_search_vector_from_artist(); +--> statement-breakpoint +CREATE FUNCTION set_review_updated_at() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + NEW.updated_at := now(); + RETURN NEW; +END; +$$; +--> statement-breakpoint +CREATE TRIGGER review_set_updated_at +BEFORE UPDATE ON review +FOR EACH ROW EXECUTE FUNCTION set_review_updated_at(); +--> statement-breakpoint +UPDATE album SET title = title; diff --git a/server/drizzle/meta/0001_snapshot.json b/server/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..b2fd311 --- /dev/null +++ b/server/drizzle/meta/0001_snapshot.json @@ -0,0 +1,579 @@ +{ + "id": "94caae8d-f082-4ee7-8111-f0f8b418bff6", + "prevId": "3d3f861d-1cfa-4d97-89d0-168270b3279c", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.album": { + "name": "album", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "release_year": { + "name": "release_year", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "search_vector": { + "name": "search_vector", + "type": "tsvector", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "album_search_vector_idx": { + "name": "album_search_vector_idx", + "columns": [ + { + "expression": "search_vector", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.album_artist": { + "name": "album_artist", + "schema": "", + "columns": { + "album_id": { + "name": "album_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "artist_id": { + "name": "artist_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "album_artist_album_id_album_id_fk": { + "name": "album_artist_album_id_album_id_fk", + "tableFrom": "album_artist", + "tableTo": "album", + "columnsFrom": ["album_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "album_artist_artist_id_artist_id_fk": { + "name": "album_artist_artist_id_artist_id_fk", + "tableFrom": "album_artist", + "tableTo": "artist", + "columnsFrom": ["artist_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "album_artist_album_id_artist_id_pk": { + "name": "album_artist_album_id_artist_id_pk", + "columns": ["album_id", "artist_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.album_genre": { + "name": "album_genre", + "schema": "", + "columns": { + "album_id": { + "name": "album_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "genre_id": { + "name": "genre_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "album_genre_album_id_album_id_fk": { + "name": "album_genre_album_id_album_id_fk", + "tableFrom": "album_genre", + "tableTo": "album", + "columnsFrom": ["album_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "album_genre_genre_id_genre_id_fk": { + "name": "album_genre_genre_id_genre_id_fk", + "tableFrom": "album_genre", + "tableTo": "genre", + "columnsFrom": ["genre_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "album_genre_album_id_genre_id_pk": { + "name": "album_genre_album_id_genre_id_pk", + "columns": ["album_id", "genre_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_user": { + "name": "app_user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.artist": { + "name": "artist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crate_item": { + "name": "crate_item", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "album_id": { + "name": "album_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "added_at": { + "name": "added_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "crate_item_user_album_unique": { + "name": "crate_item_user_album_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "album_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "crate_item_user_added_at_idx": { + "name": "crate_item_user_added_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "added_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "crate_item_user_id_app_user_id_fk": { + "name": "crate_item_user_id_app_user_id_fk", + "tableFrom": "crate_item", + "tableTo": "app_user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "crate_item_album_id_album_id_fk": { + "name": "crate_item_album_id_album_id_fk", + "tableFrom": "crate_item", + "tableTo": "album", + "columnsFrom": ["album_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.genre": { + "name": "genre", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "genre_name_unique": { + "name": "genre_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.review": { + "name": "review", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "album_id": { + "name": "album_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "rating": { + "name": "rating", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "review_user_album_unique": { + "name": "review_user_album_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "album_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "review_album_idx": { + "name": "review_album_idx", + "columns": [ + { + "expression": "album_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "review_user_id_app_user_id_fk": { + "name": "review_user_id_app_user_id_fk", + "tableFrom": "review", + "tableTo": "app_user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "review_album_id_album_id_fk": { + "name": "review_album_id_album_id_fk", + "tableFrom": "review", + "tableTo": "album", + "columnsFrom": ["album_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "review_rating_range": { + "name": "review_rating_range", + "value": "\"review\".\"rating\" BETWEEN 1 AND 5" + } + }, + "isRLSEnabled": false + }, + "public.track": { + "name": "track", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "album_id": { + "name": "album_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "track_number": { + "name": "track_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "duration_seconds": { + "name": "duration_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "track_album_track_number_unique": { + "name": "track_album_track_number_unique", + "columns": [ + { + "expression": "album_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "track_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "track_album_track_number_idx": { + "name": "track_album_track_number_idx", + "columns": [ + { + "expression": "album_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "track_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "track_album_id_album_id_fk": { + "name": "track_album_id_album_id_fk", + "tableFrom": "track", + "tableTo": "album", + "columnsFrom": ["album_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/server/drizzle/meta/_journal.json b/server/drizzle/meta/_journal.json index 8f92311..1781029 100644 --- a/server/drizzle/meta/_journal.json +++ b/server/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1791633902337, "tag": "0000_catalog", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1791974033850, + "tag": "0001_user-content-and-tracks", + "breakpoints": true } ] } diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index 8308888..12b9b7d 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -1,30 +1,31 @@ +import { sql } from 'drizzle-orm'; import { + check, customType, index, integer, pgTable, primaryKey, serial, + smallint, text, + timestamp, + uniqueIndex, + uuid, } from 'drizzle-orm/pg-core'; // Drizzle has no built-in tsvector type. const tsvector = customType<{ data: string }>({ dataType: () => 'tsvector' }); -/** - * The catalog tables that searchAlbums reads. Reviews and the crate are added - * by their own issues. - */ +/** The catalog tables used by searchAlbums and album details. */ export const album = pgTable( 'album', { id: serial('id').primaryKey(), title: text('title').notNull(), releaseYear: integer('release_year'), - // The words a search matches: the title plus the artist names, built with - // to_tsvector('simple', ...) when albums are inserted (see seed.ts). The - // 'simple' configuration lowercases every word and does no stemming, which - // is what makes the search case-insensitive and word-based. + // The words a search matches: the title plus artist names. The database + // trigger keeps this up to date when catalog data changes. searchVector: tsvector('search_vector'), }, // GIN is the index type for tsvector, so a word lookup does not scan the table. @@ -66,3 +67,66 @@ export const albumGenre = pgTable( }, (t) => [primaryKey({ columns: [t.albumId, t.genreId] })], ); + +export const track = pgTable( + 'track', + { + id: serial('id').primaryKey(), + albumId: integer('album_id') + .notNull() + .references(() => album.id, { onDelete: 'cascade' }), + title: text('title').notNull(), + trackNumber: integer('track_number').notNull(), + durationSeconds: integer('duration_seconds'), + }, + (t) => [ + uniqueIndex('track_album_track_number_unique').on(t.albumId, t.trackNumber), + index('track_album_track_number_idx').on(t.albumId, t.trackNumber), + ], +); + +export const appUser = pgTable('app_user', { + id: uuid('id').primaryKey(), + displayName: text('display_name').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), +}); + +export const review = pgTable( + 'review', + { + id: serial('id').primaryKey(), + userId: uuid('user_id') + .notNull() + .references(() => appUser.id, { onDelete: 'cascade' }), + albumId: integer('album_id') + .notNull() + .references(() => album.id, { onDelete: 'cascade' }), + rating: smallint('rating').notNull(), + comment: text('comment').notNull().default(''), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [ + uniqueIndex('review_user_album_unique').on(t.userId, t.albumId), + index('review_album_idx').on(t.albumId), + check('review_rating_range', sql`${t.rating} BETWEEN 1 AND 5`), + ], +); + +export const crateItem = pgTable( + 'crate_item', + { + id: serial('id').primaryKey(), + userId: uuid('user_id') + .notNull() + .references(() => appUser.id, { onDelete: 'cascade' }), + albumId: integer('album_id') + .notNull() + .references(() => album.id, { onDelete: 'cascade' }), + addedAt: timestamp('added_at', { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [ + uniqueIndex('crate_item_user_album_unique').on(t.userId, t.albumId), + index('crate_item_user_added_at_idx').on(t.userId, t.addedAt, t.id), + ], +); diff --git a/server/src/db/seed.ts b/server/src/db/seed.ts index bb8533e..64f4b6e 100644 --- a/server/src/db/seed.ts +++ b/server/src/db/seed.ts @@ -89,17 +89,6 @@ await db.transaction(async (tx) => { .insert(albumGenre) .values(a.genres.map((n) => ({ albumId, genreId: genreId.get(n)! }))); } - - // Build the search document from the title and every artist name, so a - // search for "coltrane" finds his albums too. - await tx.execute(sql` - UPDATE album SET search_vector = to_tsvector('simple', - album.title || ' ' || coalesce(( - SELECT string_agg(artist.name, ' ') - FROM album_artist JOIN artist ON artist.id = album_artist.artist_id - WHERE album_artist.album_id = album.id - ), '')) - `); }); await pool.end(); From c2e813c2f13f4006969115846d3a70e047410d47 Mon Sep 17 00:00:00 2001 From: Robert Andreas Kyllo Date: Wed, 14 Oct 2026 21:54:29 +0300 Subject: [PATCH 2/2] fix: #5 refresh both albums after artist move --- docs/VM-setup.md | 4 ++++ .../drizzle/0001_user-content-and-tracks.sql | 18 +++++++++--------- server/src/db/seed.ts | 3 ++- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/docs/VM-setup.md b/docs/VM-setup.md index 59d184b..c3a916c 100644 --- a/docs/VM-setup.md +++ b/docs/VM-setup.md @@ -18,6 +18,10 @@ pnpm --filter @t19/server build pnpm --filter @t19/server start # http://localhost:3001/graphql ``` +`db:seed` is a development/reset command. It replaces the catalog and, through +foreign-key cascades, deletes its associated reviews and crate items. Do not run +it against a database with user content you intend to keep. + ## Smoke suite Run on the VM after a deploy. Each command prints what to expect. The diff --git a/server/drizzle/0001_user-content-and-tracks.sql b/server/drizzle/0001_user-content-and-tracks.sql index 0965cbd..0e8f1aa 100644 --- a/server/drizzle/0001_user-content-and-tracks.sql +++ b/server/drizzle/0001_user-content-and-tracks.sql @@ -73,18 +73,18 @@ CREATE FUNCTION refresh_album_search_vector_from_artist_link() RETURNS trigger LANGUAGE plpgsql AS $$ -DECLARE - target_album_id integer; BEGIN - IF TG_OP = 'DELETE' THEN - target_album_id := OLD.album_id; - ELSE - target_album_id := NEW.album_id; + IF TG_OP <> 'DELETE' THEN + UPDATE album + SET title = title + WHERE id = NEW.album_id; END IF; - UPDATE album - SET title = title - WHERE id = target_album_id; + IF TG_OP <> 'INSERT' AND OLD.album_id IS DISTINCT FROM NEW.album_id THEN + UPDATE album + SET title = title + WHERE id = OLD.album_id; + END IF; RETURN NULL; END; $$; diff --git a/server/src/db/seed.ts b/server/src/db/seed.ts index 64f4b6e..7983a5f 100644 --- a/server/src/db/seed.ts +++ b/server/src/db/seed.ts @@ -4,7 +4,8 @@ import { album, albumArtist, albumGenre, artist, genre } from './schema.js'; /** * A small hand-written catalog, enough to exercise search, filters, facets and - * paging. Replaces whatever catalog is already in the database. + * paging. Development/reset only: the cascading truncate also deletes reviews + * and crate items connected to the catalog. */ const albums: { title: string;