diff --git a/db/migrations/0003_nodes_user_slug_namespace.sql b/db/migrations/0003_nodes_user_slug_namespace.sql new file mode 100644 index 0000000..2aa2723 --- /dev/null +++ b/db/migrations/0003_nodes_user_slug_namespace.sql @@ -0,0 +1,9 @@ +-- Node slugs become unique per user instead of globally unique (same as routes in 0002). +-- Also re-asserts the routes change from 0002: its snapshot never recorded it, and production +-- had drifted back to the global index until 2026-09-22. +-- Create each per-user index before dropping the global one so uniqueness is never unenforced, +-- and use IF [NOT] EXISTS so the migration is safe on databases where part of it already applied. +CREATE UNIQUE INDEX IF NOT EXISTS "nodes_user_id_slug_key" ON "nodes" USING btree ("user_id","slug");--> statement-breakpoint +DROP INDEX IF EXISTS "nodes_slug_key";--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "routes_user_id_slug_key" ON "routes" USING btree ("user_id","slug");--> statement-breakpoint +DROP INDEX IF EXISTS "routes_slug_key"; diff --git a/db/migrations/meta/0003_snapshot.json b/db/migrations/meta/0003_snapshot.json new file mode 100644 index 0000000..2e67fdc --- /dev/null +++ b/db/migrations/meta/0003_snapshot.json @@ -0,0 +1,495 @@ +{ + "id": "198d5404-0c25-4a1f-abea-fbee3eacf5a9", + "prevId": "a681c8cb-a6bc-492b-a555-0daa3a330583", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.events": { + "name": "events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "node_id": { + "name": "node_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "route_id": { + "name": "route_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "event_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "headers_json": { + "name": "headers_json", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_text": { + "name": "body_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "received_at": { + "name": "received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "acked_at": { + "name": "acked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "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": { + "events_node_status_idx": { + "name": "events_node_status_idx", + "columns": [ + { + "expression": "node_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "events_node_status_received_idx": { + "name": "events_node_status_received_idx", + "columns": [ + { + "expression": "node_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "received_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "events_route_received_idx": { + "name": "events_route_received_idx", + "columns": [ + { + "expression": "route_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "received_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "events_expires_at_idx": { + "name": "events_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "events_node_id_nodes_id_fk": { + "name": "events_node_id_nodes_id_fk", + "tableFrom": "events", + "tableTo": "nodes", + "columnsFrom": [ + "node_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "events_route_id_routes_id_fk": { + "name": "events_route_id_routes_id_fk", + "tableFrom": "events", + "tableTo": "routes", + "columnsFrom": [ + "route_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.nodes": { + "name": "nodes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "node_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "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": { + "nodes_user_id_idx": { + "name": "nodes_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "nodes_user_id_slug_key": { + "name": "nodes_user_id_slug_key", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routes": { + "name": "routes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "node_id": { + "name": "node_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "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": { + "routes_node_id_idx": { + "name": "routes_node_id_idx", + "columns": [ + { + "expression": "node_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routes_user_id_idx": { + "name": "routes_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routes_user_id_slug_key": { + "name": "routes_user_id_slug_key", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routes_node_id_nodes_id_fk": { + "name": "routes_node_id_nodes_id_fk", + "tableFrom": "routes", + "tableTo": "nodes", + "columnsFrom": [ + "node_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.event_status": { + "name": "event_status", + "schema": "public", + "values": [ + "pending", + "leased", + "delivered", + "failed", + "expired" + ] + }, + "public.node_status": { + "name": "node_status", + "schema": "public", + "values": [ + "active", + "disabled" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/db/migrations/meta/_journal.json b/db/migrations/meta/_journal.json index 3040add..87940b8 100644 --- a/db/migrations/meta/_journal.json +++ b/db/migrations/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1774322729407, "tag": "0002_routes_user_slug_namespace", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1790098916448, + "tag": "0003_nodes_user_slug_namespace", + "breakpoints": true } ] } \ No newline at end of file diff --git a/db/schema.ts b/db/schema.ts index db5750d..db945a9 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -34,7 +34,7 @@ export const nodes = pgTable( }, (table) => [ index('nodes_user_id_idx').on(table.userId), - uniqueIndex('nodes_slug_key').on(table.slug), + uniqueIndex('nodes_user_id_slug_key').on(table.userId, table.slug), ] ); diff --git a/tests/db/schema-unique-indexes.test.ts b/tests/db/schema-unique-indexes.test.ts new file mode 100644 index 0000000..f6bc859 --- /dev/null +++ b/tests/db/schema-unique-indexes.test.ts @@ -0,0 +1,27 @@ +import { getTableConfig } from 'drizzle-orm/pg-core'; +import { describe, expect, it } from 'vitest'; + +import { nodes, routes } from '@/db/schema'; + +function uniqueIndexColumns(table: Parameters[0]) { + return getTableConfig(table) + .indexes.filter((idx) => idx.config.unique) + .map((idx) => ({ + name: idx.config.name, + columns: idx.config.columns.map((col) => ('name' in col ? col.name : String(col))), + })); +} + +describe('slug uniqueness is scoped per user', () => { + it('routes: slug is unique per user, not globally', () => { + expect(uniqueIndexColumns(routes)).toEqual([ + { name: 'routes_user_id_slug_key', columns: ['user_id', 'slug'] }, + ]); + }); + + it('nodes: slug is unique per user, not globally', () => { + expect(uniqueIndexColumns(nodes)).toEqual([ + { name: 'nodes_user_id_slug_key', columns: ['user_id', 'slug'] }, + ]); + }); +});