Skip to content

Commit 91b8a47

Browse files
authored
feat(recently): URL-keyed enrichment map, drop typed entries (#2726)
Replace the single per-entry enrichment (enrichmentProvider / enrichmentExternalId columns) with a URL-keyed enrichments map attached at read time by scanning entry content - the same attachEnrichments pattern used by posts/notes/pages. Columns are dropped via app-migration; the obsolete backfill migration and job are removed. Drop the deprecated typed recently entries (book/media/music/github/academic/code): RecentlyTypeEnum collapses to text/link, per-type metadata schemas and the discriminated-union DTO are removed, and entry type is derived server-side from URL presence. Bumps @mx-space/api-client to 4.2.0.
1 parent 911a7d7 commit 91b8a47

14 files changed

Lines changed: 144 additions & 578 deletions

File tree

apps/core/src/database/app-migrations/20260506-enrichment-backfill.ts

Lines changed: 0 additions & 79 deletions
This file was deleted.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { sql } from 'drizzle-orm'
2+
3+
import type { AppMigration } from './types'
4+
5+
export const migration: AppMigration = {
6+
id: '20260515-recently-drop-enrichment-columns',
7+
description:
8+
'Drop recentlies single-ref enrichment columns; enrichment is attached at read time',
9+
async up({ db }) {
10+
await db.execute(sql`
11+
ALTER TABLE "recentlies"
12+
DROP COLUMN IF EXISTS "enrichment_provider",
13+
DROP COLUMN IF EXISTS "enrichment_external_id"
14+
`)
15+
await db.execute(
16+
sql`DROP INDEX IF EXISTS "recentlies_enrichment_idx"`,
17+
)
18+
},
19+
}
Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1-
import { migration as enrichmentBackfill } from './20260506-enrichment-backfill'
1+
import { migration as recentlyDropEnrichmentColumns } from './20260515-recently-drop-enrichment-columns'
22
import type { AppMigration } from './types'
33

44
/**
55
* Ordered list of app-data migrations. Runner sorts by id (lexicographic on
66
* the `YYYYMMDD-slug` prefix) before iterating, so insertion order here is
77
* not load-bearing — adding a new migration is just `import + push`.
8+
*
9+
* Migrations removed from this list never re-run; the ledger row of a
10+
* previously applied one is left in place and simply goes unreferenced.
811
*/
9-
export const migrations: AppMigration[] = [enrichmentBackfill]
12+
export const migrations: AppMigration[] = [recentlyDropEnrichmentColumns]

apps/core/src/maintenance/jobs/recently-enrichment-backfill.job.ts

Lines changed: 0 additions & 34 deletions
This file was deleted.

apps/core/src/modules/recently/recently.controller.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,10 @@ export class RecentlyController {
6868
@Put('/:id')
6969
@Auth()
7070
async update(@Param() { id }: EntityIdDto, @Body() body: RecentlyDto) {
71-
const res = await this.recentlyService.update(id, body)
71+
const res = await this.recentlyService.update(
72+
id,
73+
body as unknown as Partial<RecentlyModel>,
74+
)
7275
if (!res) {
7376
throw new BizException(ErrorCodeEnum.EntryNotFound)
7477
}

apps/core/src/modules/recently/recently.repository.ts

Lines changed: 1 addition & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,5 @@
11
import { Inject, Injectable } from '@nestjs/common'
2-
import {
3-
and,
4-
desc,
5-
eq,
6-
gt,
7-
inArray,
8-
isNull,
9-
lt,
10-
type SQL,
11-
sql,
12-
} from 'drizzle-orm'
2+
import { and, desc, eq, gt, inArray, lt, type SQL, sql } from 'drizzle-orm'
133

144
import { PG_DB_TOKEN } from '~/constants/system.constant'
155
import { recentlies } from '~/database/schema'
@@ -42,8 +32,6 @@ const mapRow = (row: typeof recentlies.$inferSelect): RecentlyRow => ({
4232
down: row.down,
4333
createdAt: row.createdAt,
4434
modifiedAt: row.modifiedAt,
45-
enrichmentProvider: row.enrichmentProvider ?? null,
46-
enrichmentExternalId: row.enrichmentExternalId ?? null,
4735
})
4836

4937
@Injectable()
@@ -122,8 +110,6 @@ export class RecentlyRepository extends BaseRepository {
122110
refType: input.refType ?? null,
123111
refId: input.refId ? parseEntityId(input.refId) : null,
124112
allowComment: input.allowComment ?? true,
125-
enrichmentProvider: input.enrichmentProvider ?? null,
126-
enrichmentExternalId: input.enrichmentExternalId ?? null,
127113
})
128114
.returning()
129115
return mapRow(row)
@@ -149,10 +135,6 @@ export class RecentlyRepository extends BaseRepository {
149135
if (patch.down !== undefined) update.down = patch.down
150136
if (patch.commentsIndex !== undefined)
151137
update.commentsIndex = patch.commentsIndex
152-
if (patch.enrichmentProvider !== undefined)
153-
update.enrichmentProvider = patch.enrichmentProvider
154-
if (patch.enrichmentExternalId !== undefined)
155-
update.enrichmentExternalId = patch.enrichmentExternalId
156138
const [row] = await this.db
157139
.update(recentlies)
158140
.set(update)
@@ -193,14 +175,6 @@ export class RecentlyRepository extends BaseRepository {
193175
return Number(row?.count ?? 0)
194176
}
195177

196-
async findWithoutEnrichment(): Promise<RecentlyRow[]> {
197-
const rows = await this.db
198-
.select()
199-
.from(recentlies)
200-
.where(isNull(recentlies.enrichmentExternalId))
201-
return rows.map(mapRow)
202-
}
203-
204178
async findRecent(size: number): Promise<RecentlyRow[]> {
205179
const rows = await this.db
206180
.select()

apps/core/src/modules/recently/recently.schema.ts

Lines changed: 5 additions & 140 deletions
Original file line numberDiff line numberDiff line change
@@ -10,150 +10,16 @@ export enum RecentlyAttitudeEnum {
1010

1111
export enum RecentlyTypeEnum {
1212
Text = 'text',
13-
Book = 'book',
14-
Media = 'media',
15-
Music = 'music',
16-
Github = 'github',
1713
Link = 'link',
18-
Academic = 'academic',
19-
Code = 'code',
2014
}
2115

22-
// --- Metadata schemas per type ---
23-
24-
export const BookMetaSchema = z.object({
25-
url: z.string().url(),
26-
title: z.string(),
27-
author: z.string(),
28-
cover: z.string().url().optional(),
29-
rating: z.number().min(0).max(10).optional(),
30-
isbn: z.string().optional(),
31-
})
32-
33-
export const MediaMetaSchema = z.object({
34-
url: z.string().url(),
35-
title: z.string(),
36-
originalTitle: z.string().optional(),
37-
cover: z.string().url().optional(),
38-
rating: z.number().min(0).max(10).optional(),
39-
description: z.string().optional(),
40-
genre: z.string().optional(),
41-
})
42-
43-
export const MusicMetaSchema = z.object({
44-
url: z.string().url(),
45-
title: z.string(),
46-
artist: z.string(),
47-
album: z.string().optional(),
48-
cover: z.string().url().optional(),
49-
source: z.string().optional(),
50-
})
51-
52-
export const GithubMetaSchema = z.object({
53-
url: z.string().url(),
54-
owner: z.string(),
55-
repo: z.string(),
56-
description: z.string().optional(),
57-
stars: z.number().optional(),
58-
language: z.string().optional(),
59-
languageColor: z.string().optional(),
60-
})
61-
62-
export const LinkMetaSchema = z.object({
63-
url: z.string().url(),
64-
title: z.string().optional(),
65-
description: z.string().optional(),
66-
image: z.string().url().optional(),
67-
})
68-
69-
export const AcademicMetaSchema = z.object({
70-
url: z.string().url(),
71-
title: z.string(),
72-
authors: z.array(z.string()).optional(),
73-
arxivId: z.string().optional(),
74-
})
75-
76-
export const CodeMetaSchema = z.object({
77-
url: z.string().url(),
78-
title: z.string(),
79-
difficulty: z.string().optional(),
80-
tags: z.array(z.string()).optional(),
81-
platform: z.string().optional(),
82-
})
83-
84-
// --- Shared optional fields ---
85-
86-
const refFields = {
16+
export const RecentlySchema = z.object({
17+
content: z.string().min(1),
8718
ref: zEntityId.optional(),
8819
refType: z.string().optional(),
89-
}
90-
91-
// --- Discriminated union with preprocess for backward compat ---
92-
93-
const RecentlyDiscriminatedSchema = z.discriminatedUnion('type', [
94-
z.object({
95-
type: z.literal(RecentlyTypeEnum.Text),
96-
content: z.string().min(1),
97-
...refFields,
98-
}),
99-
z.object({
100-
type: z.literal(RecentlyTypeEnum.Book),
101-
content: z.string().optional().default(''),
102-
metadata: BookMetaSchema,
103-
...refFields,
104-
}),
105-
z.object({
106-
type: z.literal(RecentlyTypeEnum.Media),
107-
content: z.string().optional().default(''),
108-
metadata: MediaMetaSchema,
109-
...refFields,
110-
}),
111-
z.object({
112-
type: z.literal(RecentlyTypeEnum.Music),
113-
content: z.string().optional().default(''),
114-
metadata: MusicMetaSchema,
115-
...refFields,
116-
}),
117-
z.object({
118-
type: z.literal(RecentlyTypeEnum.Github),
119-
content: z.string().optional().default(''),
120-
metadata: GithubMetaSchema,
121-
...refFields,
122-
}),
123-
z.object({
124-
type: z.literal(RecentlyTypeEnum.Link),
125-
content: z.string().optional().default(''),
126-
metadata: LinkMetaSchema,
127-
...refFields,
128-
}),
129-
z.object({
130-
type: z.literal(RecentlyTypeEnum.Academic),
131-
content: z.string().optional().default(''),
132-
metadata: AcademicMetaSchema,
133-
...refFields,
134-
}),
135-
z.object({
136-
type: z.literal(RecentlyTypeEnum.Code),
137-
content: z.string().optional().default(''),
138-
metadata: CodeMetaSchema,
139-
...refFields,
140-
}),
141-
])
142-
143-
export const RecentlySchema = z.preprocess((val: any) => {
144-
if (val && typeof val === 'object' && !('type' in val)) {
145-
return { ...val, type: RecentlyTypeEnum.Text }
146-
}
147-
return val
148-
}, RecentlyDiscriminatedSchema)
149-
150-
// z.preprocess returns ZodEffects which is incompatible with createZodDto's type constraint,
151-
// but runtime behavior is correct. Use type assertion to bypass.
152-
export class RecentlyDto extends createZodDto(
153-
RecentlySchema as unknown as z.ZodObject<any>,
154-
) {}
20+
})
15521

156-
// --- Attitude schema (unchanged) ---
22+
export class RecentlyDto extends createZodDto(RecentlySchema) {}
15723

15824
export const RecentlyAttitudeSchema = z.object({
15925
attitude: z.preprocess(
@@ -164,6 +30,5 @@ export const RecentlyAttitudeSchema = z.object({
16430

16531
export class RecentlyAttitudeDto extends createZodDto(RecentlyAttitudeSchema) {}
16632

167-
// Type exports
168-
export type RecentlyInput = z.infer<typeof RecentlyDiscriminatedSchema>
33+
export type RecentlyInput = z.infer<typeof RecentlySchema>
16934
export type RecentlyAttitudeInput = z.infer<typeof RecentlyAttitudeSchema>

0 commit comments

Comments
 (0)