···
1
1
# Card URL Type Migration Plan
2
2
3
3
**Date:** 2025-12-23
4
4
-
**Status:** Planning Phase
4
4
+
**Status:** Planning Phase
5
5
6
6
## Overview
7
7
···
10
10
## Approach: Incremental Migration
11
11
12
12
### Phase 1: Add Optional Field (Immediate)
13
13
+
13
14
- Add `url_type` TEXT column to cards table (nullable)
14
15
- Update CardMapper to populate urlType for new cards
15
16
- Add indexes for performance
16
17
- New cards will be immediately queryable by type
17
18
18
19
### Phase 2: Hybrid Queries (Temporary)
20
20
+
19
21
- Update query services to handle mixed data:
20
22
- Fast indexed lookup for new cards (urlType field)
21
23
- Fallback JSON query for old cards (contentData.metadata.type)
22
24
- Use OR conditions to support both data states
23
25
24
26
### Phase 3: Backfill Migration (Later)
27
27
+
25
28
- Run SQL update to populate urlType for existing cards
26
29
- Remove fallback JSON queries once migration complete
27
30
- Simplify query logic to use only indexed field
···
31
34
✅ **Zero downtime** - No breaking changes
32
35
✅ **Immediate value** - New cards queryable by type right away
33
36
✅ **Gradual migration** - Can backfill when convenient
34
34
-
✅ **Performance** - Indexed queries for new data
37
37
+
✅ **Performance** - Indexed queries for new data
35
38
36
39
## Implementation Notes
37
40
···
42
45
## Migration SQL (for Phase 3)
43
46
44
47
```sql
45
45
-
UPDATE cards
48
48
+
UPDATE cards
46
49
SET url_type = content_data #>> '{metadata,type}'
47
47
-
WHERE type = 'URL'
48
48
-
AND url_type IS NULL
50
50
+
WHERE type = 'URL'
51
51
+
AND url_type IS NULL
49
52
AND content_data #>> '{metadata,type}' IS NOT NULL;
50
53
```
51
54
···
346
346
type: card.type.value,
347
347
contentData,
348
348
url: card.url?.value,
349
349
-
urlType: content.type === CardTypeEnum.URL ? content.urlContent?.metadata?.type : undefined,
349
349
+
urlType:
350
350
+
content.type === CardTypeEnum.URL
351
351
+
? content.urlContent?.metadata?.type
352
352
+
: undefined,
350
353
parentCardId: card.parentCardId?.getStringValue(),
351
354
viaCardId: card.viaCardId?.getStringValue(),
352
355
libraryCount: card.libraryCount,
···
54
54
eq(collectionCards.collectionId, collectionId),
55
55
eq(cards.type, CardTypeEnum.URL),
56
56
];
57
57
-
57
57
+
58
58
if (options.urlType) {
59
59
whereConditions.push(eq(cards.urlType, options.urlType));
60
60
}
···
37
37
eq(cards.authorId, userId),
38
38
eq(cards.type, CardTypeEnum.URL),
39
39
];
40
40
-
40
40
+
41
41
if (options.urlType) {
42
42
whereConditions.push(eq(cards.urlType, options.urlType));
43
43
}
···
32
32
try {
33
33
// Get all cards and filter by user's library membership
34
34
const allCards = this.cardRepository.getAllCards();
35
35
-
let userCards = allCards
36
36
-
.filter(
37
37
-
(card) =>
38
38
-
card.isUrlCard &&
39
39
-
card.isInLibrary(CuratorId.create(userId).unwrap()),
40
40
-
);
35
35
+
let userCards = allCards.filter(
36
36
+
(card) =>
37
37
+
card.isUrlCard && card.isInLibrary(CuratorId.create(userId).unwrap()),
38
38
+
);
41
39
42
40
// Filter by urlType if specified
43
41
if (options.urlType) {
···
244
242
);
245
243
let collectionCards = allCards.filter(
246
244
(card) =>
247
247
-
collectionCardIds.has(card.cardId.getStringValue()) &&
248
248
-
card.isUrlCard,
245
245
+
collectionCardIds.has(card.cardId.getStringValue()) && card.isUrlCard,
249
246
);
250
247
251
248
// Filter by urlType if specified
···
69
69
.limit(1);
70
70
71
71
if (beforeActivity.length > 0) {
72
72
-
const conditions = [lt(feedActivities.createdAt, beforeActivity[0]!.createdAt), ...whereConditions];
72
72
+
const conditions = [
73
73
+
lt(feedActivities.createdAt, beforeActivity[0]!.createdAt),
74
74
+
...whereConditions,
75
75
+
];
73
76
activitiesResult = await this.db
74
77
.select()
75
78
.from(feedActivities)
···
82
85
}
83
86
} else {
84
87
// Regular pagination without cursor
85
85
-
const query = this.db
86
86
-
.select()
87
87
-
.from(feedActivities);
88
88
-
88
88
+
const query = this.db.select().from(feedActivities);
89
89
+
89
90
if (whereConditions.length > 0) {
90
90
-
query.where(whereConditions.length > 1 ? and(...whereConditions) : whereConditions[0]);
91
91
+
query.where(
92
92
+
whereConditions.length > 1
93
93
+
? and(...whereConditions)
94
94
+
: whereConditions[0],
95
95
+
);
91
96
}
92
92
-
97
97
+
93
98
activitiesResult = await query
94
99
.orderBy(desc(feedActivities.createdAt), desc(feedActivities.id))
95
100
.limit(limit)
···
100
105
const countQuery = this.db
101
106
.select({ count: count() })
102
107
.from(feedActivities);
103
103
-
108
108
+
104
109
if (whereConditions.length > 0) {
105
105
-
countQuery.where(whereConditions.length > 1 ? and(...whereConditions) : whereConditions[0]);
110
110
+
countQuery.where(
111
111
+
whereConditions.length > 1
112
112
+
? and(...whereConditions)
113
113
+
: whereConditions[0],
114
114
+
);
106
115
}
107
107
-
116
116
+
108
117
const totalCountResult = await countQuery;
109
118
110
119
const totalCount = totalCountResult[0]?.count || 0;
···
206
215
const conditions = [
207
216
lt(feedActivities.createdAt, beforeActivity[0]!.createdAt),
208
217
jsonArrayCondition,
209
209
-
...whereConditions
218
218
+
...whereConditions,
210
219
];
211
220
activitiesResult = await this.db
212
221
.select()
···
235
244
if (options.urlType) {
236
245
conditions.push(eq(feedActivities.urlType, options.urlType));
237
246
}
238
238
-
247
247
+
239
248
const totalCountResult = await this.db
240
249
.select({ count: count() })
241
250
.from(feedActivities)
···
1
1
-
import { pgTable, text, timestamp, jsonb, uuid, index } from 'drizzle-orm/pg-core';
1
1
+
import {
2
2
+
pgTable,
3
3
+
text,
4
4
+
timestamp,
5
5
+
jsonb,
6
6
+
uuid,
7
7
+
index,
8
8
+
} from 'drizzle-orm/pg-core';
2
9
3
3
-
export const feedActivities = pgTable('feed_activities', {
4
4
-
id: uuid('id').primaryKey(),
5
5
-
actorId: text('actor_id').notNull(), // The DID of the user who performed the activity
6
6
-
type: text('type').notNull(), // The type of activity (e.g., 'CARD_COLLECTED')
7
7
-
metadata: jsonb('metadata').notNull(), // Activity-specific metadata
8
8
-
urlType: text('url_type'), // Optional URL type from the card
9
9
-
createdAt: timestamp('created_at').notNull().defaultNow(),
10
10
-
}, (table) => ({
11
11
-
// Index for filtering by activity type
12
12
-
typeIdx: index('feed_activities_type_idx').on(table.type),
13
13
-
// Index for filtering by URL type
14
14
-
urlTypeIdx: index('feed_activities_url_type_idx').on(table.urlType),
15
15
-
// Index for sorting by creation date (most recent first)
16
16
-
createdAtIdx: index('feed_activities_created_at_idx').on(table.createdAt.desc()),
17
17
-
// Composite index for common query patterns (type + createdAt)
18
18
-
typeCreatedAtIdx: index('feed_activities_type_created_at_idx').on(table.type, table.createdAt.desc()),
19
19
-
// Composite index for URL type filtering with date sorting
20
20
-
urlTypeCreatedAtIdx: index('feed_activities_url_type_created_at_idx').on(table.urlType, table.createdAt.desc()),
21
21
-
// Composite index for filtering by both type and URL type with date sorting
22
22
-
typeUrlTypeCreatedAtIdx: index('feed_activities_type_url_type_created_at_idx').on(table.type, table.urlType, table.createdAt.desc()),
23
23
-
}));
10
10
+
export const feedActivities = pgTable(
11
11
+
'feed_activities',
12
12
+
{
13
13
+
id: uuid('id').primaryKey(),
14
14
+
actorId: text('actor_id').notNull(), // The DID of the user who performed the activity
15
15
+
type: text('type').notNull(), // The type of activity (e.g., 'CARD_COLLECTED')
16
16
+
metadata: jsonb('metadata').notNull(), // Activity-specific metadata
17
17
+
urlType: text('url_type'), // Optional URL type from the card
18
18
+
createdAt: timestamp('created_at').notNull().defaultNow(),
19
19
+
},
20
20
+
(table) => ({
21
21
+
// Index for filtering by activity type
22
22
+
typeIdx: index('feed_activities_type_idx').on(table.type),
23
23
+
// Index for filtering by URL type
24
24
+
urlTypeIdx: index('feed_activities_url_type_idx').on(table.urlType),
25
25
+
// Index for sorting by creation date (most recent first)
26
26
+
createdAtIdx: index('feed_activities_created_at_idx').on(
27
27
+
table.createdAt.desc(),
28
28
+
),
29
29
+
// Composite index for common query patterns (type + createdAt)
30
30
+
typeCreatedAtIdx: index('feed_activities_type_created_at_idx').on(
31
31
+
table.type,
32
32
+
table.createdAt.desc(),
33
33
+
),
34
34
+
// Composite index for URL type filtering with date sorting
35
35
+
urlTypeCreatedAtIdx: index('feed_activities_url_type_created_at_idx').on(
36
36
+
table.urlType,
37
37
+
table.createdAt.desc(),
38
38
+
),
39
39
+
// Composite index for filtering by both type and URL type with date sorting
40
40
+
typeUrlTypeCreatedAtIdx: index(
41
41
+
'feed_activities_type_url_type_created_at_idx',
42
42
+
).on(table.type, table.urlType, table.createdAt.desc()),
43
43
+
}),
44
44
+
);
···
251
251
"name": "cards_parent_card_id_cards_id_fk",
252
252
"tableFrom": "cards",
253
253
"tableTo": "cards",
254
254
-
"columnsFrom": [
255
255
-
"parent_card_id"
256
256
-
],
257
257
-
"columnsTo": [
258
258
-
"id"
259
259
-
],
254
254
+
"columnsFrom": ["parent_card_id"],
255
255
+
"columnsTo": ["id"],
260
256
"onDelete": "no action",
261
257
"onUpdate": "no action"
262
258
},
···
264
260
"name": "cards_via_card_id_cards_id_fk",
265
261
"tableFrom": "cards",
266
262
"tableTo": "cards",
267
267
-
"columnsFrom": [
268
268
-
"via_card_id"
269
269
-
],
270
270
-
"columnsTo": [
271
271
-
"id"
272
272
-
],
263
263
+
"columnsFrom": ["via_card_id"],
264
264
+
"columnsTo": ["id"],
273
265
"onDelete": "no action",
274
266
"onUpdate": "no action"
275
267
},
···
277
269
"name": "cards_published_record_id_published_records_id_fk",
278
270
"tableFrom": "cards",
279
271
"tableTo": "published_records",
280
280
-
"columnsFrom": [
281
281
-
"published_record_id"
282
282
-
],
283
283
-
"columnsTo": [
284
284
-
"id"
285
285
-
],
272
272
+
"columnsFrom": ["published_record_id"],
273
273
+
"columnsTo": ["id"],
286
274
"onDelete": "no action",
287
275
"onUpdate": "no action"
288
276
}
···
414
402
"name": "collection_cards_collection_id_collections_id_fk",
415
403
"tableFrom": "collection_cards",
416
404
"tableTo": "collections",
417
417
-
"columnsFrom": [
418
418
-
"collection_id"
419
419
-
],
420
420
-
"columnsTo": [
421
421
-
"id"
422
422
-
],
405
405
+
"columnsFrom": ["collection_id"],
406
406
+
"columnsTo": ["id"],
423
407
"onDelete": "cascade",
424
408
"onUpdate": "no action"
425
409
},
···
427
411
"name": "collection_cards_card_id_cards_id_fk",
428
412
"tableFrom": "collection_cards",
429
413
"tableTo": "cards",
430
430
-
"columnsFrom": [
431
431
-
"card_id"
432
432
-
],
433
433
-
"columnsTo": [
434
434
-
"id"
435
435
-
],
414
414
+
"columnsFrom": ["card_id"],
415
415
+
"columnsTo": ["id"],
436
416
"onDelete": "cascade",
437
417
"onUpdate": "no action"
438
418
},
···
440
420
"name": "collection_cards_via_card_id_cards_id_fk",
441
421
"tableFrom": "collection_cards",
442
422
"tableTo": "cards",
443
443
-
"columnsFrom": [
444
444
-
"via_card_id"
445
445
-
],
446
446
-
"columnsTo": [
447
447
-
"id"
448
448
-
],
423
423
+
"columnsFrom": ["via_card_id"],
424
424
+
"columnsTo": ["id"],
449
425
"onDelete": "no action",
450
426
"onUpdate": "no action"
451
427
},
···
453
429
"name": "collection_cards_published_record_id_published_records_id_fk",
454
430
"tableFrom": "collection_cards",
455
431
"tableTo": "published_records",
456
456
-
"columnsFrom": [
457
457
-
"published_record_id"
458
458
-
],
459
459
-
"columnsTo": [
460
460
-
"id"
461
461
-
],
432
432
+
"columnsFrom": ["published_record_id"],
433
433
+
"columnsTo": ["id"],
462
434
"onDelete": "no action",
463
435
"onUpdate": "no action"
464
436
}
···
498
470
"name": "collection_collaborators_collection_id_collections_id_fk",
499
471
"tableFrom": "collection_collaborators",
500
472
"tableTo": "collections",
501
501
-
"columnsFrom": [
502
502
-
"collection_id"
503
503
-
],
504
504
-
"columnsTo": [
505
505
-
"id"
506
506
-
],
473
473
+
"columnsFrom": ["collection_id"],
474
474
+
"columnsTo": ["id"],
507
475
"onDelete": "cascade",
508
476
"onUpdate": "no action"
509
477
}
···
619
587
"name": "collections_published_record_id_published_records_id_fk",
620
588
"tableFrom": "collections",
621
589
"tableTo": "published_records",
622
622
-
"columnsFrom": [
623
623
-
"published_record_id"
624
624
-
],
625
625
-
"columnsTo": [
626
626
-
"id"
627
627
-
],
590
590
+
"columnsFrom": ["published_record_id"],
591
591
+
"columnsTo": ["id"],
628
592
"onDelete": "no action",
629
593
"onUpdate": "no action"
630
594
}
···
723
687
"name": "library_memberships_card_id_cards_id_fk",
724
688
"tableFrom": "library_memberships",
725
689
"tableTo": "cards",
726
726
-
"columnsFrom": [
727
727
-
"card_id"
728
728
-
],
729
729
-
"columnsTo": [
730
730
-
"id"
731
731
-
],
690
690
+
"columnsFrom": ["card_id"],
691
691
+
"columnsTo": ["id"],
732
692
"onDelete": "cascade",
733
693
"onUpdate": "no action"
734
694
},
···
736
696
"name": "library_memberships_published_record_id_published_records_id_fk",
737
697
"tableFrom": "library_memberships",
738
698
"tableTo": "published_records",
739
739
-
"columnsFrom": [
740
740
-
"published_record_id"
741
741
-
],
742
742
-
"columnsTo": [
743
743
-
"id"
744
744
-
],
699
699
+
"columnsFrom": ["published_record_id"],
700
700
+
"columnsTo": ["id"],
745
701
"onDelete": "no action",
746
702
"onUpdate": "no action"
747
703
}
···
749
705
"compositePrimaryKeys": {
750
706
"library_memberships_card_id_user_id_pk": {
751
707
"name": "library_memberships_card_id_user_id_pk",
752
752
-
"columns": [
753
753
-
"card_id",
754
754
-
"user_id"
755
755
-
]
708
708
+
"columns": ["card_id", "user_id"]
756
709
}
757
710
},
758
711
"uniqueConstraints": {},
···
1106
1059
"name": "auth_refresh_tokens_user_did_users_id_fk",
1107
1060
"tableFrom": "auth_refresh_tokens",
1108
1061
"tableTo": "users",
1109
1109
-
"columnsFrom": [
1110
1110
-
"user_did"
1111
1111
-
],
1112
1112
-
"columnsTo": [
1113
1113
-
"id"
1114
1114
-
],
1062
1062
+
"columnsFrom": ["user_did"],
1063
1063
+
"columnsTo": ["id"],
1115
1064
"onDelete": "no action",
1116
1065
"onUpdate": "no action"
1117
1066
}
···
1171
1120
"schemas": {},
1172
1121
"tables": {}
1173
1122
}
1174
1174
-
}
1123
1123
+
}
···
251
251
"name": "cards_parent_card_id_cards_id_fk",
252
252
"tableFrom": "cards",
253
253
"tableTo": "cards",
254
254
-
"columnsFrom": [
255
255
-
"parent_card_id"
256
256
-
],
257
257
-
"columnsTo": [
258
258
-
"id"
259
259
-
],
254
254
+
"columnsFrom": ["parent_card_id"],
255
255
+
"columnsTo": ["id"],
260
256
"onDelete": "no action",
261
257
"onUpdate": "no action"
262
258
},
···
264
260
"name": "cards_via_card_id_cards_id_fk",
265
261
"tableFrom": "cards",
266
262
"tableTo": "cards",
267
267
-
"columnsFrom": [
268
268
-
"via_card_id"
269
269
-
],
270
270
-
"columnsTo": [
271
271
-
"id"
272
272
-
],
263
263
+
"columnsFrom": ["via_card_id"],
264
264
+
"columnsTo": ["id"],
273
265
"onDelete": "no action",
274
266
"onUpdate": "no action"
275
267
},
···
277
269
"name": "cards_published_record_id_published_records_id_fk",
278
270
"tableFrom": "cards",
279
271
"tableTo": "published_records",
280
280
-
"columnsFrom": [
281
281
-
"published_record_id"
282
282
-
],
283
283
-
"columnsTo": [
284
284
-
"id"
285
285
-
],
272
272
+
"columnsFrom": ["published_record_id"],
273
273
+
"columnsTo": ["id"],
286
274
"onDelete": "no action",
287
275
"onUpdate": "no action"
288
276
}
···
414
402
"name": "collection_cards_collection_id_collections_id_fk",
415
403
"tableFrom": "collection_cards",
416
404
"tableTo": "collections",
417
417
-
"columnsFrom": [
418
418
-
"collection_id"
419
419
-
],
420
420
-
"columnsTo": [
421
421
-
"id"
422
422
-
],
405
405
+
"columnsFrom": ["collection_id"],
406
406
+
"columnsTo": ["id"],
423
407
"onDelete": "cascade",
424
408
"onUpdate": "no action"
425
409
},
···
427
411
"name": "collection_cards_card_id_cards_id_fk",
428
412
"tableFrom": "collection_cards",
429
413
"tableTo": "cards",
430
430
-
"columnsFrom": [
431
431
-
"card_id"
432
432
-
],
433
433
-
"columnsTo": [
434
434
-
"id"
435
435
-
],
414
414
+
"columnsFrom": ["card_id"],
415
415
+
"columnsTo": ["id"],
436
416
"onDelete": "cascade",
437
417
"onUpdate": "no action"
438
418
},
···
440
420
"name": "collection_cards_via_card_id_cards_id_fk",
441
421
"tableFrom": "collection_cards",
442
422
"tableTo": "cards",
443
443
-
"columnsFrom": [
444
444
-
"via_card_id"
445
445
-
],
446
446
-
"columnsTo": [
447
447
-
"id"
448
448
-
],
423
423
+
"columnsFrom": ["via_card_id"],
424
424
+
"columnsTo": ["id"],
449
425
"onDelete": "no action",
450
426
"onUpdate": "no action"
451
427
},
···
453
429
"name": "collection_cards_published_record_id_published_records_id_fk",
454
430
"tableFrom": "collection_cards",
455
431
"tableTo": "published_records",
456
456
-
"columnsFrom": [
457
457
-
"published_record_id"
458
458
-
],
459
459
-
"columnsTo": [
460
460
-
"id"
461
461
-
],
432
432
+
"columnsFrom": ["published_record_id"],
433
433
+
"columnsTo": ["id"],
462
434
"onDelete": "no action",
463
435
"onUpdate": "no action"
464
436
}
···
498
470
"name": "collection_collaborators_collection_id_collections_id_fk",
499
471
"tableFrom": "collection_collaborators",
500
472
"tableTo": "collections",
501
501
-
"columnsFrom": [
502
502
-
"collection_id"
503
503
-
],
504
504
-
"columnsTo": [
505
505
-
"id"
506
506
-
],
473
473
+
"columnsFrom": ["collection_id"],
474
474
+
"columnsTo": ["id"],
507
475
"onDelete": "cascade",
508
476
"onUpdate": "no action"
509
477
}
···
619
587
"name": "collections_published_record_id_published_records_id_fk",
620
588
"tableFrom": "collections",
621
589
"tableTo": "published_records",
622
622
-
"columnsFrom": [
623
623
-
"published_record_id"
624
624
-
],
625
625
-
"columnsTo": [
626
626
-
"id"
627
627
-
],
590
590
+
"columnsFrom": ["published_record_id"],
591
591
+
"columnsTo": ["id"],
628
592
"onDelete": "no action",
629
593
"onUpdate": "no action"
630
594
}
···
723
687
"name": "library_memberships_card_id_cards_id_fk",
724
688
"tableFrom": "library_memberships",
725
689
"tableTo": "cards",
726
726
-
"columnsFrom": [
727
727
-
"card_id"
728
728
-
],
729
729
-
"columnsTo": [
730
730
-
"id"
731
731
-
],
690
690
+
"columnsFrom": ["card_id"],
691
691
+
"columnsTo": ["id"],
732
692
"onDelete": "cascade",
733
693
"onUpdate": "no action"
734
694
},
···
736
696
"name": "library_memberships_published_record_id_published_records_id_fk",
737
697
"tableFrom": "library_memberships",
738
698
"tableTo": "published_records",
739
739
-
"columnsFrom": [
740
740
-
"published_record_id"
741
741
-
],
742
742
-
"columnsTo": [
743
743
-
"id"
744
744
-
],
699
699
+
"columnsFrom": ["published_record_id"],
700
700
+
"columnsTo": ["id"],
745
701
"onDelete": "no action",
746
702
"onUpdate": "no action"
747
703
}
···
749
705
"compositePrimaryKeys": {
750
706
"library_memberships_card_id_user_id_pk": {
751
707
"name": "library_memberships_card_id_user_id_pk",
752
752
-
"columns": [
753
753
-
"card_id",
754
754
-
"user_id"
755
755
-
]
708
708
+
"columns": ["card_id", "user_id"]
756
709
}
757
710
},
758
711
"uniqueConstraints": {},
···
1227
1180
"name": "auth_refresh_tokens_user_did_users_id_fk",
1228
1181
"tableFrom": "auth_refresh_tokens",
1229
1182
"tableTo": "users",
1230
1230
-
"columnsFrom": [
1231
1231
-
"user_did"
1232
1232
-
],
1233
1233
-
"columnsTo": [
1234
1234
-
"id"
1235
1235
-
],
1183
1183
+
"columnsFrom": ["user_did"],
1184
1184
+
"columnsTo": ["id"],
1236
1185
"onDelete": "no action",
1237
1186
"onUpdate": "no action"
1238
1187
}
···
1292
1241
"schemas": {},
1293
1242
"tables": {}
1294
1243
}
1295
1295
-
}
1244
1244
+
}