diff --git a/src/modules/cards/infrastructure/repositories/schema/card.sql.ts b/src/modules/cards/infrastructure/repositories/schema/card.sql.ts index 4939fb73..0d91e5f7 100644 --- a/src/modules/cards/infrastructure/repositories/schema/card.sql.ts +++ b/src/modules/cards/infrastructure/repositories/schema/card.sql.ts @@ -51,6 +51,18 @@ export const cards: PgTableWithColumns = pgTable( parentTypeIdx: index('idx_cards_parent_type') .on(table.parentCardId, table.type) .where(sql`type = 'NOTE'`), + // Index for time-series queries on created_at + createdAtIdx: index('idx_cards_created_at').on(table.createdAt), + // Composite index for getUserEngagementStats optimization + authorCreatedAtIdx: index('idx_cards_author_created_at').on( + table.authorId, + table.createdAt, + ), + // Composite index for getContentBreakdownStats optimization + createdAtUrlTypeIdx: index('idx_cards_created_at_url_type').on( + table.createdAt, + table.urlType, + ), }; }, ); diff --git a/src/modules/cards/infrastructure/repositories/schema/collection.sql.ts b/src/modules/cards/infrastructure/repositories/schema/collection.sql.ts index 0f6147d0..a19310b1 100644 --- a/src/modules/cards/infrastructure/repositories/schema/collection.sql.ts +++ b/src/modules/cards/infrastructure/repositories/schema/collection.sql.ts @@ -35,6 +35,17 @@ export const collections = pgTable( table.authorId, table.updatedAt, ), + // Index for time-series queries on created_at + createdAtIdx: index('idx_collections_created_at').on(table.createdAt), + // Composite index for getUserEngagementStats optimization + authorCreatedAtIdx: index('idx_collections_author_created_at').on( + table.authorId, + table.createdAt, + ), + // Composite index for getContentBreakdownStats optimization + createdAtAccessTypeIdx: index( + 'idx_collections_created_at_access_type', + ).on(table.createdAt, table.accessType), }; }, ); diff --git a/src/modules/cards/infrastructure/repositories/schema/connection.sql.ts b/src/modules/cards/infrastructure/repositories/schema/connection.sql.ts index 4aeb1ab8..9e4c7b47 100644 --- a/src/modules/cards/infrastructure/repositories/schema/connection.sql.ts +++ b/src/modules/cards/infrastructure/repositories/schema/connection.sql.ts @@ -55,6 +55,10 @@ export const connections = pgTable( table.curatorId, table.createdAt.desc(), ), + // Composite index for getContentBreakdownStats optimization + createdAtConnectionTypeIdx: index( + 'idx_connections_created_at_connection_type', + ).on(table.createdAt, table.connectionType), }; }, ); diff --git a/src/modules/cards/tests/test-utils/createTestSchema.ts b/src/modules/cards/tests/test-utils/createTestSchema.ts index fbf8611c..ed2fdbb3 100644 --- a/src/modules/cards/tests/test-utils/createTestSchema.ts +++ b/src/modules/cards/tests/test-utils/createTestSchema.ts @@ -270,6 +270,16 @@ export async function createTestSchema(db: PostgresJsDatabase) { await db.execute(sql` CREATE INDEX IF NOT EXISTS cards_author_id_idx ON cards(author_id); `); + // New indexes for stats query optimization + await db.execute(sql` + CREATE INDEX IF NOT EXISTS idx_cards_created_at ON cards(created_at); + `); + await db.execute(sql` + CREATE INDEX IF NOT EXISTS idx_cards_author_created_at ON cards(author_id, created_at); + `); + await db.execute(sql` + CREATE INDEX IF NOT EXISTS idx_cards_created_at_url_type ON cards(created_at, url_type); + `); // Collections table indexes await db.execute(sql` @@ -278,6 +288,16 @@ export async function createTestSchema(db: PostgresJsDatabase) { await db.execute(sql` CREATE INDEX IF NOT EXISTS collections_author_updated_at_idx ON collections(author_id, updated_at); `); + // New indexes for stats query optimization + await db.execute(sql` + CREATE INDEX IF NOT EXISTS idx_collections_created_at ON collections(created_at); + `); + await db.execute(sql` + CREATE INDEX IF NOT EXISTS idx_collections_author_created_at ON collections(author_id, created_at); + `); + await db.execute(sql` + CREATE INDEX IF NOT EXISTS idx_collections_created_at_access_type ON collections(created_at, access_type); + `); // Collection cards table indexes await db.execute(sql` @@ -294,6 +314,13 @@ export async function createTestSchema(db: PostgresJsDatabase) { await db.execute(sql` CREATE INDEX IF NOT EXISTS idx_follows_target ON follows(target_id, target_type); `); + // New indexes for stats query optimization + await db.execute(sql` + CREATE INDEX IF NOT EXISTS idx_follows_created_at ON follows(created_at); + `); + await db.execute(sql` + CREATE INDEX IF NOT EXISTS idx_follows_follower_created_at ON follows(follower_id, created_at); + `); // Connections table indexes await db.execute(sql` @@ -311,6 +338,10 @@ export async function createTestSchema(db: PostgresJsDatabase) { await db.execute(sql` CREATE INDEX IF NOT EXISTS connections_curator_created_at_idx ON connections(curator_id, created_at DESC); `); + // New indexes for stats query optimization + await db.execute(sql` + CREATE INDEX IF NOT EXISTS idx_connections_created_at_connection_type ON connections(created_at, connection_type); + `); // Following feed items indexes await db.execute(sql` diff --git a/src/modules/user/infrastructure/repositories/DrizzleUserStatsRepository.ts b/src/modules/user/infrastructure/repositories/DrizzleUserStatsRepository.ts index 1465ca4d..a19f0850 100644 --- a/src/modules/user/infrastructure/repositories/DrizzleUserStatsRepository.ts +++ b/src/modules/user/infrastructure/repositories/DrizzleUserStatsRepository.ts @@ -94,54 +94,75 @@ export class DrizzleUserStatsRepository implements IUserStatsRepository { async getUserEngagementStats( options: UserEngagementStatsOptions, ): Promise { - // Main snapshot query + // Main snapshot query - optimized to use CTEs and LEFT JOINs instead of correlated subqueries const snapshotQuery = sql` - WITH user_activity AS ( + WITH user_cards AS ( + SELECT author_id, COUNT(*) as card_count + FROM ${cards} + WHERE type = 'URL' + GROUP BY author_id + ), + user_collections AS ( + SELECT author_id, COUNT(*) as collection_count + FROM ${collections} + GROUP BY author_id + ), + user_connections AS ( + SELECT curator_id, COUNT(*) as connection_count + FROM ${connections} + GROUP BY curator_id + ), + user_follows AS ( + SELECT follower_id, COUNT(*) as follow_count + FROM ${follows} + GROUP BY follower_id + ), + user_contributions AS ( + SELECT added_by, COUNT(*) as contribution_count + FROM ${collectionCards} + GROUP BY added_by + ), + user_activity AS ( SELECT u.id AS user_id, - EXISTS(SELECT 1 FROM ${cards} WHERE author_id = u.id AND type = 'URL') AS has_cards, - EXISTS(SELECT 1 FROM ${collections} WHERE author_id = u.id) AS has_collections, - EXISTS(SELECT 1 FROM ${connections} WHERE curator_id = u.id) AS has_connections, - EXISTS(SELECT 1 FROM ${follows} WHERE follower_id = u.id) AS has_follows, - EXISTS(SELECT 1 FROM ${collectionCards} WHERE added_by = u.id) AS has_contributions, - ( - (SELECT COUNT(*) FROM ${cards} WHERE author_id = u.id AND type = 'URL') + - (SELECT COUNT(*) FROM ${collections} WHERE author_id = u.id) + - (SELECT COUNT(*) FROM ${connections} WHERE curator_id = u.id) + - (SELECT COUNT(*) FROM ${follows} WHERE follower_id = u.id) - ) AS total_actions + COALESCE(uc.card_count, 0) > 0 AS has_cards, + COALESCE(ucol.collection_count, 0) > 0 AS has_collections, + COALESCE(ucon.connection_count, 0) > 0 AS has_connections, + COALESCE(uf.follow_count, 0) > 0 AS has_follows, + COALESCE(ucontr.contribution_count, 0) > 0 AS has_contributions, + COALESCE(uc.card_count, 0) + COALESCE(ucol.collection_count, 0) + + COALESCE(ucon.connection_count, 0) + COALESCE(uf.follow_count, 0) AS total_actions FROM ${users} u - ), - aggregated AS ( - SELECT - COUNT(*)::int AS total_users, - COUNT(*) FILTER ( - WHERE has_cards OR has_collections OR has_connections - OR has_follows OR has_contributions - )::int AS active_users, - COUNT(*) FILTER (WHERE has_cards)::int AS users_with_cards, - COUNT(*) FILTER (WHERE has_collections)::int AS users_with_collections, - COUNT(*) FILTER (WHERE has_connections)::int AS users_with_connections, - COUNT(*) FILTER (WHERE has_follows)::int AS users_with_follows, - COUNT(*) FILTER (WHERE has_contributions)::int AS users_with_contributions, - COALESCE(AVG(total_actions) FILTER (WHERE total_actions > 0), 0)::numeric AS avg_actions - FROM user_activity + LEFT JOIN user_cards uc ON u.id = uc.author_id + LEFT JOIN user_collections ucol ON u.id = ucol.author_id + LEFT JOIN user_connections ucon ON u.id = ucon.curator_id + LEFT JOIN user_follows uf ON u.id = uf.follower_id + LEFT JOIN user_contributions ucontr ON u.id = ucontr.added_by ) SELECT - total_users, - active_users, - (total_users - active_users) AS inactive_users, - users_with_cards, - users_with_collections, - users_with_connections, - users_with_follows, - users_with_contributions, + COUNT(*)::int AS total_users, + COUNT(*) FILTER ( + WHERE has_cards OR has_collections OR has_connections + OR has_follows OR has_contributions + )::int AS active_users, + COUNT(*) FILTER (WHERE has_cards)::int AS users_with_cards, + COUNT(*) FILTER (WHERE has_collections)::int AS users_with_collections, + COUNT(*) FILTER (WHERE has_connections)::int AS users_with_connections, + COUNT(*) FILTER (WHERE has_follows)::int AS users_with_follows, + COUNT(*) FILTER (WHERE has_contributions)::int AS users_with_contributions, + COALESCE(AVG(total_actions) FILTER (WHERE total_actions > 0), 0)::numeric AS avg_actions_per_active_user, CASE - WHEN total_users > 0 THEN (active_users::numeric / total_users::numeric) + WHEN COUNT(*) > 0 THEN (COUNT(*) FILTER ( + WHERE has_cards OR has_collections OR has_connections + OR has_follows OR has_contributions + )::numeric / COUNT(*)::numeric) ELSE 0 END AS activation_rate, - avg_actions AS avg_actions_per_active_user - FROM aggregated + COUNT(*)::int - COUNT(*) FILTER ( + WHERE has_cards OR has_collections OR has_connections + OR has_follows OR has_contributions + )::int AS inactive_users + FROM user_activity `; const result = await this.db.execute(snapshotQuery); @@ -175,26 +196,40 @@ export class DrizzleUserStatsRepository implements IUserStatsRepository { ): Promise { const { interval = 'day', limit = 30 } = options; + // Optimized version using UNION ALL instead of multiple MIN subqueries const timeSeriesQuery = sql` - WITH user_first_activity AS ( + WITH all_user_actions AS ( + SELECT author_id as user_id, created_at + FROM ${cards} + WHERE type = 'URL' + UNION ALL + SELECT author_id as user_id, created_at + FROM ${collections} + UNION ALL + SELECT curator_id as user_id, created_at + FROM ${connections} + UNION ALL + SELECT follower_id as user_id, created_at + FROM ${follows} + UNION ALL + SELECT added_by as user_id, added_at as created_at + FROM ${collectionCards} + ), + user_first_activity AS ( SELECT u.id AS user_id, u.linked_at, - LEAST( - COALESCE((SELECT MIN(created_at) FROM ${cards} WHERE author_id = u.id AND type = 'URL'), '9999-12-31'::timestamp), - COALESCE((SELECT MIN(created_at) FROM ${collections} WHERE author_id = u.id), '9999-12-31'::timestamp), - COALESCE((SELECT MIN(created_at) FROM ${connections} WHERE curator_id = u.id), '9999-12-31'::timestamp), - COALESCE((SELECT MIN(created_at) FROM ${follows} WHERE follower_id = u.id), '9999-12-31'::timestamp), - COALESCE((SELECT MIN(added_at) FROM ${collectionCards} WHERE added_by = u.id), '9999-12-31'::timestamp) - ) AS first_action_at + MIN(aua.created_at) AS first_action_at FROM ${users} u + LEFT JOIN all_user_actions aua ON u.id = aua.user_id + GROUP BY u.id, u.linked_at ), period_stats AS ( SELECT date_trunc(${interval}, first_action_at) AS period, - COUNT(*) FILTER (WHERE first_action_at < '9999-12-31'::timestamp) AS newly_activated + COUNT(*) as newly_activated FROM user_first_activity - WHERE first_action_at < '9999-12-31'::timestamp + WHERE first_action_at IS NOT NULL GROUP BY period ), cumulative AS ( @@ -229,44 +264,63 @@ export class DrizzleUserStatsRepository implements IUserStatsRepository { ): Promise { const { interval, limit } = options; - // Query for content creation over time + // Optimized query: pre-aggregate each table before joining const activityQuery = sql` - WITH date_periods AS ( - SELECT DISTINCT date_trunc(${interval}, created_at) AS period - FROM ( - SELECT created_at FROM ${cards} WHERE type = 'URL' - UNION ALL - SELECT created_at FROM ${collections} - UNION ALL - SELECT created_at FROM ${connections} - UNION ALL - SELECT created_at FROM ${follows} - ) all_dates + WITH card_counts AS ( + SELECT + date_trunc(${interval}, created_at) AS period, + COUNT(*)::int AS count + FROM ${cards} + WHERE type = 'URL' AND created_at IS NOT NULL + GROUP BY period + ), + collection_counts AS ( + SELECT + date_trunc(${interval}, created_at) AS period, + COUNT(*)::int AS count + FROM ${collections} WHERE created_at IS NOT NULL + GROUP BY period ), - activity_counts AS ( + connection_counts AS ( SELECT - dp.period, - COALESCE(COUNT(DISTINCT c.id), 0)::int AS cards_created, - COALESCE(COUNT(DISTINCT col.id), 0)::int AS collections_created, - COALESCE(COUNT(DISTINCT con.id), 0)::int AS connections_created, - COALESCE(COUNT(DISTINCT f.follower_id || '-' || f.target_id || '-' || f.target_type), 0)::int AS follows_created - FROM date_periods dp - LEFT JOIN ${cards} c ON date_trunc(${interval}, c.created_at) = dp.period AND c.type = 'URL' - LEFT JOIN ${collections} col ON date_trunc(${interval}, col.created_at) = dp.period - LEFT JOIN ${connections} con ON date_trunc(${interval}, con.created_at) = dp.period - LEFT JOIN ${follows} f ON date_trunc(${interval}, f.created_at) = dp.period - GROUP BY dp.period + date_trunc(${interval}, created_at) AS period, + COUNT(*)::int AS count + FROM ${connections} + WHERE created_at IS NOT NULL + GROUP BY period + ), + follow_counts AS ( + SELECT + date_trunc(${interval}, created_at) AS period, + COUNT(*)::int AS count + FROM ${follows} + WHERE created_at IS NOT NULL + GROUP BY period + ), + all_periods AS ( + SELECT period FROM card_counts + UNION + SELECT period FROM collection_counts + UNION + SELECT period FROM connection_counts + UNION + SELECT period FROM follow_counts ) SELECT - period::text AS date, - cards_created, - collections_created, - connections_created, - follows_created, - (cards_created + collections_created + connections_created + follows_created) AS total_actions - FROM activity_counts - ORDER BY period DESC + ap.period::text AS date, + COALESCE(cc.count, 0) AS cards_created, + COALESCE(col.count, 0) AS collections_created, + COALESCE(con.count, 0) AS connections_created, + COALESCE(fc.count, 0) AS follows_created, + COALESCE(cc.count, 0) + COALESCE(col.count, 0) + + COALESCE(con.count, 0) + COALESCE(fc.count, 0) AS total_actions + FROM all_periods ap + LEFT JOIN card_counts cc ON ap.period = cc.period + LEFT JOIN collection_counts col ON ap.period = col.period + LEFT JOIN connection_counts con ON ap.period = con.period + LEFT JOIN follow_counts fc ON ap.period = fc.period + ORDER BY ap.period DESC LIMIT ${limit} `; @@ -327,32 +381,53 @@ export class DrizzleUserStatsRepository implements IUserStatsRepository { ): Promise { const { interval, limit } = options; - // Query for URL cards breakdown by type over time + // Optimized query for URL cards: use incremental aggregation with window functions const urlCardsQuery = sql` - WITH periods AS ( - SELECT DISTINCT date_trunc(${interval}, created_at) AS period + WITH all_cards AS ( + SELECT + COALESCE(url_type, 'unspecified') AS url_type, + created_at FROM ${cards} WHERE type = 'URL' AND created_at IS NOT NULL ), - url_card_counts AS ( + all_periods AS ( + SELECT DISTINCT date_trunc(${interval}, created_at) AS period + FROM all_cards + ), + all_types AS ( + SELECT DISTINCT url_type + FROM all_cards + ), + period_type_grid AS ( + SELECT p.period, t.url_type + FROM all_periods p + CROSS JOIN all_types t + ), + period_counts AS ( + SELECT + date_trunc(${interval}, created_at) AS period, + url_type, + COUNT(*)::int AS period_count + FROM all_cards + GROUP BY 1, 2 + ), + cumulative_counts AS ( SELECT - p.period, - COALESCE(c.url_type, 'unspecified') AS url_type, - COUNT(c.id) AS count - FROM periods p - CROSS JOIN LATERAL ( - SELECT * FROM ${cards} - WHERE type = 'URL' - AND created_at <= p.period + ('1 ' || ${interval})::interval - ) c - GROUP BY p.period, c.url_type + ptg.period, + ptg.url_type, + SUM(COALESCE(pc.period_count, 0)) OVER ( + PARTITION BY ptg.url_type + ORDER BY ptg.period + ) AS cumulative_count + FROM period_type_grid ptg + LEFT JOIN period_counts pc ON ptg.period = pc.period AND ptg.url_type = pc.url_type ), url_card_aggregated AS ( SELECT period::text AS date, - jsonb_object_agg(url_type, count) AS by_type, - SUM(count)::int AS total - FROM url_card_counts + jsonb_object_agg(url_type, cumulative_count) AS by_type, + SUM(cumulative_count)::int AS total + FROM cumulative_counts GROUP BY period ORDER BY period DESC LIMIT ${limit} @@ -360,31 +435,32 @@ export class DrizzleUserStatsRepository implements IUserStatsRepository { SELECT * FROM url_card_aggregated `; - // Query for collections breakdown by access type over time + // Optimized query for collections: use incremental aggregation with window functions const collectionsQuery = sql` - WITH periods AS ( - SELECT DISTINCT date_trunc(${interval}, created_at) AS period + WITH period_collection_counts AS ( + SELECT + date_trunc(${interval}, created_at) AS period, + access_type, + COUNT(*)::int AS period_count FROM ${collections} WHERE created_at IS NOT NULL - ), - collection_counts AS ( - SELECT - p.period, - col.access_type, - COUNT(col.id) AS count - FROM periods p - CROSS JOIN LATERAL ( - SELECT * FROM ${collections} - WHERE created_at <= p.period + ('1 ' || ${interval})::interval - ) col - GROUP BY p.period, col.access_type + GROUP BY 1, 2 ), collection_aggregated AS ( SELECT period::text AS date, - jsonb_object_agg(access_type, count) AS by_access_type, - SUM(count)::int AS total - FROM collection_counts + jsonb_object_agg(access_type, cumulative_count) AS by_access_type, + SUM(cumulative_count)::int AS total + FROM ( + SELECT + period, + access_type, + SUM(period_count) OVER ( + PARTITION BY access_type + ORDER BY period + ) AS cumulative_count + FROM period_collection_counts + ) cumulative_data GROUP BY period ORDER BY period DESC LIMIT ${limit} @@ -392,31 +468,53 @@ export class DrizzleUserStatsRepository implements IUserStatsRepository { SELECT * FROM collection_aggregated `; - // Query for connections breakdown by type over time + // Optimized query for connections: use incremental aggregation with window functions const connectionsQuery = sql` - WITH periods AS ( - SELECT DISTINCT date_trunc(${interval}, created_at) AS period + WITH all_connections AS ( + SELECT + COALESCE(connection_type, 'unspecified') AS connection_type, + created_at FROM ${connections} WHERE created_at IS NOT NULL ), - connection_counts AS ( + all_periods AS ( + SELECT DISTINCT date_trunc(${interval}, created_at) AS period + FROM all_connections + ), + all_types AS ( + SELECT DISTINCT connection_type + FROM all_connections + ), + period_type_grid AS ( + SELECT p.period, t.connection_type + FROM all_periods p + CROSS JOIN all_types t + ), + period_counts AS ( + SELECT + date_trunc(${interval}, created_at) AS period, + connection_type, + COUNT(*)::int AS period_count + FROM all_connections + GROUP BY 1, 2 + ), + cumulative_counts AS ( SELECT - p.period, - COALESCE(con.connection_type, 'unspecified') AS connection_type, - COUNT(con.id) AS count - FROM periods p - CROSS JOIN LATERAL ( - SELECT * FROM ${connections} - WHERE created_at <= p.period + ('1 ' || ${interval})::interval - ) con - GROUP BY p.period, con.connection_type + ptg.period, + ptg.connection_type, + SUM(COALESCE(pc.period_count, 0)) OVER ( + PARTITION BY ptg.connection_type + ORDER BY ptg.period + ) AS cumulative_count + FROM period_type_grid ptg + LEFT JOIN period_counts pc ON ptg.period = pc.period AND ptg.connection_type = pc.connection_type ), connection_aggregated AS ( SELECT period::text AS date, - jsonb_object_agg(connection_type, count) AS by_type, - SUM(count)::int AS total - FROM connection_counts + jsonb_object_agg(connection_type, cumulative_count) AS by_type, + SUM(cumulative_count)::int AS total + FROM cumulative_counts GROUP BY period ORDER BY period DESC LIMIT ${limit} diff --git a/src/modules/user/infrastructure/repositories/schema/follows.sql.ts b/src/modules/user/infrastructure/repositories/schema/follows.sql.ts index 540d7b2c..5b5191db 100644 --- a/src/modules/user/infrastructure/repositories/schema/follows.sql.ts +++ b/src/modules/user/infrastructure/repositories/schema/follows.sql.ts @@ -25,5 +25,12 @@ export const follows = pgTable( }), followerIdx: index('idx_follows_follower').on(table.followerId), targetIdx: index('idx_follows_target').on(table.targetId, table.targetType), + // Index for time-series queries on created_at + createdAtIdx: index('idx_follows_created_at').on(table.createdAt), + // Composite index for getUserEngagementStats optimization + followerCreatedAtIdx: index('idx_follows_follower_created_at').on( + table.followerId, + table.createdAt, + ), }), ); diff --git a/src/modules/user/tests/infrastructure/DrizzleUserStatsRepository.integration.test.ts b/src/modules/user/tests/infrastructure/DrizzleUserStatsRepository.integration.test.ts new file mode 100644 index 00000000..939b87fb --- /dev/null +++ b/src/modules/user/tests/infrastructure/DrizzleUserStatsRepository.integration.test.ts @@ -0,0 +1,842 @@ +import { + PostgreSqlContainer, + StartedPostgreSqlContainer, +} from '@testcontainers/postgresql'; +import postgres from 'postgres'; +import { drizzle, PostgresJsDatabase } from 'drizzle-orm/postgres-js'; +import { DrizzleUserStatsRepository } from '../../infrastructure/repositories/DrizzleUserStatsRepository'; +import { createTestSchema } from '../../../cards/tests/test-utils/createTestSchema'; +import { users } from '../../infrastructure/repositories/schema/user.sql'; +import { cards } from '../../../cards/infrastructure/repositories/schema/card.sql'; +import { collections } from '../../../cards/infrastructure/repositories/schema/collection.sql'; +import { connections } from '../../../cards/infrastructure/repositories/schema/connection.sql'; +import { follows } from '../../infrastructure/repositories/schema/follows.sql'; +import { collectionCards } from '../../../cards/infrastructure/repositories/schema/collection.sql'; + +describe('DrizzleUserStatsRepository', () => { + let container: StartedPostgreSqlContainer; + let db: PostgresJsDatabase; + let statsRepository: DrizzleUserStatsRepository; + + beforeAll(async () => { + // Start PostgreSQL container + container = await new PostgreSqlContainer('postgres:14').start(); + + // Create database connection + const connectionString = container.getConnectionUri(); + process.env.DATABASE_URL = connectionString; + const client = postgres(connectionString); + db = drizzle(client); + + // Create repository + statsRepository = new DrizzleUserStatsRepository(db); + + // Create schema using helper function + await createTestSchema(db); + }, 60000); // Increase timeout for container startup + + afterAll(async () => { + await container.stop(); + }); + + // Clear data between tests + beforeEach(async () => { + await db.delete(collectionCards); + await db.delete(follows); + await db.delete(connections); + await db.delete(collections); + await db.delete(cards); + await db.delete(users); + }); + + describe('getUserGrowthStats', () => { + it('should return user growth statistics with cumulative totals', async () => { + const baseTime = new Date('2026-04-01T00:00:00Z'); + + // Insert users at different times + await db.insert(users).values([ + { + id: 'user1', + handle: 'user1.test', + linkedAt: new Date(baseTime.getTime()), // Apr 1 + lastLoginAt: new Date(baseTime.getTime()), + }, + { + id: 'user2', + handle: 'user2.test', + linkedAt: new Date(baseTime.getTime()), // Apr 1 + lastLoginAt: new Date(baseTime.getTime()), + }, + { + id: 'user3', + handle: 'user3.test', + linkedAt: new Date(baseTime.getTime() + 24 * 60 * 60 * 1000), // Apr 2 + lastLoginAt: new Date(baseTime.getTime() + 24 * 60 * 60 * 1000), + }, + { + id: 'user4', + handle: 'user4.test', + linkedAt: new Date(baseTime.getTime() + 2 * 24 * 60 * 60 * 1000), // Apr 3 + lastLoginAt: new Date(baseTime.getTime() + 2 * 24 * 60 * 60 * 1000), + }, + ]); + + const result = await statsRepository.getUserGrowthStats({ + interval: 'day', + limit: 10, + }); + + expect(result.dataPoints).toHaveLength(3); + expect(result.currentTotal).toBe(4); + + // Check cumulative growth (chronological order) + expect(result.dataPoints[0]?.newUsers).toBe(2); // Apr 1: 2 new users + expect(result.dataPoints[0]?.totalUsers).toBe(2); // Total: 2 + + expect(result.dataPoints[1]?.newUsers).toBe(1); // Apr 2: 1 new user + expect(result.dataPoints[1]?.totalUsers).toBe(3); // Total: 3 + + expect(result.dataPoints[2]?.newUsers).toBe(1); // Apr 3: 1 new user + expect(result.dataPoints[2]?.totalUsers).toBe(4); // Total: 4 + }); + + it('should return empty data when no users exist', async () => { + const result = await statsRepository.getUserGrowthStats({ + interval: 'day', + limit: 10, + }); + + expect(result.dataPoints).toHaveLength(0); + expect(result.currentTotal).toBe(0); + }); + + it('should support week interval aggregation', async () => { + const baseTime = new Date('2026-04-01T00:00:00Z'); // Tuesday + + // Insert users across two weeks + await db.insert(users).values([ + { + id: 'user1', + handle: 'user1.test', + linkedAt: new Date(baseTime.getTime()), // Week 1 + lastLoginAt: new Date(baseTime.getTime()), + }, + { + id: 'user2', + handle: 'user2.test', + linkedAt: new Date(baseTime.getTime() + 7 * 24 * 60 * 60 * 1000), // Week 2 + lastLoginAt: new Date(baseTime.getTime() + 7 * 24 * 60 * 60 * 1000), + }, + ]); + + const result = await statsRepository.getUserGrowthStats({ + interval: 'week', + limit: 10, + }); + + expect(result.dataPoints).toHaveLength(2); + expect(result.currentTotal).toBe(2); + }); + }); + + describe('getUserEngagementStats', () => { + beforeEach(async () => { + const baseTime = new Date('2026-04-01T00:00:00Z'); + + // Create test users + await db.insert(users).values([ + { + id: 'active-user-1', + handle: 'active1.test', + linkedAt: baseTime, + lastLoginAt: baseTime, + }, + { + id: 'active-user-2', + handle: 'active2.test', + linkedAt: baseTime, + lastLoginAt: baseTime, + }, + { + id: 'inactive-user', + handle: 'inactive.test', + linkedAt: baseTime, + lastLoginAt: baseTime, + }, + ]); + }); + + it('should calculate engagement metrics for active and inactive users', async () => { + const baseTime = new Date('2026-04-01T00:00:00Z'); + + // Active user 1: has URL cards + await db.insert(cards).values([ + { + id: '10000000-0000-0000-0000-000000000001', + authorId: 'active-user-1', + type: 'URL', + contentData: { url: 'https://example.com' }, + url: 'https://example.com', + urlType: 'article', + createdAt: baseTime, + updatedAt: baseTime, + }, + ]); + + // Active user 2: has collections and follows + await db.insert(collections).values([ + { + id: '20000000-0000-0000-0000-000000000001', + authorId: 'active-user-2', + name: 'Test Collection', + accessType: 'OPEN', + createdAt: baseTime, + updatedAt: baseTime, + }, + ]); + + await db.insert(follows).values([ + { + followerId: 'active-user-2', + targetId: 'active-user-1', + targetType: 'USER', + createdAt: baseTime, + }, + ]); + + // Inactive user: no content + + const result = await statsRepository.getUserEngagementStats({}); + + expect(result.totalUsers).toBe(3); + expect(result.activeUsers).toBe(2); + expect(result.inactiveUsers).toBe(1); + expect(result.usersWithCards).toBe(1); + expect(result.usersWithCollections).toBe(1); + expect(result.usersWithFollows).toBe(1); + expect(result.activationRate).toBeCloseTo(2 / 3, 2); + expect(result.avgActionsPerActiveUser).toBeGreaterThan(0); + expect(result.dataPoints).toBeUndefined(); // No time series by default + }); + + it('should only count URL cards, not NOTE or HIGHLIGHT cards', async () => { + const baseTime = new Date('2026-04-01T00:00:00Z'); + + // User with URL cards (should count) + await db.insert(cards).values([ + { + id: '50000000-0000-0000-0000-000000000001', + authorId: 'active-user-1', + type: 'URL', + contentData: { url: 'https://example.com' }, + url: 'https://example.com', + urlType: 'article', + createdAt: baseTime, + updatedAt: baseTime, + }, + ]); + + // User with NOTE cards (should not count as having cards) + await db.insert(cards).values([ + { + id: '40000000-0000-0000-0000-000000000001', + authorId: 'active-user-2', + type: 'NOTE', + contentData: { text: 'A note' }, + createdAt: baseTime, + updatedAt: baseTime, + }, + ]); + + const result = await statsRepository.getUserEngagementStats({}); + + expect(result.totalUsers).toBe(3); + expect(result.usersWithCards).toBe(1); // Only active-user-1 has URL cards + expect(result.activeUsers).toBe(1); // Only active-user-1 is active + }); + + it('should include time series data when requested', async () => { + const baseTime = new Date('2026-04-01T00:00:00Z'); + + // Create URL cards at different times + await db.insert(cards).values([ + { + id: '10000000-0000-0000-0000-000000000001', + authorId: 'active-user-1', + type: 'URL', + contentData: { url: 'https://example.com' }, + url: 'https://example.com', + urlType: 'article', + createdAt: new Date(baseTime.getTime()), // Day 1 + updatedAt: baseTime, + }, + { + id: '10000000-0000-0000-0000-000000000002', + authorId: 'active-user-2', + type: 'URL', + contentData: { url: 'https://example2.com' }, + url: 'https://example2.com', + urlType: 'video', + createdAt: new Date(baseTime.getTime() + 24 * 60 * 60 * 1000), // Day 2 + updatedAt: new Date(baseTime.getTime() + 24 * 60 * 60 * 1000), + }, + ]); + + const result = await statsRepository.getUserEngagementStats({ + includeTimeSeries: true, + interval: 'day', + limit: 10, + }); + + expect(result.dataPoints).toBeDefined(); + expect(result.dataPoints!.length).toBeGreaterThan(0); + expect(result.dataPoints![0]).toHaveProperty('date'); + expect(result.dataPoints![0]).toHaveProperty('activeUsers'); + expect(result.dataPoints![0]).toHaveProperty('newlyActivatedUsers'); + expect(result.dataPoints![0]).toHaveProperty('cumulativeActiveUsers'); + }); + + it('should track users with contributions to others collections', async () => { + const baseTime = new Date('2026-04-01T00:00:00Z'); + + // Create a collection owned by active-user-1 + await db.insert(collections).values([ + { + id: '20000000-0000-0000-0000-000000000001', + authorId: 'active-user-1', + name: 'Test Collection', + accessType: 'OPEN', + createdAt: baseTime, + updatedAt: baseTime, + }, + ]); + + // Create a URL card + await db.insert(cards).values([ + { + id: '10000000-0000-0000-0000-000000000001', + authorId: 'active-user-2', + type: 'URL', + contentData: { url: 'https://example.com' }, + url: 'https://example.com', + urlType: 'article', + createdAt: baseTime, + updatedAt: baseTime, + }, + ]); + + // Active-user-2 adds a card to active-user-1's collection + await db.insert(collectionCards).values([ + { + id: '60000000-0000-0000-0000-000000000001', + collectionId: '20000000-0000-0000-0000-000000000001', + cardId: '10000000-0000-0000-0000-000000000001', + addedBy: 'active-user-2', // Different from collection owner + addedAt: baseTime, + }, + ]); + + const result = await statsRepository.getUserEngagementStats({}); + + expect(result.usersWithContributions).toBe(1); // active-user-2 contributed to someone else's collection + }); + }); + + describe('getDailyActivityStats', () => { + it('should return daily activity counts for all content types', async () => { + const baseTime = new Date('2026-04-01T00:00:00Z'); + + // Day 1: Create URL cards + await db.insert(cards).values([ + { + id: '10000000-0000-0000-0000-000000000001', + authorId: 'user1', + type: 'URL', + contentData: { url: 'https://example.com' }, + url: 'https://example.com', + urlType: 'article', + createdAt: baseTime, + updatedAt: baseTime, + }, + { + id: '10000000-0000-0000-0000-000000000002', + authorId: 'user2', + type: 'URL', + contentData: { url: 'https://example2.com' }, + url: 'https://example2.com', + urlType: 'video', + createdAt: baseTime, + updatedAt: baseTime, + }, + ]); + + // Day 1: Create collections + await db.insert(collections).values([ + { + id: '20000000-0000-0000-0000-000000000001', + authorId: 'user1', + name: 'Collection 1', + accessType: 'OPEN', + createdAt: baseTime, + updatedAt: baseTime, + }, + ]); + + // Day 2: Create connections and follows + const day2Time = new Date(baseTime.getTime() + 24 * 60 * 60 * 1000); + + await db.insert(connections).values([ + { + id: '30000000-0000-0000-0000-000000000001', + curatorId: 'user1', + sourceType: 'URL', + sourceValue: 'https://example.com', + targetType: 'URL', + targetValue: 'https://example2.com', + connectionType: 'SUPPORTS', + createdAt: day2Time, + updatedAt: day2Time, + }, + ]); + + await db.insert(follows).values([ + { + followerId: 'user1', + targetId: 'user2', + targetType: 'USER', + createdAt: day2Time, + }, + ]); + + const result = await statsRepository.getDailyActivityStats({ + interval: 'day', + limit: 10, + }); + + expect(result.dataPoints).toHaveLength(2); + + // Find day 1 and day 2 data points + const day1 = result.dataPoints.find((dp) => + dp.date.startsWith('2026-04-01'), + ); + const day2 = result.dataPoints.find((dp) => + dp.date.startsWith('2026-04-02'), + ); + + expect(day1).toBeDefined(); + expect(day1?.cardsCreated).toBe(2); + expect(day1?.collectionsCreated).toBe(1); + expect(day1?.connectionsCreated).toBe(0); + expect(day1?.followsCreated).toBe(0); + expect(day1?.totalActions).toBe(3); + + expect(day2).toBeDefined(); + expect(day2?.cardsCreated).toBe(0); + expect(day2?.collectionsCreated).toBe(0); + expect(day2?.connectionsCreated).toBe(1); + expect(day2?.followsCreated).toBe(1); + expect(day2?.totalActions).toBe(2); + + // Check totals + expect(result.totals.cardsCreated).toBe(2); + expect(result.totals.collectionsCreated).toBe(1); + expect(result.totals.connectionsCreated).toBe(1); + expect(result.totals.followsCreated).toBe(1); + expect(result.totals.totalActions).toBe(5); + }); + + it('should only count URL cards, not NOTE or HIGHLIGHT cards', async () => { + const baseTime = new Date('2026-04-01T00:00:00Z'); + + // Create URL cards (should count) + await db.insert(cards).values([ + { + id: '50000000-0000-0000-0000-000000000001', + authorId: 'user1', + type: 'URL', + contentData: { url: 'https://example.com' }, + url: 'https://example.com', + urlType: 'article', + createdAt: baseTime, + updatedAt: baseTime, + }, + ]); + + // Create NOTE cards (should not count) + await db.insert(cards).values([ + { + id: '40000000-0000-0000-0000-000000000001', + authorId: 'user2', + type: 'NOTE', + contentData: { text: 'A note' }, + createdAt: baseTime, + updatedAt: baseTime, + }, + ]); + + const result = await statsRepository.getDailyActivityStats({ + interval: 'day', + limit: 10, + }); + + expect(result.dataPoints).toHaveLength(1); + expect(result.dataPoints[0]?.cardsCreated).toBe(1); // Only URL card + expect(result.totals.cardsCreated).toBe(1); + }); + + it('should return empty data when no activity exists', async () => { + const result = await statsRepository.getDailyActivityStats({ + interval: 'day', + limit: 10, + }); + + expect(result.dataPoints).toHaveLength(0); + expect(result.totals.cardsCreated).toBe(0); + expect(result.totals.totalActions).toBe(0); + }); + + it('should support week interval aggregation', async () => { + const baseTime = new Date('2026-04-01T00:00:00Z'); + + // Create cards in two different weeks + await db.insert(cards).values([ + { + id: '10000000-0000-0000-0000-000000000001', + authorId: 'user1', + type: 'URL', + contentData: { url: 'https://example.com' }, + url: 'https://example.com', + urlType: 'article', + createdAt: baseTime, // Week 1 + updatedAt: baseTime, + }, + { + id: '10000000-0000-0000-0000-000000000002', + authorId: 'user2', + type: 'URL', + contentData: { url: 'https://example2.com' }, + url: 'https://example2.com', + urlType: 'video', + createdAt: new Date(baseTime.getTime() + 7 * 24 * 60 * 60 * 1000), // Week 2 + updatedAt: new Date(baseTime.getTime() + 7 * 24 * 60 * 60 * 1000), + }, + ]); + + const result = await statsRepository.getDailyActivityStats({ + interval: 'week', + limit: 10, + }); + + expect(result.dataPoints).toHaveLength(2); + expect(result.totals.cardsCreated).toBe(2); + }); + }); + + describe('getContentBreakdownStats', () => { + it('should return breakdown of URL cards by type over time', async () => { + const baseTime = new Date('2026-04-01T00:00:00Z'); + + // Day 1: Create URL cards of different types + await db.insert(cards).values([ + { + id: '10000000-0000-0000-0000-000000000001', + authorId: 'user1', + type: 'URL', + contentData: { url: 'https://example.com' }, + url: 'https://example.com', + urlType: 'article', + createdAt: baseTime, + updatedAt: baseTime, + }, + { + id: '10000000-0000-0000-0000-000000000002', + authorId: 'user2', + type: 'URL', + contentData: { url: 'https://example2.com' }, + url: 'https://example2.com', + urlType: 'article', + createdAt: baseTime, + updatedAt: baseTime, + }, + { + id: '10000000-0000-0000-0000-000000000003', + authorId: 'user3', + type: 'URL', + contentData: { url: 'https://example3.com' }, + url: 'https://example3.com', + urlType: 'video', + createdAt: baseTime, + updatedAt: baseTime, + }, + ]); + + // Day 2: Create more URL cards + const day2Time = new Date(baseTime.getTime() + 24 * 60 * 60 * 1000); + await db.insert(cards).values([ + { + id: '10000000-0000-0000-0000-000000000004', + authorId: 'user1', + type: 'URL', + contentData: { url: 'https://example4.com' }, + url: 'https://example4.com', + urlType: 'tool', + createdAt: day2Time, + updatedAt: day2Time, + }, + ]); + + const result = await statsRepository.getContentBreakdownStats({ + interval: 'day', + limit: 10, + }); + + expect(result.dataPoints.length).toBeGreaterThan(0); + + // Check current totals + expect(result.currentTotals.urlCards.total).toBe(4); + expect(result.currentTotals.urlCards.byType['article']).toBe(2); + expect(result.currentTotals.urlCards.byType['video']).toBe(1); + expect(result.currentTotals.urlCards.byType['tool']).toBe(1); + }); + + it('should return breakdown of collections by access type', async () => { + const baseTime = new Date('2026-04-01T00:00:00Z'); + + // Create collections with different access types + await db.insert(collections).values([ + { + id: '20000000-0000-0000-0000-000000000001', + authorId: 'user1', + name: 'Open Collection 1', + accessType: 'OPEN', + createdAt: baseTime, + updatedAt: baseTime, + }, + { + id: '20000000-0000-0000-0000-000000000002', + authorId: 'user2', + name: 'Open Collection 2', + accessType: 'OPEN', + createdAt: baseTime, + updatedAt: baseTime, + }, + { + id: '20000000-0000-0000-0000-000000000003', + authorId: 'user3', + name: 'Closed Collection', + accessType: 'CLOSED', + createdAt: baseTime, + updatedAt: baseTime, + }, + ]); + + const result = await statsRepository.getContentBreakdownStats({ + interval: 'day', + limit: 10, + }); + + expect(result.currentTotals.collections.total).toBe(3); + expect(result.currentTotals.collections.byAccessType['OPEN']).toBe(2); + expect(result.currentTotals.collections.byAccessType['CLOSED']).toBe(1); + }); + + it('should return breakdown of connections by type', async () => { + const baseTime = new Date('2026-04-01T00:00:00Z'); + + // Create connections with different types + await db.insert(connections).values([ + { + id: '30000000-0000-0000-0000-000000000001', + curatorId: 'user1', + sourceType: 'URL', + sourceValue: 'https://example.com', + targetType: 'URL', + targetValue: 'https://example2.com', + connectionType: 'SUPPORTS', + createdAt: baseTime, + updatedAt: baseTime, + }, + { + id: '30000000-0000-0000-0000-000000000002', + curatorId: 'user2', + sourceType: 'URL', + sourceValue: 'https://example3.com', + targetType: 'URL', + targetValue: 'https://example4.com', + connectionType: 'SUPPORTS', + createdAt: baseTime, + updatedAt: baseTime, + }, + { + id: '30000000-0000-0000-0000-000000000003', + curatorId: 'user3', + sourceType: 'URL', + sourceValue: 'https://example5.com', + targetType: 'URL', + targetValue: 'https://example6.com', + connectionType: 'OPPOSES', + createdAt: baseTime, + updatedAt: baseTime, + }, + { + id: '30000000-0000-0000-0000-000000000004', + curatorId: 'user1', + sourceType: 'URL', + sourceValue: 'https://example7.com', + targetType: 'URL', + targetValue: 'https://example8.com', + connectionType: null, // Should map to 'unspecified' + createdAt: baseTime, + updatedAt: baseTime, + }, + ]); + + const result = await statsRepository.getContentBreakdownStats({ + interval: 'day', + limit: 10, + }); + + expect(result.currentTotals.connections.total).toBe(4); + expect(result.currentTotals.connections.byType['SUPPORTS']).toBe(2); + expect(result.currentTotals.connections.byType['OPPOSES']).toBe(1); + expect(result.currentTotals.connections.byType['unspecified']).toBe(1); + }); + + it('should only count URL cards, not NOTE or HIGHLIGHT cards', async () => { + const baseTime = new Date('2026-04-01T00:00:00Z'); + + // Create URL cards (should count) + await db.insert(cards).values([ + { + id: '50000000-0000-0000-0000-000000000001', + authorId: 'user1', + type: 'URL', + contentData: { url: 'https://example.com' }, + url: 'https://example.com', + urlType: 'article', + createdAt: baseTime, + updatedAt: baseTime, + }, + ]); + + // Create NOTE cards (should not count) + await db.insert(cards).values([ + { + id: '40000000-0000-0000-0000-000000000001', + authorId: 'user2', + type: 'NOTE', + contentData: { text: 'A note' }, + createdAt: baseTime, + updatedAt: baseTime, + }, + ]); + + const result = await statsRepository.getContentBreakdownStats({ + interval: 'day', + limit: 10, + }); + + expect(result.currentTotals.urlCards.total).toBe(1); // Only URL card + }); + + it('should handle null urlType as unspecified', async () => { + const baseTime = new Date('2026-04-01T00:00:00Z'); + + // Create URL card with null urlType + await db.insert(cards).values([ + { + id: '10000000-0000-0000-0000-000000000001', + authorId: 'user1', + type: 'URL', + contentData: { url: 'https://example.com' }, + url: 'https://example.com', + urlType: null, // Should map to 'unspecified' + createdAt: baseTime, + updatedAt: baseTime, + }, + ]); + + const result = await statsRepository.getContentBreakdownStats({ + interval: 'day', + limit: 10, + }); + + expect(result.currentTotals.urlCards.total).toBe(1); + expect(result.currentTotals.urlCards.byType['unspecified']).toBe(1); + }); + + it('should show cumulative growth over time periods', async () => { + const baseTime = new Date('2026-04-01T00:00:00Z'); + + // Day 1: Create 2 URL cards + await db.insert(cards).values([ + { + id: '10000000-0000-0000-0000-000000000001', + authorId: 'user1', + type: 'URL', + contentData: { url: 'https://example.com' }, + url: 'https://example.com', + urlType: 'article', + createdAt: baseTime, + updatedAt: baseTime, + }, + { + id: '10000000-0000-0000-0000-000000000002', + authorId: 'user2', + type: 'URL', + contentData: { url: 'https://example2.com' }, + url: 'https://example2.com', + urlType: 'article', + createdAt: baseTime, + updatedAt: baseTime, + }, + ]); + + // Day 2: Create 1 more URL card + const day2Time = new Date(baseTime.getTime() + 24 * 60 * 60 * 1000); + await db.insert(cards).values([ + { + id: '10000000-0000-0000-0000-000000000003', + authorId: 'user1', + type: 'URL', + contentData: { url: 'https://example3.com' }, + url: 'https://example3.com', + urlType: 'video', + createdAt: day2Time, + updatedAt: day2Time, + }, + ]); + + const result = await statsRepository.getContentBreakdownStats({ + interval: 'day', + limit: 10, + }); + + expect(result.dataPoints.length).toBeGreaterThanOrEqual(2); + + // Data should be in chronological order + const sortedDataPoints = [...result.dataPoints].sort( + (a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(), + ); + + // First period should have cumulative count + const firstPeriod = sortedDataPoints[0]; + expect(firstPeriod).toBeDefined(); + + // Last period should have cumulative total matching current totals + expect(result.currentTotals.urlCards.total).toBe(3); + }); + + it('should return empty data when no content exists', async () => { + const result = await statsRepository.getContentBreakdownStats({ + interval: 'day', + limit: 10, + }); + + expect(result.dataPoints).toHaveLength(0); + expect(result.currentTotals.urlCards.total).toBe(0); + expect(result.currentTotals.collections.total).toBe(0); + expect(result.currentTotals.connections.total).toBe(0); + }); + }); +}); diff --git a/src/shared/infrastructure/database/migrations/0021_volatile_loki.sql b/src/shared/infrastructure/database/migrations/0021_volatile_loki.sql new file mode 100644 index 00000000..67780e98 --- /dev/null +++ b/src/shared/infrastructure/database/migrations/0021_volatile_loki.sql @@ -0,0 +1,9 @@ +CREATE INDEX "idx_cards_created_at" ON "cards" USING btree ("created_at");--> statement-breakpoint +CREATE INDEX "idx_cards_author_created_at" ON "cards" USING btree ("author_id","created_at");--> statement-breakpoint +CREATE INDEX "idx_cards_created_at_url_type" ON "cards" USING btree ("created_at","url_type");--> statement-breakpoint +CREATE INDEX "idx_collections_created_at" ON "collections" USING btree ("created_at");--> statement-breakpoint +CREATE INDEX "idx_collections_author_created_at" ON "collections" USING btree ("author_id","created_at");--> statement-breakpoint +CREATE INDEX "idx_collections_created_at_access_type" ON "collections" USING btree ("created_at","access_type");--> statement-breakpoint +CREATE INDEX "idx_connections_created_at_connection_type" ON "connections" USING btree ("created_at","connection_type");--> statement-breakpoint +CREATE INDEX "idx_follows_created_at" ON "follows" USING btree ("created_at");--> statement-breakpoint +CREATE INDEX "idx_follows_follower_created_at" ON "follows" USING btree ("follower_id","created_at"); \ No newline at end of file diff --git a/src/shared/infrastructure/database/migrations/meta/0021_snapshot.json b/src/shared/infrastructure/database/migrations/meta/0021_snapshot.json new file mode 100644 index 00000000..3c8a6938 --- /dev/null +++ b/src/shared/infrastructure/database/migrations/meta/0021_snapshot.json @@ -0,0 +1,1963 @@ +{ + "id": "411a0087-6ded-48dd-beb3-1d1aa4ea11b6", + "prevId": "4a8abde0-ad5a-4280-bf0e-dd8187d1c4b0", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.app_password_sessions": { + "name": "app_password_sessions", + "schema": "", + "columns": { + "did": { + "name": "did", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "session_data": { + "name": "session_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "app_password": { + "name": "app_password", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cards": { + "name": "cards", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_data": { + "name": "content_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url_type": { + "name": "url_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_card_id": { + "name": "parent_card_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "via_card_id": { + "name": "via_card_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "published_record_id": { + "name": "published_record_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "library_count": { + "name": "library_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "cards_author_url_idx": { + "name": "cards_author_url_idx", + "columns": [ + { + "expression": "author_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cards_author_id_idx": { + "name": "cards_author_id_idx", + "columns": [ + { + "expression": "author_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cards_type_updated_at": { + "name": "idx_cards_type_updated_at", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cards_url_type": { + "name": "idx_cards_url_type", + "columns": [ + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cards_url_type_filter": { + "name": "idx_cards_url_type_filter", + "columns": [ + { + "expression": "url_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cards_parent_type": { + "name": "idx_cards_parent_type", + "columns": [ + { + "expression": "parent_card_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "type = 'NOTE'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cards_created_at": { + "name": "idx_cards_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cards_author_created_at": { + "name": "idx_cards_author_created_at", + "columns": [ + { + "expression": "author_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cards_created_at_url_type": { + "name": "idx_cards_created_at_url_type", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "url_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cards_parent_card_id_cards_id_fk": { + "name": "cards_parent_card_id_cards_id_fk", + "tableFrom": "cards", + "tableTo": "cards", + "columnsFrom": ["parent_card_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cards_via_card_id_cards_id_fk": { + "name": "cards_via_card_id_cards_id_fk", + "tableFrom": "cards", + "tableTo": "cards", + "columnsFrom": ["via_card_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cards_published_record_id_published_records_id_fk": { + "name": "cards_published_record_id_published_records_id_fk", + "tableFrom": "cards", + "tableTo": "published_records", + "columnsFrom": ["published_record_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.collection_cards": { + "name": "collection_cards", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "collection_id": { + "name": "collection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "card_id": { + "name": "card_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "added_at": { + "name": "added_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "via_card_id": { + "name": "via_card_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "published_record_id": { + "name": "published_record_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "collection_cards_card_id_idx": { + "name": "collection_cards_card_id_idx", + "columns": [ + { + "expression": "card_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "collection_cards_collection_id_idx": { + "name": "collection_cards_collection_id_idx", + "columns": [ + { + "expression": "collection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_collection_cards_collection_added": { + "name": "idx_collection_cards_collection_added", + "columns": [ + { + "expression": "collection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "added_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_collection_cards_card_collection": { + "name": "idx_collection_cards_card_collection", + "columns": [ + { + "expression": "card_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_collection_cards_added_by_added_at": { + "name": "idx_collection_cards_added_by_added_at", + "columns": [ + { + "expression": "added_by", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "added_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "collection_cards_collection_id_collections_id_fk": { + "name": "collection_cards_collection_id_collections_id_fk", + "tableFrom": "collection_cards", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "collection_cards_card_id_cards_id_fk": { + "name": "collection_cards_card_id_cards_id_fk", + "tableFrom": "collection_cards", + "tableTo": "cards", + "columnsFrom": ["card_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "collection_cards_via_card_id_cards_id_fk": { + "name": "collection_cards_via_card_id_cards_id_fk", + "tableFrom": "collection_cards", + "tableTo": "cards", + "columnsFrom": ["via_card_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "collection_cards_published_record_id_published_records_id_fk": { + "name": "collection_cards_published_record_id_published_records_id_fk", + "tableFrom": "collection_cards", + "tableTo": "published_records", + "columnsFrom": ["published_record_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.collection_collaborators": { + "name": "collection_collaborators", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "collection_id": { + "name": "collection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "collaborator_id": { + "name": "collaborator_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "collection_collaborators_collection_id_collections_id_fk": { + "name": "collection_collaborators_collection_id_collections_id_fk", + "tableFrom": "collection_collaborators", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.collections": { + "name": "collections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_type": { + "name": "access_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "card_count": { + "name": "card_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "published_record_id": { + "name": "published_record_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "collections_author_id_idx": { + "name": "collections_author_id_idx", + "columns": [ + { + "expression": "author_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "collections_author_updated_at_idx": { + "name": "collections_author_updated_at_idx", + "columns": [ + { + "expression": "author_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_collections_created_at": { + "name": "idx_collections_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_collections_author_created_at": { + "name": "idx_collections_author_created_at", + "columns": [ + { + "expression": "author_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_collections_created_at_access_type": { + "name": "idx_collections_created_at_access_type", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "access_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "collections_published_record_id_published_records_id_fk": { + "name": "collections_published_record_id_published_records_id_fk", + "tableFrom": "collections", + "tableTo": "published_records", + "columnsFrom": ["published_record_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.connections": { + "name": "connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "curator_id": { + "name": "curator_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_value": { + "name": "source_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_url_metadata": { + "name": "source_url_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_value": { + "name": "target_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_url_metadata": { + "name": "target_url_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "connection_type": { + "name": "connection_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_record_id": { + "name": "published_record_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "connections_curator_id_idx": { + "name": "connections_curator_id_idx", + "columns": [ + { + "expression": "curator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "connections_source_idx": { + "name": "connections_source_idx", + "columns": [ + { + "expression": "source_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_value", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "connections_target_idx": { + "name": "connections_target_idx", + "columns": [ + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_value", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "connections_created_at_idx": { + "name": "connections_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "connections_curator_created_at_idx": { + "name": "connections_curator_created_at_idx", + "columns": [ + { + "expression": "curator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_connections_created_at_connection_type": { + "name": "idx_connections_created_at_connection_type", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "connections_published_record_id_published_records_id_fk": { + "name": "connections_published_record_id_published_records_id_fk", + "tableFrom": "connections", + "tableTo": "published_records", + "columnsFrom": ["published_record_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.library_memberships": { + "name": "library_memberships", + "schema": "", + "columns": { + "card_id": { + "name": "card_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "added_at": { + "name": "added_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "published_record_id": { + "name": "published_record_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_user_cards": { + "name": "idx_user_cards", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_card_users": { + "name": "idx_card_users", + "columns": [ + { + "expression": "card_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_library_memberships_user_type_covering": { + "name": "idx_library_memberships_user_type_covering", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "added_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "library_memberships_card_id_cards_id_fk": { + "name": "library_memberships_card_id_cards_id_fk", + "tableFrom": "library_memberships", + "tableTo": "cards", + "columnsFrom": ["card_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "library_memberships_published_record_id_published_records_id_fk": { + "name": "library_memberships_published_record_id_published_records_id_fk", + "tableFrom": "library_memberships", + "tableTo": "published_records", + "columnsFrom": ["published_record_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "library_memberships_card_id_user_id_pk": { + "name": "library_memberships_card_id_user_id_pk", + "columns": ["card_id", "user_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.published_records": { + "name": "published_records", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cid": { + "name": "cid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recorded_at": { + "name": "recorded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "uri_cid_unique_idx": { + "name": "uri_cid_unique_idx", + "columns": [ + { + "expression": "uri", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cid", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "published_records_uri_idx": { + "name": "published_records_uri_idx", + "columns": [ + { + "expression": "uri", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.feed_activities": { + "name": "feed_activities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "card_id": { + "name": "card_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "url_type": { + "name": "url_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "feed_activities_type_idx": { + "name": "feed_activities_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feed_activities_url_type_idx": { + "name": "feed_activities_url_type_idx", + "columns": [ + { + "expression": "url_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feed_activities_created_at_idx": { + "name": "feed_activities_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feed_activities_type_created_at_idx": { + "name": "feed_activities_type_created_at_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feed_activities_url_type_created_at_idx": { + "name": "feed_activities_url_type_created_at_idx", + "columns": [ + { + "expression": "url_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feed_activities_type_url_type_created_at_idx": { + "name": "feed_activities_type_url_type_created_at_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "url_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feed_activities_dedup_idx": { + "name": "feed_activities_dedup_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "card_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feed_activities_card_id_idx": { + "name": "feed_activities_card_id_idx", + "columns": [ + { + "expression": "card_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feed_activities_connection_id_idx": { + "name": "feed_activities_connection_id_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feed_activities_source_idx": { + "name": "feed_activities_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.following_feed_items": { + "name": "following_feed_items", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "activity_id": { + "name": "activity_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_following_feed_user_time": { + "name": "idx_following_feed_user_time", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "following_feed_items_activity_id_feed_activities_id_fk": { + "name": "following_feed_items_activity_id_feed_activities_id_fk", + "tableFrom": "following_feed_items", + "tableTo": "feed_activities", + "columnsFrom": ["activity_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "following_feed_items_user_id_activity_id_pk": { + "name": "following_feed_items_user_id_activity_id_pk", + "columns": ["user_id", "activity_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notifications": { + "name": "notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "recipient_user_id": { + "name": "recipient_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "read": { + "name": "read", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notifications_recipient_idx": { + "name": "notifications_recipient_idx", + "columns": [ + { + "expression": "recipient_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notifications_recipient_created_at_idx": { + "name": "notifications_recipient_created_at_idx", + "columns": [ + { + "expression": "recipient_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notifications_recipient_read_idx": { + "name": "notifications_recipient_read_idx", + "columns": [ + { + "expression": "recipient_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "read", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_statuses": { + "name": "sync_statuses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "curator_id": { + "name": "curator_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sync_state": { + "name": "sync_state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_sync_attempt_at": { + "name": "last_sync_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "sync_error_message": { + "name": "sync_error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "records_processed": { + "name": "records_processed", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sync_statuses_curator_id_unique": { + "name": "sync_statuses_curator_id_unique", + "nullsNotDistinct": false, + "columns": ["curator_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_session": { + "name": "auth_session", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "session": { + "name": "session", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_state": { + "name": "auth_state", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_refresh_tokens": { + "name": "auth_refresh_tokens", + "schema": "", + "columns": { + "token_id": { + "name": "token_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_did": { + "name": "user_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issued_at": { + "name": "issued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "revoked": { + "name": "revoked", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "auth_refresh_tokens_user_did_users_id_fk": { + "name": "auth_refresh_tokens_user_did_users_id_fk", + "tableFrom": "auth_refresh_tokens", + "tableTo": "users", + "columnsFrom": ["user_did"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.follows": { + "name": "follows", + "schema": "", + "columns": { + "follower_id": { + "name": "follower_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "published_record_id": { + "name": "published_record_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_follows_follower": { + "name": "idx_follows_follower", + "columns": [ + { + "expression": "follower_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_follows_target": { + "name": "idx_follows_target", + "columns": [ + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_follows_created_at": { + "name": "idx_follows_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_follows_follower_created_at": { + "name": "idx_follows_follower_created_at", + "columns": [ + { + "expression": "follower_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "follows_published_record_id_published_records_id_fk": { + "name": "follows_published_record_id_published_records_id_fk", + "tableFrom": "follows", + "tableTo": "published_records", + "columnsFrom": ["published_record_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "follows_follower_id_target_id_target_type_pk": { + "name": "follows_follower_id_target_id_target_type_pk", + "columns": ["follower_id", "target_id", "target_type"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "linked_at": { + "name": "linked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "last_login_at": { + "name": "last_login_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/src/shared/infrastructure/database/migrations/meta/_journal.json b/src/shared/infrastructure/database/migrations/meta/_journal.json index 94ee65e2..8ac25f38 100644 --- a/src/shared/infrastructure/database/migrations/meta/_journal.json +++ b/src/shared/infrastructure/database/migrations/meta/_journal.json @@ -148,6 +148,13 @@ "when": 1773944859810, "tag": "0020_closed_supreme_intelligence", "breakpoints": true + }, + { + "idx": 21, + "version": "7", + "when": 1775867436251, + "tag": "0021_volatile_loki", + "breakpoints": true } ] }