diff --git a/apps/core/nest-cli.json b/apps/core/nest-cli.json index de8d25dceea..151bae0604c 100644 --- a/apps/core/nest-cli.json +++ b/apps/core/nest-cli.json @@ -3,7 +3,7 @@ "sourceRoot": "src", "compilerOptions": { "builder": "swc", - "typeCheck": true, + "typeCheck": false, "plugins": [], "assets": [ "**/*.json" diff --git a/apps/core/package.json b/apps/core/package.json index a77e3868231..c9f4ff4752e 100644 --- a/apps/core/package.json +++ b/apps/core/package.json @@ -58,8 +58,8 @@ "@babel/plugin-transform-modules-commonjs": "7.28.6", "@babel/plugin-transform-typescript": "7.28.6", "@babel/types": "^7.29.0", - "@better-auth/api-key": "^1.5.1", - "@better-auth/passkey": "^1.5.1", + "@better-auth/api-key": "^1.6.2", + "@better-auth/passkey": "^1.6.2", "@fastify/cookie": "11.0.2", "@fastify/multipart": "10.0.0", "@fastify/static": "9.1.0", @@ -92,7 +92,7 @@ "axios": "^1.13.3", "axios-retry": "4.5.0", "bcryptjs": "^3.0.3", - "better-auth": "^1.5.1", + "better-auth": "^1.6.2", "blurhash": "2.0.5", "cache-manager": "7.2.8", "commander": "14.0.3", diff --git a/apps/core/src/common/decorators/translate-fields.decorator.ts b/apps/core/src/common/decorators/translate-fields.decorator.ts index 3e61ff805f8..b50b8dc0628 100644 --- a/apps/core/src/common/decorators/translate-fields.decorator.ts +++ b/apps/core/src/common/decorators/translate-fields.decorator.ts @@ -7,7 +7,7 @@ export const TRANSLATE_FIELDS_KEY = 'translate_fields' export interface TranslateFieldRule { path: string keyPath: TranslationEntryKeyPath - idField?: string + idField?: 'id' } export const TranslateFields = (...rules: TranslateFieldRule[]) => diff --git a/apps/core/src/common/interceptors/translation-entry.interceptor.ts b/apps/core/src/common/interceptors/translation-entry.interceptor.ts index dcb08400abd..92afc5714e9 100644 --- a/apps/core/src/common/interceptors/translation-entry.interceptor.ts +++ b/apps/core/src/common/interceptors/translation-entry.interceptor.ts @@ -272,7 +272,7 @@ export class TranslationEntryInterceptor implements NestInterceptor { private collectEntityIds( data: any, path: string, - idField: string, + idField: 'id', ids: Set, ): void { this.visitMatches(data, path, ({ parent }) => { @@ -297,7 +297,7 @@ export class TranslationEntryInterceptor implements NestInterceptor { private replaceEntityValues( data: any, path: string, - idField: string, + idField: 'id', map: Map, ): void { this.visitMatches(data, path, ({ parent, property }) => { diff --git a/apps/core/src/common/zod/index.ts b/apps/core/src/common/zod/index.ts index 1883e0d851d..cdb47bfb182 100644 --- a/apps/core/src/common/zod/index.ts +++ b/apps/core/src/common/zod/index.ts @@ -37,5 +37,6 @@ export { ExtendedZodValidationPipe, extendedZodValidationPipeInstance, } from './validation.pipe' +export { zObjectIdString } from '~/shared/id' export { createZodDto } from 'nestjs-zod' export { z } from 'zod' diff --git a/apps/core/src/common/zod/primitives.ts b/apps/core/src/common/zod/primitives.ts index e739f1fa592..0ba7a3621e8 100644 --- a/apps/core/src/common/zod/primitives.ts +++ b/apps/core/src/common/zod/primitives.ts @@ -1,10 +1,10 @@ import { z } from 'zod' +import { zObjectIdString } from '~/shared/id' + // MongoDB Types -export const zMongoId = z - .string() - .regex(/^[0-9a-f]{24}$/i, 'Invalid MongoDB ObjectId') +export const zMongoId = zObjectIdString export const zMongoIdOrInt = z.union([ zMongoId, @@ -24,7 +24,7 @@ export const zNilOrString = z.string().nullable().optional() export const zHexColor = z .string() - .regex(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i, 'Invalid hex color') + .regex(/^#([\da-f]{3}|[\da-f]{6})$/i, 'Invalid hex color') // URL Types diff --git a/apps/core/src/migration/version/v10.4.3.ts b/apps/core/src/migration/version/v10.4.3.ts index 3267f773087..290d6892b61 100644 --- a/apps/core/src/migration/version/v10.4.3.ts +++ b/apps/core/src/migration/version/v10.4.3.ts @@ -7,6 +7,7 @@ import { SEARCH_DOCUMENT_COLLECTION_NAME, } from '~/constants/db.constant' import { buildSearchDocument } from '~/modules/search/search-document.util' +import { normalizeDocumentIds } from '~/shared/model/plugins/lean-id' import { defineMigration } from '../helper' @@ -73,9 +74,15 @@ export default defineMigration( ]) const documents = [ - ...posts.map((doc) => buildSearchDocument('post', doc)), - ...pages.map((doc) => buildSearchDocument('page', doc)), - ...notes.map((doc) => buildSearchDocument('note', doc)), + ...posts.map((doc) => + buildSearchDocument('post', normalizeDocumentIds(doc)), + ), + ...pages.map((doc) => + buildSearchDocument('page', normalizeDocumentIds(doc)), + ), + ...notes.map((doc) => + buildSearchDocument('note', normalizeDocumentIds(doc)), + ), ] const collection = db.collection(SEARCH_DOCUMENT_COLLECTION_NAME) diff --git a/apps/core/src/modules/activity/activity.controller.ts b/apps/core/src/modules/activity/activity.controller.ts index 48e93bcfaf7..763c2b5644c 100644 --- a/apps/core/src/modules/activity/activity.controller.ts +++ b/apps/core/src/modules/activity/activity.controller.ts @@ -1,16 +1,18 @@ import { Body, Delete, Get, Param, Post, Query } from '@nestjs/common' +import { keyBy, pick } from 'es-toolkit/compat' + import { ApiController } from '~/common/decorators/api-controller.decorator' import { Auth } from '~/common/decorators/auth.decorator' import { HttpCache } from '~/common/decorators/cache.decorator' import { HTTPDecorators } from '~/common/decorators/http.decorator' -import { IpLocation } from '~/common/decorators/ip.decorator' import type { IpRecord } from '~/common/decorators/ip.decorator' +import { IpLocation } from '~/common/decorators/ip.decorator' import { Lang } from '~/common/decorators/lang.decorator' import { CollectionRefTypes } from '~/constants/db.constant' import { TranslationService } from '~/processors/helper/helper.translation.service' import { PagerDto } from '~/shared/dto/pager.dto' import { snakecaseKeysWithCompat } from '~/utils/case.util' -import { keyBy, pick } from 'es-toolkit/compat' + import { ReaderService } from '../reader/reader.service' import { Activity } from './activity.constant' import { @@ -74,11 +76,13 @@ export class ActivityController { const { page, size, type } = pager switch (type) { - case Activity.Like: + case Activity.Like: { return this.service.getLikeActivities(page, size) + } - case Activity.ReadDuration: + case Activity.ReadDuration: { return this.service.getReadDurationActivities(page, size) + } } } @@ -103,10 +107,7 @@ export class ActivityController { .findReaderInIds(readerIds) .then((arr) => { return arr.map((item) => { - return snakecaseKeysWithCompat({ - ...item, - id: item._id.toHexString(), - }) + return snakecaseKeysWithCompat(item) }) }) @@ -159,7 +160,7 @@ export class ActivityController { targetLang: lang, translationFields: ['title', 'translationMeta'] as const, getInput: (item: any) => ({ - id: item.id ?? item._id?.toString?.() ?? '', + id: item.id ?? '', title: item.title ?? '', created: item.created, }), @@ -340,7 +341,7 @@ export class ActivityController { targetLang: lang, translationFields: ['title'] as const, getInput: (item) => ({ - id: item._id?.toString?.() ?? '', + id: item.id ?? '', title: item.title ?? '', created: item.created, modified: item.modified, @@ -356,7 +357,7 @@ export class ActivityController { targetLang: lang, translationFields: ['title'] as const, getInput: (item) => ({ - id: item._id?.toString?.() ?? '', + id: item.id ?? '', title: item.title ?? '', created: item.created, modified: item.modified, @@ -438,7 +439,7 @@ export class ActivityController { targetLang: lang, translationFields: ['title', 'translationMeta'] as const, getInput: (item: any) => ({ - id: item._id?.toString?.() ?? item.id ?? '', + id: item.id ?? '', title: item.title ?? '', created: item.created, }), @@ -462,10 +463,7 @@ export class ActivityController { targetLang: lang, translationFields: ['title', 'translationMeta'] as const, getInput: (item: any) => ({ - id: - item.title === '未公开的日记' - ? '' - : (item._id?.toString?.() ?? ''), + id: item.title === '未公开的日记' ? '' : (item.id ?? ''), title: item.title ?? '', created: item.created, }), diff --git a/apps/core/src/modules/activity/activity.service.ts b/apps/core/src/modules/activity/activity.service.ts index c5bb3674b36..86dc531dff1 100644 --- a/apps/core/src/modules/activity/activity.service.ts +++ b/apps/core/src/modules/activity/activity.service.ts @@ -22,6 +22,7 @@ import { GatewayService } from '~/processors/gateway/gateway.service' import { WebEventsGateway } from '~/processors/gateway/web/events.gateway' import { CountingService } from '~/processors/helper/helper.counting.service' import { EventManagerService } from '~/processors/helper/helper.event.service' +import { normalizeDocumentIds } from '~/shared/model/plugins/lean-id' import { InjectModel } from '~/transformers/model.transformer' import { transformDataToPaginate } from '~/transformers/paginate.transformer' import { checkRefModelCollectionType } from '~/utils/biz.util' @@ -204,7 +205,7 @@ export class ActivityService implements OnModuleInit, OnModuleDestroy { const readerMap = new Map() for (const reader of readers) { - readerMap.set(reader._id.toHexString(), reader) + readerMap.set(reader.id, reader) } const type2Collection = { @@ -231,7 +232,8 @@ export class ActivityService implements OnModuleInit, OnModuleDestroy { .toArray() for (const doc of docs) { - refModelData.set(doc._id.toHexString(), doc) + normalizeDocumentIds(doc) + refModelData.set(doc.id, doc) } } @@ -337,7 +339,6 @@ export class ActivityService implements OnModuleInit, OnModuleDestroy { reader, ref: pick(refModel, [ 'id', - '_id', 'title', 'nid', 'slug', @@ -395,11 +396,7 @@ export class ActivityService implements OnModuleInit, OnModuleDestroy { const reader = await this.readerService.findReaderInIds([data.readerId]) if (reader.length) { Object.assign(serializedPresenceData, { - reader: camelcaseKeys({ - ...reader[0], - _id: undefined, - id: reader[0]._id.toHexString(), - }), + reader: camelcaseKeys(reader[0]), }) } } @@ -635,6 +632,10 @@ export class ActivityService implements OnModuleInit, OnModuleDestroy { .toArray(), ]) + normalizeDocumentIds(recent) + normalizeDocumentIds(post) + normalizeDocumentIds(note) + const postCategoryIds = post.map((p: any) => p.categoryId).filter(Boolean) const categories = postCategoryIds.length > 0 diff --git a/apps/core/src/modules/aggregate/aggregate.controller.ts b/apps/core/src/modules/aggregate/aggregate.controller.ts index b55a058fcfd..58239b7f632 100644 --- a/apps/core/src/modules/aggregate/aggregate.controller.ts +++ b/apps/core/src/modules/aggregate/aggregate.controller.ts @@ -170,7 +170,6 @@ export class AggregateController { if (lang) { type TopItem = { - _id?: any id?: string title?: string created?: Date | null @@ -183,7 +182,7 @@ export class AggregateController { targetLang: lang, translationFields: ['title', 'translationMeta'] as const, getInput: (item) => ({ - id: item._id?.toString?.() ?? item.id ?? '', + id: item.id ?? '', title: item.title ?? '', created: item.created, modified: item.modified, @@ -206,7 +205,7 @@ export class AggregateController { targetLang: lang, translationFields: ['title', 'translationMeta'] as const, getInput: (item) => ({ - id: item._id?.toString?.() ?? item.id ?? '', + id: item.id ?? '', title: item.title ?? '', created: item.created, modified: item.modified, @@ -241,7 +240,6 @@ export class AggregateController { if (!lang) return result type LatestItem = { - _id?: any id?: string title?: string created?: Date | null @@ -254,7 +252,7 @@ export class AggregateController { targetLang: lang, translationFields: ['title', 'translationMeta'] as const, getInput: (item) => ({ - id: item._id?.toString?.() ?? item.id ?? '', + id: item.id ?? '', title: item.title ?? '', created: item.created, modified: item.modified, @@ -298,7 +296,6 @@ export class AggregateController { const { sort = 1, type, year } = query const data = await this.aggregateService.getTimeline(year, type, sort) type TimelineItem = { - _id?: { toString?: () => string } | string id?: string title: string created?: Date | null @@ -313,7 +310,7 @@ export class AggregateController { targetLang: lang, translationFields: ['title', 'translationMeta'] as const, getInput: (post) => ({ - id: post._id?.toString?.() ?? post.id ?? String(post._id), + id: post.id ?? '', title: post.title, modified: post.modified, created: post.created, @@ -338,7 +335,7 @@ export class AggregateController { targetLang: lang, translationFields: ['title', 'translationMeta'] as const, getInput: (note) => ({ - id: note._id?.toString?.() ?? note.id ?? String(note._id), + id: note.id ?? '', title: note.title, modified: note.modified, created: note.created, diff --git a/apps/core/src/modules/aggregate/aggregate.service.ts b/apps/core/src/modules/aggregate/aggregate.service.ts index fa127444dd9..77e286506e9 100644 --- a/apps/core/src/modules/aggregate/aggregate.service.ts +++ b/apps/core/src/modules/aggregate/aggregate.service.ts @@ -153,7 +153,7 @@ export class AggregateService { .then((list) => list.map((item) => ({ ...pick(item, [ - '_id', + 'id', 'title', 'slug', 'created', @@ -220,7 +220,7 @@ export class AggregateService { .then((list) => list.map((item) => ({ - ...pick(item, ['_id', 'title', 'slug', 'created', 'modified']), + ...pick(item, ['id', 'title', 'slug', 'created', 'modified']), category: item.category, url: encodeURI( `/posts/${(item.category as CategoryModel).slug}/${item.slug}`, @@ -740,7 +740,7 @@ export class AggregateService { .lean() return posts.map((post) => ({ - id: post._id, + id: post.id, title: post.title, slug: post.slug, reads: post.count?.read || 0, diff --git a/apps/core/src/modules/ai/ai-summary/ai-summary.service.ts b/apps/core/src/modules/ai/ai-summary/ai-summary.service.ts index 6ff4e728589..09093008d75 100644 --- a/apps/core/src/modules/ai/ai-summary/ai-summary.service.ts +++ b/apps/core/src/modules/ai/ai-summary/ai-summary.service.ts @@ -456,8 +456,8 @@ export class AiSummaryService implements OnModuleInit { ]) matchedRefIds = [ - ...matchedPosts.map((p) => p._id.toString()), - ...matchedNotes.map((n) => n._id.toString()), + ...matchedPosts.map((post) => post.id), + ...matchedNotes.map((note) => note.id), ] if (matchedRefIds.length === 0) { diff --git a/apps/core/src/modules/ai/ai-translation/ai-translation-event-handler.service.ts b/apps/core/src/modules/ai/ai-translation/ai-translation-event-handler.service.ts index fd3b1634e0b..dc9bd444838 100644 --- a/apps/core/src/modules/ai/ai-translation/ai-translation-event-handler.service.ts +++ b/apps/core/src/modules/ai/ai-translation/ai-translation-event-handler.service.ts @@ -13,6 +13,9 @@ import { AiTranslationService } from './ai-translation.service' import type { ArticleDocument, ArticleEventPayload, + CategoryTranslationEventPayload, + EntityDeleteEventPayload, + TopicTranslationEventPayload, } from './ai-translation.types' import { TranslationEntryService } from './translation-entry.service' @@ -153,11 +156,10 @@ export class AiTranslationEventHandlerService { // === Translation Entry: Category === @OnEvent(BusinessEvents.CATEGORY_CREATE) - async handleCategoryCreate(event: any) { + async handleCategoryCreate(event: CategoryTranslationEventPayload) { if (!(await this.isAutoEntryEnabled())) return - const doc = event - if (!doc?._id || !doc?.name) return - const id = doc._id.toString() + if (!event.name) return + const id = event.id this.logger.log(`Auto-generating translation entry for category: ${id}`) await this.translationEntryService .generateForValues([ @@ -165,7 +167,7 @@ export class AiTranslationEventHandlerService { keyPath: 'category.name', keyType: 'entity', lookupKey: id, - sourceText: doc.name, + sourceText: event.name, }, ]) .catch((err) => @@ -174,14 +176,13 @@ export class AiTranslationEventHandlerService { } @OnEvent(BusinessEvents.CATEGORY_UPDATE) - async handleCategoryUpdate(event: any) { - const doc = event - if (!doc?._id || !doc?.name) return - const id = doc._id.toString() + async handleCategoryUpdate(event: CategoryTranslationEventPayload) { + if (!event.name) return + const id = event.id await this.translationEntryService.handleEntityUpdate( 'category.name', id, - doc.name, + event.name, ) if (!(await this.isAutoEntryEnabled())) return @@ -191,7 +192,7 @@ export class AiTranslationEventHandlerService { keyPath: 'category.name', keyType: 'entity', lookupKey: id, - sourceText: doc.name, + sourceText: event.name, }, ]) .catch((err) => @@ -202,36 +203,35 @@ export class AiTranslationEventHandlerService { } @OnEvent(BusinessEvents.CATEGORY_DELETE) - async handleCategoryDelete(event: any) { - const id = event?.id?.toString?.() ?? event?._id?.toString?.() - if (!id) return - await this.translationEntryService.deleteByKeyPath('category.name', id) + async handleCategoryDelete(event: EntityDeleteEventPayload) { + await this.translationEntryService.deleteByKeyPath( + 'category.name', + event.id, + ) } // === Translation Entry: Topic === @OnEvent(BusinessEvents.TOPIC_CREATE) - async handleTopicCreate(event: any) { + async handleTopicCreate(event: TopicTranslationEventPayload) { if (!(await this.isAutoEntryEnabled())) return - const doc = event - if (!doc?._id) return - const id = doc._id.toString() + const id = event.id const values: Parameters[0] = [] - if (doc.name) { + if (event.name) { values.push({ keyPath: 'topic.name', keyType: 'entity', lookupKey: id, - sourceText: doc.name, + sourceText: event.name, }) } - if (doc.introduce) { + if (event.introduce) { values.push({ keyPath: 'topic.introduce', keyType: 'entity', lookupKey: id, - sourceText: doc.introduce, + sourceText: event.introduce, }) } if (!values.length) return @@ -244,42 +244,40 @@ export class AiTranslationEventHandlerService { } @OnEvent(BusinessEvents.TOPIC_UPDATE) - async handleTopicUpdate(event: any) { - const doc = event - if (!doc?._id) return - const id = doc._id.toString() - if (doc.name != null) { + async handleTopicUpdate(event: TopicTranslationEventPayload) { + const id = event.id + if (event.name != null) { await this.translationEntryService.handleEntityUpdate( 'topic.name', id, - doc.name, + event.name, ) } - if (doc.introduce != null) { + if (event.introduce != null) { await this.translationEntryService.handleEntityUpdate( 'topic.introduce', id, - doc.introduce, + event.introduce, ) } if (!(await this.isAutoEntryEnabled())) return const values: Parameters[0] = [] - if (doc.name) { + if (event.name) { values.push({ keyPath: 'topic.name', keyType: 'entity', lookupKey: id, - sourceText: doc.name, + sourceText: event.name, }) } - if (doc.introduce) { + if (event.introduce) { values.push({ keyPath: 'topic.introduce', keyType: 'entity', lookupKey: id, - sourceText: doc.introduce, + sourceText: event.introduce, }) } if (!values.length) return @@ -291,21 +289,20 @@ export class AiTranslationEventHandlerService { } @OnEvent(BusinessEvents.TOPIC_DELETE) - async handleTopicDelete(event: any) { - const id = event?.id?.toString?.() ?? event?._id?.toString?.() - if (!id) return - await this.translationEntryService.deleteByKeyPath('topic.name', id) - await this.translationEntryService.deleteByKeyPath('topic.introduce', id) + async handleTopicDelete(event: EntityDeleteEventPayload) { + await this.translationEntryService.deleteByKeyPath('topic.name', event.id) + await this.translationEntryService.deleteByKeyPath( + 'topic.introduce', + event.id, + ) } // === Translation Entry: Note mood/weather === @OnEvent(BusinessEvents.NOTE_CREATE) - async handleNoteCreateEntry(event: any) { + async handleNoteCreateEntry(event: { id: string }) { if (!(await this.isAutoEntryEnabled())) return - const id = event?.id?.toString?.() ?? event?._id?.toString?.() - if (!id) return - const note = await this.databaseService.findGlobalById(id) + const note = await this.databaseService.findGlobalById(event.id) if (!note) return const values = this.collectNoteDictValues(note.document) if (!values.length) return @@ -317,11 +314,9 @@ export class AiTranslationEventHandlerService { } @OnEvent(BusinessEvents.NOTE_UPDATE) - async handleNoteUpdateEntry(event: any) { + async handleNoteUpdateEntry(event: { id: string }) { if (!(await this.isAutoEntryEnabled())) return - const id = event?.id?.toString?.() ?? event?._id?.toString?.() - if (!id) return - const note = await this.databaseService.findGlobalById(id) + const note = await this.databaseService.findGlobalById(event.id) if (!note) return const values = this.collectNoteDictValues(note.document) if (!values.length) return diff --git a/apps/core/src/modules/ai/ai-translation/ai-translation.service.ts b/apps/core/src/modules/ai/ai-translation/ai-translation.service.ts index 2c1ce32acec..cde6fd34f64 100644 --- a/apps/core/src/modules/ai/ai-translation/ai-translation.service.ts +++ b/apps/core/src/modules/ai/ai-translation/ai-translation.service.ts @@ -42,7 +42,6 @@ import type { GetTranslationsGroupedQueryInput } from './ai-translation.schema' import type { ArticleContent, ArticleDocument, - ArticleEventDocument, ArticleEventPayload, } from './ai-translation.types' import { BaseTranslationService } from './base-translation.service' @@ -324,13 +323,13 @@ export class AiTranslationService // Build article info map const articleMap = new Map() for (const post of posts) { - articleMap.set(post._id.toString(), { title: post.title, type: 'Post' }) + articleMap.set(post.id, { title: post.title, type: 'Post' }) } for (const note of notes) { - articleMap.set(note._id.toString(), { title: note.title, type: 'Note' }) + articleMap.set(note.id, { title: note.title, type: 'Note' }) } for (const page of pages) { - articleMap.set(page._id.toString(), { title: page.title, type: 'Page' }) + articleMap.set(page.id, { title: page.title, type: 'Page' }) } const allArticleIds = Array.from(articleMap.keys()) @@ -465,17 +464,13 @@ export class AiTranslationService } extractIdFromEvent(event: ArticleEventPayload): string | null { - if ('data' in event) { - return (event as { data: string }).data ?? null + if ('data' in event && typeof event.data === 'string') { + return event.data } if ('id' in event && typeof event.id === 'string') { return event.id } - const doc = event as ArticleEventDocument - if (doc._id && typeof doc._id === 'string') { - return doc._id - } - return doc.id ?? doc._id?.toString?.() ?? null + return null } /** @@ -931,9 +926,9 @@ export class AiTranslationService ]) matchedRefIds = [ - ...matchedPosts.map((p) => p._id.toString()), - ...matchedNotes.map((n) => n._id.toString()), - ...matchedPages.map((p) => p._id.toString()), + ...matchedPosts.map((post) => post.id), + ...matchedNotes.map((note) => note.id), + ...matchedPages.map((page) => page.id), ] if (matchedRefIds.length === 0) { diff --git a/apps/core/src/modules/ai/ai-translation/ai-translation.types.ts b/apps/core/src/modules/ai/ai-translation/ai-translation.types.ts index a2f365a0608..9942fa49d38 100644 --- a/apps/core/src/modules/ai/ai-translation/ai-translation.types.ts +++ b/apps/core/src/modules/ai/ai-translation/ai-translation.types.ts @@ -17,15 +17,28 @@ export interface ArticleContent { export type ArticleDocument = PostModel | NoteModel | PageModel -export type ArticleEventDocument = ArticleDocument & { - _id?: { toString?: () => string } | string -} +export type ArticleEventDocument = ArticleDocument export type ArticleEventPayload = | ArticleEventDocument | { data: string } | { id: string } +export type CategoryTranslationEventPayload = { + id: string + name?: string | null +} + +export type TopicTranslationEventPayload = { + id: string + name?: string | null + introduce?: string | null +} + +export type EntityDeleteEventPayload = { + id: string +} + export type GlobalArticle = | { document: PostModel; type: CollectionRefTypes.Post } | { document: NoteModel; type: CollectionRefTypes.Note } diff --git a/apps/core/src/modules/ai/ai-translation/translation-entry.service.ts b/apps/core/src/modules/ai/ai-translation/translation-entry.service.ts index acf6335a5ca..cb0ee6e0509 100644 --- a/apps/core/src/modules/ai/ai-translation/translation-entry.service.ts +++ b/apps/core/src/modules/ai/ai-translation/translation-entry.service.ts @@ -215,7 +215,7 @@ export class TranslationEntryService { values.push({ keyPath: 'category.name', keyType: 'entity', - lookupKey: cat._id.toString(), + lookupKey: cat.id, sourceText: cat.name, }) } @@ -227,7 +227,7 @@ export class TranslationEntryService { values.push({ keyPath: 'topic.name', keyType: 'entity', - lookupKey: topic._id.toString(), + lookupKey: topic.id, sourceText: topic.name, }) } @@ -235,7 +235,7 @@ export class TranslationEntryService { values.push({ keyPath: 'topic.introduce', keyType: 'entity', - lookupKey: topic._id.toString(), + lookupKey: topic.id, sourceText: topic.introduce, }) } diff --git a/apps/core/src/modules/ai/ai-writer/ai-slug-backfill.service.ts b/apps/core/src/modules/ai/ai-writer/ai-slug-backfill.service.ts index 6c2708226e0..c0ca593c6d9 100644 --- a/apps/core/src/modules/ai/ai-writer/ai-slug-backfill.service.ts +++ b/apps/core/src/modules/ai/ai-writer/ai-slug-backfill.service.ts @@ -165,7 +165,7 @@ export class AiSlugBackfillService implements OnModuleInit { const updated = await this.noteModel.updateOne( { - _id: note._id, + _id: note.id, $or: [ { slug: { $exists: false } }, { slug: null }, diff --git a/apps/core/src/modules/auth/auth.service.ts b/apps/core/src/modules/auth/auth.service.ts index 60a6b72ff57..722a8fb0308 100644 --- a/apps/core/src/modules/auth/auth.service.ts +++ b/apps/core/src/modules/auth/auth.service.ts @@ -19,6 +19,7 @@ import { import { ErrorCodeEnum } from '~/constants/error-code.constant' import { alphabet } from '~/constants/other.constant' import { DatabaseService } from '~/processors/database/database.service' +import { normalizeDocumentIds } from '~/shared/model/plugins/lean-id' import { getAvatar } from '~/utils/tool.util' import { AuthInstanceInjectKey } from './auth.constant' @@ -123,13 +124,18 @@ export class AuthService { .find(this.buildApiKeyOwnerQuery(ownerId)) .toArray() - return keys.map((token) => ({ - id: token._id?.toString(), - token: token.key, - name: token.name, - created: token.createdAt, - expired: token.expiresAt ?? undefined, - })) + return keys.map((token) => { + const normalizedToken = normalizeDocumentIds({ + ...token, + }) as ApiKeyDocument + return { + id: normalizedToken.id, + token: normalizedToken.key, + name: normalizedToken.name, + created: normalizedToken.createdAt, + expired: normalizedToken.expiresAt ?? undefined, + } + }) } async getTokenSecret(id: string) { @@ -143,12 +149,13 @@ export class AuthService { if (!token) { return null } + const normalizedToken = normalizeDocumentIds({ ...token }) as ApiKeyDocument return { - id: token._id?.toString(), - token: token.key, - name: token.name, - created: token.createdAt, - expired: token.expiresAt ?? undefined, + id: normalizedToken.id, + token: normalizedToken.key, + name: normalizedToken.name, + created: normalizedToken.createdAt, + expired: normalizedToken.expiresAt ?? undefined, } } @@ -774,15 +781,18 @@ export class AuthService { if (!reader) { return null } + const normalizedReader = normalizeDocumentIds({ + ...reader, + }) as SessionUser & Record return { - id: reader._id?.toString(), - email: reader.email, - name: reader.name, - image: reader.image, - role: reader.role, - handle: reader.handle, - username: reader.username, - displayUsername: reader.displayUsername, + id: normalizedReader.id, + email: normalizedReader.email as string | null | undefined, + name: normalizedReader.name as string | undefined, + image: normalizedReader.image as string | null | undefined, + role: normalizedReader.role as SessionUser['role'], + handle: normalizedReader.handle as string | undefined, + username: normalizedReader.username as string | undefined, + displayUsername: normalizedReader.displayUsername as string | undefined, } } } diff --git a/apps/core/src/modules/category/category.controller.ts b/apps/core/src/modules/category/category.controller.ts index 571ef278604..5a52bad80e9 100644 --- a/apps/core/src/modules/category/category.controller.ts +++ b/apps/core/src/modules/category/category.controller.ts @@ -48,7 +48,7 @@ export class CategoryController { @TranslateFields({ path: '[].name', keyPath: 'category.name', - idField: '_id', + idField: 'id', }) async getCategories( @Query() query: MultiCategoriesQueryDto, @@ -90,7 +90,7 @@ export class CategoryController { @TranslateFields({ path: 'data.name', keyPath: 'category.name', - idField: '_id', + idField: 'id', }) async getCategoryById( @Param() { query }: SlugOrIdDto, @@ -124,7 +124,7 @@ export class CategoryController { } let children: any[] = - (await this.categoryService.findCategoryPost(res._id.toHexString(), { + (await this.categoryService.findCategoryPost(res.id, { $and: [tag ? { tags: tag } : {}], })) || [] @@ -141,7 +141,7 @@ export class CategoryController { targetLang: lang, translationFields: ['title', 'translationMeta'] as const, getInput: (item: any) => ({ - id: item._id?.toString?.() ?? item.id ?? '', + id: item.id ?? '', title: item.title ?? '', created: item.created, modified: item.modified, diff --git a/apps/core/src/modules/category/category.service.ts b/apps/core/src/modules/category/category.service.ts index 50b1b1d405f..00b3226599a 100644 --- a/apps/core/src/modules/category/category.service.ts +++ b/apps/core/src/modules/category/category.service.ts @@ -57,7 +57,7 @@ export class CategoryService implements OnApplicationBootstrap { const data = await this.model.find({ type: CategoryType.Category }).lean() const counts = await Promise.all( data.map((item) => { - const id = item._id + const id = item.id return this.postService.model.countDocuments({ categoryId: id }) }), ) @@ -108,12 +108,13 @@ export class CategoryService implements OnApplicationBootstrap { if (posts.length === 0) { throw new CannotFindException() } - return posts.map(({ _id, title, slug, category, created }) => ({ - _id, + return posts.map(({ id, title, slug, category, created, modified }) => ({ + id, title, slug, category: omit(category, ['count', '__v', 'created', 'modified']), created, + modified, })) } @@ -136,7 +137,7 @@ export class CategoryService implements OnApplicationBootstrap { async create(name: string, slug?: string) { const doc = await this.model.create({ name, slug: slug ?? name }) this.clearCache() - this.eventManager.emit(BusinessEvents.CATEGORY_CREATE, doc, { + this.eventManager.emit(BusinessEvents.CATEGORY_CREATE, doc.toObject(), { scope: EventScope.TO_SYSTEM_VISITOR, }) return doc @@ -169,7 +170,11 @@ export class CategoryService implements OnApplicationBootstrap { this.clearCache() - this.eventManager.emit(BusinessEvents.CATEGORY_UPDATE, newDoc, { + if (!newDoc) { + return newDoc + } + + this.eventManager.emit(BusinessEvents.CATEGORY_UPDATE, newDoc.toObject(), { scope: EventScope.TO_SYSTEM_VISITOR, }) return newDoc diff --git a/apps/core/src/modules/comment/comment.controller.ts b/apps/core/src/modules/comment/comment.controller.ts index ef34ed3ff3c..f1f766d9557 100644 --- a/apps/core/src/modules/comment/comment.controller.ts +++ b/apps/core/src/modules/comment/comment.controller.ts @@ -79,10 +79,7 @@ export class CommentController { const model: Partial = { ...body, ...ipLocation } const comment = await this.commentService.createComment(id, model, ref) - this.lifecycleService.afterCreateComment( - String((comment as any).id || (comment as any)._id), - ipLocation, - ) + this.lifecycleService.afterCreateComment(comment.id, ipLocation) return this.commentService .fillAndReplaceAvatarUrl([comment]) @@ -352,7 +349,7 @@ export class CommentController { .lean() .populate('ref') - const refId = (currentRefModel?.ref as any)?._id + const refId = (currentRefModel?.ref as any)?.id if (refId) { await this.commentService.model.updateMany( { @@ -428,7 +425,7 @@ export class CommentController { } const comments = await this.commentService.model.find(filter).lean() for (const comment of comments) { - await this.commentService.softDeleteComment(comment._id.toString()) + await this.commentService.softDeleteComment(comment.id) } } else if (ids?.length) { for (const id of ids) { diff --git a/apps/core/src/modules/comment/comment.lifecycle.service.ts b/apps/core/src/modules/comment/comment.lifecycle.service.ts index b3e200968a3..be1ca61ba0e 100644 --- a/apps/core/src/modules/comment/comment.lifecycle.service.ts +++ b/apps/core/src/modules/comment/comment.lifecycle.service.ts @@ -92,10 +92,7 @@ export class CommentLifecycleService implements OnModuleInit, OnModuleDestroy { this.commentCreateListenerDisposer?.() } - async afterCreateComment( - commentId: string, - ipLocation: { ip: string }, - ) { + async afterCreateComment(commentId: string, ipLocation: { ip: string }) { const comment = await this.commentModel .findById(commentId) .lean({ getters: true }) @@ -142,11 +139,8 @@ export class CommentLifecycleService implements OnModuleInit, OnModuleDestroy { }) } - async afterReplyComment( - comment: CommentModel, - ipLocation: { ip: string }, - ) { - const commentId = comment.id ?? (comment as any)._id?.toString() + async afterReplyComment(comment: CommentModel, ipLocation: { ip: string }) { + const commentId = comment.id const isLoggedInComment = !!comment.readerId scheduleManager.schedule(async () => { @@ -187,7 +181,9 @@ export class CommentLifecycleService implements OnModuleInit, OnModuleDestroy { .then((readers) => readers[0] ?? null) } - private toOwnerIdentity(ownerInfo: Awaited>) { + private toOwnerIdentity( + ownerInfo: Awaited>, + ) { return { role: 'owner' as const, author: ownerInfo.name || '', @@ -221,7 +217,9 @@ export class CommentLifecycleService implements OnModuleInit, OnModuleDestroy { author: reader.name || comment.author || '', mail: reader.email || comment.mail || '', avatar: - reader.image || comment.avatar || getAvatar(reader.email || comment.mail), + reader.image || + comment.avatar || + getAvatar(reader.email || comment.mail), } } } @@ -257,14 +255,20 @@ export class CommentLifecycleService implements OnModuleInit, OnModuleDestroy { const parentIdentity = await this.resolveCommentIdentity(parent, ownerInfo) if (!refDoc || !ownerInfo.mail) return - if (type === CommentReplyMailType.Guest && commentIdentity.role === 'guest') { + if ( + type === CommentReplyMailType.Guest && + commentIdentity.role === 'guest' + ) { commentIdentity = !comment.author && !comment.mail && !comment.avatar ? this.toOwnerIdentity(ownerInfo) : commentIdentity } - if (type === CommentReplyMailType.Owner && commentIdentity.role === 'owner') { + if ( + type === CommentReplyMailType.Owner && + commentIdentity.role === 'owner' + ) { return } @@ -414,7 +418,7 @@ export class CommentLifecycleService implements OnModuleInit, OnModuleDestroy { ).toString() } case CollectionRefTypes.Recently: { - return new URL(`/thinking/${model._id}`, base).toString() + return new URL(`/thinking/${model.id}`, base).toString() } } } diff --git a/apps/core/src/modules/comment/comment.service.ts b/apps/core/src/modules/comment/comment.service.ts index 468c351d5dd..808b9202dec 100644 --- a/apps/core/src/modules/comment/comment.service.ts +++ b/apps/core/src/modules/comment/comment.service.ts @@ -62,7 +62,9 @@ export class CommentService { return this.commentModel } - private toObjectId(id: string | Types.ObjectId | { _id?: unknown }) { + private toObjectId( + id: string | Types.ObjectId | { _id?: unknown; id?: unknown }, + ) { if (id instanceof Types.ObjectId) { return id } @@ -71,6 +73,10 @@ export class CommentService { return this.toObjectId((id as { _id?: unknown })._id as any) } + if (typeof id === 'object' && id && 'id' in id) { + return this.toObjectId((id as { id?: unknown }).id as any) + } + return new Types.ObjectId(String(id)) } @@ -329,7 +335,7 @@ export class CommentService { private async resolveAnchorForCreate( anchor: CommentAnchorInput | undefined, - refDoc: Pick & { _id: any }, + refDoc: Pick, ): Promise { if (!anchor) { return undefined @@ -339,7 +345,7 @@ export class CommentService { if (anchor.lang) { const translation = await this.aiTranslationModel - .findOne({ refId: refDoc._id.toString(), lang: anchor.lang }) + .findOne({ refId: refDoc.id, lang: anchor.lang }) .lean() if ( @@ -585,12 +591,12 @@ export class CommentService { ) if (!nextAnchor) { - deleting.push(comment.id ?? comment._id.toString()) + deleting.push(comment.id) continue } await this.commentModel.updateOne( - { _id: comment._id }, + { _id: comment.id }, { $set: { anchor: nextAnchor, @@ -660,7 +666,7 @@ export class CommentService { this.assignAuthProviderToComment(doc) } - let ref: (WriteBaseModel & { _id: any }) | null = null + let ref: WriteBaseModel | null = null let refType = type if (type) { const model = this.getModelByRefType(type) @@ -717,7 +723,7 @@ export class CommentService { }) await this.databaseService.getModelByRefType(refType!).updateOne( - { _id: ref._id }, + { _id: ref.id }, { $inc: { commentsIndex: 1, @@ -918,7 +924,7 @@ export class CommentService { ) const rootIds = comments.docs.map((comment: any) => - (comment.rootCommentId || comment._id).toString(), + String(comment.rootCommentId ?? comment.id), ) const replies = rootIds.length @@ -947,7 +953,7 @@ export class CommentService { } const docs = comments.docs.map((comment: any) => { - const rootId = String(comment.rootCommentId || comment._id) + const rootId = String(comment.rootCommentId ?? comment.id) const threadReplies = repliesByRootId.get(rootId) || [] const { replies, replyWindow } = this.buildReplyWindow(threadReplies) @@ -1078,10 +1084,7 @@ export class CommentService { : [] const readerMap = new Map() readers.forEach((reader) => { - const id = (reader as any).id || (reader as any)._id?.toString?.() - if (id) { - readerMap.set(id, reader) - } + readerMap.set(reader.id, reader) }) comments.forEach(function process(comment) { diff --git a/apps/core/src/modules/draft/draft.service.ts b/apps/core/src/modules/draft/draft.service.ts index fe1086a3384..9c3fc3e7f68 100644 --- a/apps/core/src/modules/draft/draft.service.ts +++ b/apps/core/src/modules/draft/draft.service.ts @@ -188,7 +188,7 @@ export class DraftService { await Promise.all( drafts.map((draft) => this.fileReferenceService.removeReferencesForDocument( - draft._id.toString(), + draft.id, FileReferenceType.Draft, ), ), diff --git a/apps/core/src/modules/file/file.controller.ts b/apps/core/src/modules/file/file.controller.ts index 635ba0f5006..323984742b7 100644 --- a/apps/core/src/modules/file/file.controller.ts +++ b/apps/core/src/modules/file/file.controller.ts @@ -74,7 +74,7 @@ export class FileController { return { data: files.map((file) => ({ - id: file._id, + id: file.id, fileName: file.fileName, fileUrl: file.fileUrl, created: file.created, diff --git a/apps/core/src/modules/link/link-avatar.service.ts b/apps/core/src/modules/link/link-avatar.service.ts index cfa2e4b6059..9ee99f721e0 100644 --- a/apps/core/src/modules/link/link-avatar.service.ts +++ b/apps/core/src/modules/link/link-avatar.service.ts @@ -1,14 +1,17 @@ import { Readable } from 'node:stream' import { URL } from 'node:url' + import { Injectable, Logger } from '@nestjs/common' import type { DocumentType } from '@typegoose/typegoose' +import { customAlphabet } from 'nanoid' + import { BizException } from '~/common/exceptions/biz.exception' import { ErrorCodeEnum } from '~/constants/error-code.constant' import { alphabet } from '~/constants/other.constant' import { HttpService } from '~/processors/helper/helper.http.service' import { InjectModel } from '~/transformers/model.transformer' import { validateImageBuffer } from '~/utils/image.util' -import { customAlphabet } from 'nanoid' + import { ConfigsService } from '../configs/configs.service' import { FileService } from '../file/file.service' import type { FileType } from '../file/file.type' @@ -81,7 +84,7 @@ export class LinkAvatarService { } } catch (error: any) { this.logger.warn( - `解析友链 ${doc._id} 的站点地址失败: ${error?.message || String(error)}`, + `解析友链 ${doc.id} 的站点地址失败: ${error?.message || String(error)}`, ) } return webUrl @@ -107,7 +110,7 @@ export class LinkAvatarService { !this.isAllowedMimeType(normalizedContentType) ) { this.logger.warn( - `友链 ${doc._id} 头像响应类型 ${contentType || 'unknown'} 不在受支持图片范围,跳过内链转换`, + `友链 ${doc.id} 头像响应类型 ${contentType || 'unknown'} 不在受支持图片范围,跳过内链转换`, ) return false } @@ -144,7 +147,7 @@ export class LinkAvatarService { doc.avatar = internalUrl await doc.save() - this.logger.log(`友链 ${doc._id} 头像已转换为内部链接`) + this.logger.log(`友链 ${doc.id} 头像已转换为内部链接`) return true } @@ -173,14 +176,14 @@ export class LinkAvatarService { for (const link of links) { try { if (this.isExternalAvatar(link.avatar as string)) { - const converted = await this.convertToInternal(String(link._id)) + const converted = await this.convertToInternal(link.id) if (converted) { - updatedIds.push(String(link._id)) + updatedIds.push(link.id) } } } catch (error: any) { this.logger.error( - `迁移友链头像失败: ${link._id} - ${error?.message || String(error)}`, + `迁移友链头像失败: ${link.id} - ${error?.message || String(error)}`, ) } } diff --git a/apps/core/src/modules/link/link.service.ts b/apps/core/src/modules/link/link.service.ts index d8d4722a2be..3e27d3f7e26 100644 --- a/apps/core/src/modules/link/link.service.ts +++ b/apps/core/src/modules/link/link.service.ts @@ -61,7 +61,7 @@ export class LinkService { case LinkState.Outdate: { nextModel = await this.model .findOneAndUpdate( - { _id: existedDoc._id }, + { _id: existedDoc.id }, { $set: { state: LinkState.Audit, diff --git a/apps/core/src/modules/markdown/markdown.service.ts b/apps/core/src/modules/markdown/markdown.service.ts index 60e7d5f5d78..a8deef68ca5 100644 --- a/apps/core/src/modules/markdown/markdown.service.ts +++ b/apps/core/src/modules/markdown/markdown.service.ts @@ -4,16 +4,18 @@ import { Logger, } from '@nestjs/common' import type { ReturnModelType } from '@typegoose/typegoose' +import { omit } from 'es-toolkit/compat' +import { dump } from 'js-yaml' +import JSZip from 'jszip' +import { Types } from 'mongoose' + import { BizException } from '~/common/exceptions/biz.exception' import { CollectionRefTypes } from '~/constants/db.constant' import { ErrorCodeEnum } from '~/constants/error-code.constant' import { DatabaseService } from '~/processors/database/database.service' import { AssetService } from '~/processors/helper/helper.asset.service' import { InjectModel } from '~/transformers/model.transformer' -import { omit } from 'es-toolkit/compat' -import { dump } from 'js-yaml' -import JSZip from 'jszip' -import { Types } from 'mongoose' + import { CategoryModel } from '../category/category.model' import { NoteModel } from '../note/note.model' import { PageModel } from '../page/page.model' @@ -45,7 +47,7 @@ export class MarkdownService { let count = 1 const categoryNameAndId = (await this.categoryModel.find().lean()).map( (c) => { - return { name: c.name, _id: c._id, slug: c.slug } + return { name: c.name, id: c.id, slug: c.slug } }, ) @@ -66,7 +68,7 @@ export class MarkdownService { }) categoryNameAndId.push({ name: newCategoryDoc.name, - _id: newCategoryDoc._id, + id: newCategoryDoc.id, slug: newCategoryDoc.slug, }) @@ -89,7 +91,7 @@ export class MarkdownService { slug: Date.now(), text: item.text, ...genDate(item), - categoryId: new Types.ObjectId(defaultCategory._id), + categoryId: new Types.ObjectId(defaultCategory.id), } as any as PostModel) } else { const category = await insertOrCreateCategory( @@ -100,9 +102,9 @@ export class MarkdownService { slug: item.meta.slug || item.meta.title, text: item.text, ...genDate(item), - categoryId: category?._id.toHexString() || defaultCategory._id, + categoryId: category?.id ?? defaultCategory.id, tags: item.meta.tags || [], - } as PostModel) + } as unknown as PostModel) } } return await this.postModel diff --git a/apps/core/src/modules/note/note.controller.ts b/apps/core/src/modules/note/note.controller.ts index 6ea0f18288e..ea5ecf9060f 100644 --- a/apps/core/src/modules/note/note.controller.ts +++ b/apps/core/src/modules/note/note.controller.ts @@ -48,8 +48,7 @@ import { import { NoteService } from './note.service' type NoteListItem = { - _id?: { toString?: () => string } | string - id?: string + id: string nid?: number title: string slug?: string @@ -163,11 +162,7 @@ export class NoteController { const idMap = new Map() for (const note of notes) { if (!note) continue - const id = - typeof note._id === 'string' - ? note._id - : (note._id?.toString?.() ?? note.id ?? '') - if (id) idMap.set(note, id) + if (note.id) idMap.set(note, note.id) } if (!idMap.size) return @@ -187,11 +182,11 @@ export class NoteController { @TranslateFields( { path: 'docs[].mood', keyPath: 'note.mood' }, { path: 'docs[].weather', keyPath: 'note.weather' }, - { path: 'docs[].topic.name', keyPath: 'topic.name', idField: '_id' }, + { path: 'docs[].topic.name', keyPath: 'topic.name', idField: 'id' }, { path: 'docs[].topic.introduce', keyPath: 'topic.introduce', - idField: '_id', + idField: 'id', }, ) async getNotes( @@ -260,7 +255,7 @@ export class NoteController { if (typeof doc.text === 'string') { translationInputs.push({ - id: doc._id?.toString?.() ?? doc.id ?? String(doc._id), + id: doc.id, title: doc.title, text: doc.text, meta: doc.meta as { lang?: string } | undefined, @@ -286,7 +281,7 @@ export class NoteController { }) result.docs = result.docs.map((doc) => { - const docId = doc._id?.toString?.() ?? doc.id ?? String(doc._id) + const docId = doc.id const translation = translationResults.get(docId) if (!translation?.isTranslated) { return doc @@ -325,13 +320,12 @@ export class NoteController { private async enrichDocsWithSummary( result: { docs: (NoteModel & { - _id?: { toString: () => string } toObject?: () => Record })[] }, lang?: string, ) { - const ids = result.docs.map((d) => d.id || d._id!.toString()) + const ids = result.docs.map((d) => d.id) const summaryMap = await this.aiSummaryService.batchGetSummariesByRefIds( ids, lang || DEFAULT_SUMMARY_LANG, @@ -341,9 +335,7 @@ export class NoteController { const plain = ( typeof doc.toObject === 'function' ? doc.toObject() : doc ) as Record - const docId = - (plain.id as string) || - (plain._id as { toString: () => string })?.toString() + const docId = plain.id as string plain.summary = summaryMap.get(docId) ?? (plain.text as string)?.slice(0, 150) ?? '' delete plain.text @@ -387,15 +379,15 @@ export class NoteController { @TranslateFields( { path: 'mood', keyPath: 'note.mood' }, { path: 'weather', keyPath: 'note.weather' }, - { path: 'topic.name', keyPath: 'topic.name', idField: '_id' }, - { path: 'topic.introduce', keyPath: 'topic.introduce', idField: '_id' }, + { path: 'topic.name', keyPath: 'topic.name', idField: 'id' }, + { path: 'topic.introduce', keyPath: 'topic.introduce', idField: 'id' }, { path: 'data.mood', keyPath: 'note.mood' }, { path: 'data.weather', keyPath: 'note.weather' }, - { path: 'data.topic.name', keyPath: 'topic.name', idField: '_id' }, + { path: 'data.topic.name', keyPath: 'topic.name', idField: 'id' }, { path: 'data.topic.introduce', keyPath: 'topic.introduce', - idField: '_id', + idField: 'id', }, ) async getNoteByDateAndSlug( @@ -493,7 +485,7 @@ export class NoteController { targetLang: lang, translationFields: ['title', 'translationMeta'] as const, getInput: (item) => ({ - id: item._id?.toString?.() ?? item.id ?? String(item._id), + id: item.id, title: item.title, modified: item.modified, created: item.created, @@ -545,15 +537,15 @@ export class NoteController { @TranslateFields( { path: 'mood', keyPath: 'note.mood' }, { path: 'weather', keyPath: 'note.weather' }, - { path: 'topic.name', keyPath: 'topic.name', idField: '_id' }, - { path: 'topic.introduce', keyPath: 'topic.introduce', idField: '_id' }, + { path: 'topic.name', keyPath: 'topic.name', idField: 'id' }, + { path: 'topic.introduce', keyPath: 'topic.introduce', idField: 'id' }, { path: 'data.mood', keyPath: 'note.mood' }, { path: 'data.weather', keyPath: 'note.weather' }, - { path: 'data.topic.name', keyPath: 'topic.name', idField: '_id' }, + { path: 'data.topic.name', keyPath: 'topic.name', idField: 'id' }, { path: 'data.topic.introduce', keyPath: 'topic.introduce', - idField: '_id', + idField: 'id', }, ) async getLatestOne( @@ -601,15 +593,15 @@ export class NoteController { @TranslateFields( { path: 'mood', keyPath: 'note.mood' }, { path: 'weather', keyPath: 'note.weather' }, - { path: 'topic.name', keyPath: 'topic.name', idField: '_id' }, - { path: 'topic.introduce', keyPath: 'topic.introduce', idField: '_id' }, + { path: 'topic.name', keyPath: 'topic.name', idField: 'id' }, + { path: 'topic.introduce', keyPath: 'topic.introduce', idField: 'id' }, { path: 'data.mood', keyPath: 'note.mood' }, { path: 'data.weather', keyPath: 'note.weather' }, - { path: 'data.topic.name', keyPath: 'topic.name', idField: '_id' }, + { path: 'data.topic.name', keyPath: 'topic.name', idField: 'id' }, { path: 'data.topic.introduce', keyPath: 'topic.introduce', - idField: '_id', + idField: 'id', }, ) async getNoteByNid( @@ -682,7 +674,7 @@ export class NoteController { targetLang: lang, translationFields: ['title', 'translationMeta'] as const, getInput: (item) => ({ - id: item._id?.toString?.() ?? item.id ?? String(item._id), + id: item.id, title: item.title, modified: item.modified, created: item.created, diff --git a/apps/core/src/modules/note/note.service.ts b/apps/core/src/modules/note/note.service.ts index e8becfa64ff..f1c0f564346 100644 --- a/apps/core/src/modules/note/note.service.ts +++ b/apps/core/src/modules/note/note.service.ts @@ -148,8 +148,7 @@ export class NoteService { return } - const existingId = existing.id ?? existing._id?.toString?.() - if (excludeId && existingId === excludeId) { + if (excludeId && existing.id === excludeId) { return } @@ -573,7 +572,7 @@ export class NoteService { if (!document) { return null } - return document._id + return document.id } async findOneByIdOrNid(unique: any) { diff --git a/apps/core/src/modules/owner/owner.model.ts b/apps/core/src/modules/owner/owner.model.ts index 3ace9e0c21b..e8a796cb10d 100644 --- a/apps/core/src/modules/owner/owner.model.ts +++ b/apps/core/src/modules/owner/owner.model.ts @@ -9,7 +9,6 @@ const securityKeys = [ export class OwnerModel { id: string - _id?: unknown username!: string name!: string diff --git a/apps/core/src/modules/owner/owner.service.ts b/apps/core/src/modules/owner/owner.service.ts index 2f56a7e857d..e3c8fb327e2 100644 --- a/apps/core/src/modules/owner/owner.service.ts +++ b/apps/core/src/modules/owner/owner.service.ts @@ -12,6 +12,7 @@ import { ErrorCodeEnum } from '~/constants/error-code.constant' import { EventBusEvents } from '~/constants/event-bus.constant' import { DatabaseService } from '~/processors/database/database.service' import { EventManagerService } from '~/processors/helper/helper.event.service' +import { normalizeDocumentIds } from '~/shared/model/plugins/lean-id' import { InjectModel } from '~/transformers/model.transformer' import { getAvatar } from '~/utils/tool.util' @@ -68,21 +69,25 @@ export class OwnerService { } private toOwnerModel(reader: any, profile: any): OwnerDocument { + const normalizedReader = reader ? normalizeDocumentIds({ ...reader }) : null const mail = profile?.mail ?? reader?.email ?? '' const avatar = - reader?.image ?? - getAvatar(mail || reader?.email || reader?.username || 'owner@local') + normalizedReader?.image ?? + getAvatar( + mail || + normalizedReader?.email || + normalizedReader?.username || + 'owner@local', + ) return { - id: reader?._id?.toString?.() || reader?.id || '', - _id: reader?._id, - - username: reader?.username ?? reader?.handle ?? '', + id: normalizedReader?.id ?? '', + username: normalizedReader?.username ?? normalizedReader?.handle ?? '', name: - reader?.name ?? - reader?.displayUsername ?? - reader?.username ?? - reader?.handle ?? + normalizedReader?.name ?? + normalizedReader?.displayUsername ?? + normalizedReader?.username ?? + normalizedReader?.handle ?? 'owner', introduce: profile?.introduce, avatar, @@ -92,11 +97,11 @@ export class OwnerService { lastLoginIp: profile?.lastLoginIp, socialIds: profile?.socialIds, role: 'owner', - email: reader?.email, - image: reader?.image, - handle: reader?.handle, - displayUsername: reader?.displayUsername, - created: reader?.createdAt ?? profile?.created, + email: normalizedReader?.email, + image: normalizedReader?.image, + handle: normalizedReader?.handle, + displayUsername: normalizedReader?.displayUsername, + created: normalizedReader?.createdAt ?? profile?.created, } } diff --git a/apps/core/src/modules/page/page.controller.ts b/apps/core/src/modules/page/page.controller.ts index 410e5c401d0..a30159e078f 100644 --- a/apps/core/src/modules/page/page.controller.ts +++ b/apps/core/src/modules/page/page.controller.ts @@ -83,7 +83,7 @@ export class PageController { doc.meta = JSON.safeParse(doc.meta as string) || doc.meta } translationInputs.push({ - id: doc._id?.toString?.() ?? doc.id ?? String(doc._id), + id: doc.id, title: doc.title, text: doc.text, subtitle: doc.subtitle, @@ -103,7 +103,7 @@ export class PageController { }) result.docs = result.docs.map((doc) => { - const docId = doc._id?.toString?.() ?? doc.id ?? String(doc._id) + const docId = doc.id const translation = translationResults.get(docId) if (!translation?.isTranslated) { return doc @@ -174,7 +174,7 @@ export class PageController { } const translationResult = await this.translationService.translateArticle({ - articleId: page._id?.toString?.() ?? page.id ?? String(page._id), + articleId: page.id, targetLang: lang, originalData: { title: page.title, diff --git a/apps/core/src/modules/post/post.controller.ts b/apps/core/src/modules/post/post.controller.ts index 5a89b46ab3d..9ecd598ba98 100644 --- a/apps/core/src/modules/post/post.controller.ts +++ b/apps/core/src/modules/post/post.controller.ts @@ -25,6 +25,7 @@ import { TranslationService, } from '~/processors/helper/helper.translation.service' import { MongoIdDto } from '~/shared/dto/id.dto' +import { normalizeDocumentIds } from '~/shared/model/plugins/lean-id' import { addYearCondition } from '~/transformers/db-query.transformer' import { applyContentPreference } from '~/utils/content.util' @@ -53,7 +54,7 @@ export class PostController { @TranslateFields({ path: 'docs[].category.name', keyPath: 'category.name', - idField: '_id', + idField: 'id', }) async getPaginate( @Query() query: PostPagerDto, @@ -160,6 +161,9 @@ export class PostController { }, ) .then(async (res) => { + res.docs.forEach((doc) => + normalizeDocumentIds(doc, this.postService.model.schema), + ) const translationInputs: ArticleTranslationInput[] = [] for (const doc of res.docs) { const originalText = doc.text @@ -169,7 +173,7 @@ export class PostController { if (lang && typeof originalText === 'string') { translationInputs.push({ - id: doc._id?.toString?.() ?? doc.id ?? String(doc._id), + id: doc.id, title: doc.title, text: originalText, summary: doc.summary, @@ -193,7 +197,7 @@ export class PostController { }) res.docs = res.docs.map((doc) => { - const docId = doc._id?.toString?.() ?? doc.id ?? String(doc._id) + const docId = doc.id const translation = translationResults.get(docId) if (!translation?.isTranslated) { return doc @@ -234,7 +238,7 @@ export class PostController { @TranslateFields({ path: 'category.name', keyPath: 'category.name', - idField: '_id', + idField: 'id', }) async getById( @Param() params: MongoIdDto, @@ -264,7 +268,7 @@ export class PostController { @TranslateFields({ path: 'category.name', keyPath: 'category.name', - idField: '_id', + idField: 'id', }) async getLatest( @IpLocation() ip: IpRecord, @@ -301,7 +305,7 @@ export class PostController { @TranslateFields({ path: 'category.name', keyPath: 'category.name', - idField: '_id', + idField: 'id', }) async getByCateAndSlug( @Param() params: CategoryAndSlugDto, diff --git a/apps/core/src/modules/recently/recently.model.ts b/apps/core/src/modules/recently/recently.model.ts index 7e145825cbb..8c3650476bc 100644 --- a/apps/core/src/modules/recently/recently.model.ts +++ b/apps/core/src/modules/recently/recently.model.ts @@ -49,7 +49,7 @@ export class RecentlyModel extends BaseCommentIndexModel { down: number get refId() { - return (this.ref as any)?._id ?? this.ref + return (this.ref as any)?.id ?? (this.ref as any)?._id ?? this.ref } set refId(id: string) { diff --git a/apps/core/src/modules/recently/recently.service.ts b/apps/core/src/modules/recently/recently.service.ts index 13330b741d4..0e785804778 100644 --- a/apps/core/src/modules/recently/recently.service.ts +++ b/apps/core/src/modules/recently/recently.service.ts @@ -11,6 +11,7 @@ import { ErrorCodeEnum } from '~/constants/error-code.constant' import { DatabaseService } from '~/processors/database/database.service' import { EventManagerService } from '~/processors/helper/helper.event.service' import { RedisService } from '~/processors/redis/redis.service' +import { normalizeDocumentIds } from '~/shared/model/plugins/lean-id' import { InjectModel } from '~/transformers/model.transformer' import { getRedisKey } from '~/utils/redis.util' import { scheduleManager } from '~/utils/schedule.util' @@ -75,6 +76,7 @@ export class RecentlyService { ...this.commentCountPipeline, ])) as RecentlyModel[] + normalizeDocumentIds(result) await this.populateRef(result) return result @@ -90,6 +92,7 @@ export class RecentlyService { }, ])) as RecentlyModel[] + normalizeDocumentIds(result) await this.populateRef(result) return result[0] || null @@ -126,7 +129,8 @@ export class RecentlyService { }) for await (const doc of cursor) { - foreignIdMap[doc._id.toHexString()] = Object.assign({}, doc) + normalizeDocumentIds(doc) + foreignIdMap[doc.id] = Object.assign({}, doc) } } @@ -215,6 +219,7 @@ export class RecentlyService { }, { $limit: size }, ]) + normalizeDocumentIds(result) await this.populateRef(result) return result } @@ -236,7 +241,7 @@ export class RecentlyService { const commentCount = await this.commentService.model.countDocuments({ refType: CollectionRefTypes.Recently, - ref: latest._id, + ref: latest.id, }) return { diff --git a/apps/core/src/modules/search/search-document.util.ts b/apps/core/src/modules/search/search-document.util.ts index 66ff19e4c0e..6990e5d8f81 100644 --- a/apps/core/src/modules/search/search-document.util.ts +++ b/apps/core/src/modules/search/search-document.util.ts @@ -8,8 +8,7 @@ import type { } from './search-document.model' type SearchDocumentSource = { - id?: string - _id?: { toString: () => string } + id: string title?: string | null text?: string | null contentFormat?: string | null @@ -48,7 +47,7 @@ export function buildSearchDocument( return { refType, - refId: data.id ?? data._id?.toString?.() ?? '', + refId: data.id, title: normalizedTitle, searchText: normalizedBody, terms: [ diff --git a/apps/core/src/modules/search/search.service.ts b/apps/core/src/modules/search/search.service.ts index 2fdcbd7b9b2..750d5d35b39 100644 --- a/apps/core/src/modules/search/search.service.ts +++ b/apps/core/src/modules/search/search.service.ts @@ -8,6 +8,7 @@ import { BusinessEvents } from '~/constants/business-event.constant' import { POST_SERVICE_TOKEN } from '~/constants/injection.constant' import type { SearchDto } from '~/modules/search/search.schema' import type { Pagination } from '~/shared/interface/paginator.interface' +import { normalizeDocumentIds } from '~/shared/model/plugins/lean-id' import { InjectModel } from '~/transformers/model.transformer' import { NoteService } from '../note/note.service' @@ -35,7 +36,6 @@ import { type SearchDocumentLean = SearchDocumentModel & { id?: string - _id?: { toString: () => string } } type SearchCorpusStats = { @@ -564,7 +564,10 @@ export class SearchService { refType: SearchDocumentRefType, data: Record, ): SearchDocumentModel { - return buildSearchDocument(refType, data) as SearchDocumentModel + return buildSearchDocument( + refType, + normalizeDocumentIds(data), + ) as SearchDocumentModel } private buildSearchKeywordRegexes(keyword: string) { diff --git a/apps/core/src/modules/serverless/serverless.service.ts b/apps/core/src/modules/serverless/serverless.service.ts index 2aa063b6355..f9fda34e01a 100644 --- a/apps/core/src/modules/serverless/serverless.service.ts +++ b/apps/core/src/modules/serverless/serverless.service.ts @@ -31,6 +31,7 @@ import { DatabaseService } from '~/processors/database/database.service' import { AssetService } from '~/processors/helper/helper.asset.service' import { EventManagerService } from '~/processors/helper/helper.event.service' import { RedisService } from '~/processors/redis/redis.service' +import { normalizeDocumentIds } from '~/shared/model/plugins/lean-id' import { InjectModel } from '~/transformers/model.transformer' import { EncryptUtil } from '~/utils/encrypt.util' import { getRedisKey } from '~/utils/redis.util' @@ -199,14 +200,23 @@ export class ServerlessService implements OnModuleInit, OnModuleDestroy { ? new Types.ObjectId(owner._id.toString()) : owner._id, }) + const normalizedOwner = normalizeDocumentIds({ ...owner }) as Record< + string, + unknown + > + const ownerId = normalizedOwner.id as string return { - id: owner._id.toString(), - _id: owner._id, - username: owner.username ?? owner.handle ?? '', - name: owner.name, + // Preserve the published sandbox contract while exposing canonical id. + _id: ownerId, + id: ownerId, + username: + (normalizedOwner.username as string | undefined) ?? + (normalizedOwner.handle as string | undefined) ?? + '', + name: normalizedOwner.name as string | undefined, introduce: ownerProfile?.introduce, - avatar: owner.image, + avatar: normalizedOwner.image as string | undefined, mail: ownerProfile?.mail ?? owner.email, url: ownerProfile?.url, lastLoginTime: ownerProfile?.lastLoginTime, @@ -418,7 +428,7 @@ export class ServerlessService implements OnModuleInit, OnModuleDestroy { result: SandboxResult, ) { await this.logModel.create({ - functionId: model.id || (model as any)._id?.toString(), + functionId: model.id, reference: model.reference, name: model.name, method: context.req.method, diff --git a/apps/core/src/modules/topic/topic.controller.ts b/apps/core/src/modules/topic/topic.controller.ts index db94ab4f710..53d154437a9 100644 --- a/apps/core/src/modules/topic/topic.controller.ts +++ b/apps/core/src/modules/topic/topic.controller.ts @@ -10,20 +10,20 @@ import { TopicModel } from './topic.model' import { TopicSlugParamsDto } from './topic.schema' const topicTranslateFields = [ - { path: 'name', keyPath: 'topic.name' as const, idField: '_id' as const }, + { path: 'name', keyPath: 'topic.name' as const, idField: 'id' as const }, { path: 'introduce', keyPath: 'topic.introduce' as const, - idField: '_id' as const, + idField: 'id' as const, }, ] const topicTranslateListFields = [ - { path: '[].name', keyPath: 'topic.name' as const, idField: '_id' as const }, + { path: '[].name', keyPath: 'topic.name' as const, idField: 'id' as const }, { path: '[].introduce', keyPath: 'topic.introduce' as const, - idField: '_id' as const, + idField: 'id' as const, }, ] diff --git a/apps/core/src/processors/gateway/web/visitor-event-dispatch.service.ts b/apps/core/src/processors/gateway/web/visitor-event-dispatch.service.ts index ea6061c4d8f..fa4025f07da 100644 --- a/apps/core/src/processors/gateway/web/visitor-event-dispatch.service.ts +++ b/apps/core/src/processors/gateway/web/visitor-event-dispatch.service.ts @@ -298,7 +298,7 @@ export class VisitorEventDispatchService implements OnModuleInit { const sockets = await this.webGateway.getSocketsOfRoom(roomName) if (!sockets.length) return - const articleId = (doc as any).id || (doc as any)._id?.toString() + const articleId = doc.id const originalData = { title: (doc as any).title, text: (doc as any).text, diff --git a/apps/core/src/shared/dto/id.dto.ts b/apps/core/src/shared/dto/id.dto.ts index 092c33e71cd..6b43e84857b 100644 --- a/apps/core/src/shared/dto/id.dto.ts +++ b/apps/core/src/shared/dto/id.dto.ts @@ -1,8 +1,10 @@ import { UnprocessableEntityException } from '@nestjs/common' -import { zMongoId } from '~/common/zod' import { createZodDto } from 'nestjs-zod' import { z } from 'zod' +import { zMongoId } from '~/common/zod' +import type { ObjectIdString } from '~/shared/id' + export const MongoIdSchema = z.object({ id: zMongoId, }) @@ -19,7 +21,7 @@ export const IntIdOrMongoIdSchema = z.object({ id: z.preprocess( (value) => { if (typeof value === 'string') { - if (/^[0-9a-f]{24}$/i.test(value)) { + if (/^[\da-f]{24}$/i.test(value)) { return value } const nid = Number(value) @@ -38,5 +40,7 @@ export const IntIdOrMongoIdSchema = z.object({ export class IntIdOrMongoIdDto extends createZodDto(IntIdOrMongoIdSchema) {} -export type MongoIdInput = z.infer +export type MongoIdInput = z.infer & { + id: ObjectIdString +} export type IntIdOrMongoIdInput = z.infer diff --git a/apps/core/src/shared/id/id.schema.ts b/apps/core/src/shared/id/id.schema.ts new file mode 100644 index 00000000000..7fb76c09081 --- /dev/null +++ b/apps/core/src/shared/id/id.schema.ts @@ -0,0 +1,10 @@ +import { z } from 'zod' + +import type { ObjectIdString } from './id.type' + +export const OBJECT_ID_PATTERN = /^[\da-f]{24}$/i + +export const zObjectIdString = z + .string() + .regex(OBJECT_ID_PATTERN, 'Invalid MongoDB ObjectId') + .transform((value) => value as ObjectIdString) diff --git a/apps/core/src/shared/id/id.type.ts b/apps/core/src/shared/id/id.type.ts new file mode 100644 index 00000000000..56244d76580 --- /dev/null +++ b/apps/core/src/shared/id/id.type.ts @@ -0,0 +1,19 @@ +declare const objectIdBrand: unique symbol +declare const entityIdBrand: unique symbol + +export type ObjectIdString = string & { + readonly [objectIdBrand]: 'ObjectIdString' +} + +export type EntityId = ObjectIdString & { + readonly [entityIdBrand]: Name +} + +export type PostId = EntityId<'post'> +export type NoteId = EntityId<'note'> +export type PageId = EntityId<'page'> +export type RecentlyId = EntityId<'recently'> +export type CommentId = EntityId<'comment'> +export type ReaderId = EntityId<'reader'> +export type CategoryId = EntityId<'category'> +export type TopicId = EntityId<'topic'> diff --git a/apps/core/src/shared/id/id.util.ts b/apps/core/src/shared/id/id.util.ts new file mode 100644 index 00000000000..9644466f3f1 --- /dev/null +++ b/apps/core/src/shared/id/id.util.ts @@ -0,0 +1,44 @@ +import { Types } from 'mongoose' + +import { OBJECT_ID_PATTERN, zObjectIdString } from './id.schema' +import type { EntityId, ObjectIdString } from './id.type' + +type ObjectIdLike = Types.ObjectId | { toString: () => string } + +export function isObjectIdString(value: unknown): value is ObjectIdString { + return typeof value === 'string' && OBJECT_ID_PATTERN.test(value) +} + +export function parseObjectIdString(value: unknown): ObjectIdString { + return zObjectIdString.parse(value) +} + +export function unsafeObjectIdString(value: string): ObjectIdString { + return value as ObjectIdString +} + +export function brandEntityId( + id: ObjectIdString, +): EntityId { + return id as EntityId +} + +export function normalizeObjectIdString(value: string | ObjectIdLike) { + if (typeof value === 'string') { + return unsafeObjectIdString(value) + } + + return unsafeObjectIdString(value.toString()) +} + +export function toObjectId(id: ObjectIdString): Types.ObjectId { + if (!Types.ObjectId.isValid(id)) { + throw new TypeError(`Invalid ObjectId: ${id}`) + } + + return new Types.ObjectId(id) +} + +export function toObjectIdArray(ids: readonly ObjectIdString[]) { + return ids.map((id) => toObjectId(id)) +} diff --git a/apps/core/src/shared/id/index.ts b/apps/core/src/shared/id/index.ts new file mode 100644 index 00000000000..fee9e813cdf --- /dev/null +++ b/apps/core/src/shared/id/index.ts @@ -0,0 +1,3 @@ +export * from './id.schema' +export * from './id.type' +export * from './id.util' diff --git a/apps/core/src/shared/model/base.model.ts b/apps/core/src/shared/model/base.model.ts index 41b7e98f31b..494e484534b 100644 --- a/apps/core/src/shared/model/base.model.ts +++ b/apps/core/src/shared/model/base.model.ts @@ -2,7 +2,8 @@ import { index, modelOptions, plugin } from '@typegoose/typegoose' import mongooseLeanGetters from 'mongoose-lean-getters' import mongooseLeanVirtuals from 'mongoose-lean-virtuals' import Paginate from 'mongoose-paginate-v2' -import { mongooseLeanId } from './plugins/lean-id' + +import { mongooseLeanId, normalizeDocumentIds } from './plugins/lean-id' @plugin(mongooseLeanVirtuals) @plugin(Paginate) @@ -10,8 +11,16 @@ import { mongooseLeanId } from './plugins/lean-id' @plugin(mongooseLeanId) @modelOptions({ schemaOptions: { - toJSON: { virtuals: true, getters: true }, - toObject: { virtuals: true, getters: true }, + toJSON: { + virtuals: true, + getters: true, + transform: (doc, ret) => normalizeDocumentIds(ret, doc?.schema), + }, + toObject: { + virtuals: true, + getters: true, + transform: (doc, ret) => normalizeDocumentIds(ret, doc?.schema), + }, timestamps: { createdAt: 'created', updatedAt: false, diff --git a/apps/core/src/shared/model/plugins/lean-id.ts b/apps/core/src/shared/model/plugins/lean-id.ts index 9098e838671..f8005cb4252 100644 --- a/apps/core/src/shared/model/plugins/lean-id.ts +++ b/apps/core/src/shared/model/plugins/lean-id.ts @@ -1,3 +1,52 @@ +import { normalizeObjectIdString } from '~/shared/id' + +interface NormalizationSchema { + base?: { + models?: Record< + string, + { + schema?: NormalizationSchema + } + > + } + paths: Record + virtuals?: Record + path: (path: string) => NormalizationSchemaType | undefined +} + +interface NormalizationSchemaType { + instance?: string + schema?: NormalizationSchema + options?: Record + embeddedSchemaType?: { + instance?: string + options?: Record + } + caster?: { + instance?: string + options?: Record + } +} + +interface NormalizationVirtualType { + options?: Record & { + justOne?: boolean + } +} + +interface TraversalEntry { + key: string + isArray: boolean + schema?: NormalizationSchema +} + +interface TraversalPlan { + pathEntries: TraversalEntry[] + virtualEntries: TraversalEntry[] +} + +const traversalPlanCache = new WeakMap() + // Adapted from mongoose-lean-id export function mongooseLeanId(schema: any) { schema.post('find', attachId) @@ -7,32 +56,59 @@ export function mongooseLeanId(schema: any) { schema.post('findOneAndDelete', attachId) } -function replaceId(res: any) { - if (Array.isArray(res)) { - for (const item of res) { - if (!item || isObjectId(item)) continue - if (item._id) { - item.id = item._id.toString() - } - for (const key of Object.keys(item)) { - if (Array.isArray(item[key])) { - replaceId(item[key]) - } - } +export function normalizeDocumentIds( + value: any, + schema?: NormalizationSchema | null, +): any { + return normalizeDocumentIdsInternal(value, false, schema) +} + +function normalizeLeanDocumentIds( + value: any, + schema?: NormalizationSchema | null, +): any { + return normalizeDocumentIdsInternal(value, true, schema) +} + +function normalizeDocumentIdsInternal( + value: any, + preserveOriginalId: boolean, + schema?: NormalizationSchema | null, +): any { + if (value == null || isObjectId(value)) { + return value + } + + const resolvedSchema = schema ?? resolveSchemaFromValue(value) + + if (Array.isArray(value)) { + for (const item of value) { + normalizeDocumentIdsInternal(item, preserveOriginalId, resolvedSchema) } - return + return value } - if (isObjectId(res)) return + if (typeof value !== 'object') { + return value + } - if (res._id) { - res.id = res._id.toString() + if ('_id' in value && value._id != null) { + value.id = normalizeObjectIdString(value._id) } - for (const key of Object.keys(res)) { - if (Array.isArray(res[key])) { - replaceId(res[key]) + + if ('_id' in value) { + if (preserveOriginalId) { + hidePropertyFromEnumeration(value, '_id') + } else { + Reflect.deleteProperty(value, '_id') } } + + if (resolvedSchema) { + traverseKnownSchemaChildren(value, resolvedSchema, preserveOriginalId) + } + + return value } function attachId(this: any, res: any) { @@ -40,10 +116,262 @@ function attachId(this: any, res: any) { return } if (this._mongooseOptions.lean) { - replaceId(res) + normalizeLeanDocumentIds(res, this.model?.schema) + } +} + +function hidePropertyFromEnumeration(target: Record, key: string) { + const descriptor = Object.getOwnPropertyDescriptor(target, key) + if (descriptor?.enumerable === false) { + return + } + + if (descriptor && descriptor.configurable === false) { + return + } + + if (descriptor && ('get' in descriptor || 'set' in descriptor)) { + Object.defineProperty(target, key, { + configurable: descriptor.configurable, + enumerable: false, + get: descriptor.get, + set: descriptor.set, + }) + return + } + + Object.defineProperty(target, key, { + configurable: descriptor?.configurable ?? true, + enumerable: false, + writable: descriptor?.writable ?? true, + value: target[key], + }) +} + +function traverseKnownSchemaChildren( + value: Record, + schema: NormalizationSchema, + preserveOriginalId: boolean, +) { + const plan = getTraversalPlan(schema) + + for (const entry of plan.pathEntries) { + if (!(entry.key in value)) { + continue + } + normalizeTraversalEntry(value[entry.key], entry, preserveOriginalId) + } + + for (const entry of plan.virtualEntries) { + if (!(entry.key in value)) { + continue + } + normalizeTraversalEntry(value[entry.key], entry, preserveOriginalId) } } +function normalizeTraversalEntry( + value: any, + entry: TraversalEntry, + preserveOriginalId: boolean, +) { + if (value == null) { + return + } + + if (entry.isArray) { + if (!Array.isArray(value)) { + return + } + + for (const item of value) { + normalizeDocumentIdsInternal(item, preserveOriginalId, entry.schema) + } + return + } + + normalizeDocumentIdsInternal(value, preserveOriginalId, entry.schema) +} + +function getTraversalPlan(schema: NormalizationSchema): TraversalPlan { + const cachedPlan = traversalPlanCache.get(schema) + if (cachedPlan) { + return cachedPlan + } + + const pathEntries = Object.keys(schema.paths) + .filter(isTopLevelSchemaPath) + .filter((key) => key !== '_id' && key !== '__v') + .map((key) => buildPathTraversalEntry(schema, key)) + .filter((entry): entry is TraversalEntry => entry != null) + + const virtualEntries = Object.entries(schema.virtuals ?? {}) + .filter(([key]) => key !== 'id' && !key.includes('.')) + .map(([key, virtualType]) => + buildVirtualTraversalEntry(schema, key, virtualType), + ) + .filter((entry): entry is TraversalEntry => entry != null) + + const plan = { pathEntries, virtualEntries } + traversalPlanCache.set(schema, plan) + return plan +} + +function buildPathTraversalEntry( + schema: NormalizationSchema, + key: string, +): TraversalEntry | null { + const schemaType = schema.path(key) + if (!schemaType) { + return null + } + + if (schemaType.schema) { + return { + key, + isArray: schemaType.instance === 'Array', + schema: schemaType.schema, + } + } + + const ref = getSchemaTypeRef(schemaType) + if (!ref) { + return null + } + + return { + key, + isArray: schemaType.instance === 'Array', + schema: resolveReferencedSchema(schema, ref), + } +} + +function buildVirtualTraversalEntry( + schema: NormalizationSchema, + key: string, + virtualType?: NormalizationVirtualType, +): TraversalEntry | null { + const ref = virtualType?.options?.ref + if (!ref) { + return null + } + + return { + key, + isArray: virtualType.options?.justOne !== true, + schema: resolveReferencedSchema(schema, ref), + } +} + +function getSchemaTypeRef(schemaType: NormalizationSchemaType) { + return ( + schemaType.options?.ref ?? + schemaType.embeddedSchemaType?.options?.ref ?? + schemaType.caster?.options?.ref + ) +} + +function resolveReferencedSchema( + schema: NormalizationSchema, + ref: unknown, +): NormalizationSchema | undefined { + const refName = resolveReferenceName(ref) + if (!refName) { + return undefined + } + + const modelSchema = schema.base?.models?.[refName]?.schema + return isSchemaLike(modelSchema) ? modelSchema : undefined +} + +function resolveReferenceName(ref: unknown): string | undefined { + if (typeof ref === 'string' && ref) { + return ref + } + + if (typeof ref === 'function') { + try { + const resolved = ref() + if (typeof resolved === 'string' && resolved) { + return resolved + } + if ( + resolved && + typeof resolved === 'object' && + 'modelName' in resolved && + typeof resolved.modelName === 'string' + ) { + return resolved.modelName + } + if ( + resolved && + typeof resolved === 'object' && + 'name' in resolved && + typeof resolved.name === 'string' + ) { + return resolved.name + } + } catch { + // Ignore eager ref resolution failures and fall back to function metadata. + } + + if (ref.name) { + return ref.name + } + } + + if ( + ref && + typeof ref === 'object' && + 'modelName' in ref && + typeof ref.modelName === 'string' + ) { + return ref.modelName + } + + if ( + ref && + typeof ref === 'object' && + 'name' in ref && + typeof ref.name === 'string' + ) { + return ref.name + } + + return undefined +} + +function resolveSchemaFromValue( + value: unknown, +): NormalizationSchema | undefined { + if (!value || typeof value !== 'object') { + return undefined + } + + return ( + asSchemaLike((value as any).schema) ?? + asSchemaLike((value as any).$__schema) ?? + asSchemaLike((value as any).constructor?.schema) + ) +} + +function asSchemaLike(value: unknown): NormalizationSchema | undefined { + return isSchemaLike(value) ? value : undefined +} + +function isSchemaLike(value: unknown): value is NormalizationSchema { + return ( + !!value && + typeof value === 'object' && + 'paths' in value && + typeof (value as any).path === 'function' + ) +} + +function isTopLevelSchemaPath(path: string) { + return !path.includes('.') +} + function isObjectId(v: any) { if (v == null) { return false diff --git a/apps/core/test/src/modules/aggregate/aggregate.service.spec.ts b/apps/core/test/src/modules/aggregate/aggregate.service.spec.ts new file mode 100644 index 00000000000..4b0c30d1e60 --- /dev/null +++ b/apps/core/test/src/modules/aggregate/aggregate.service.spec.ts @@ -0,0 +1,59 @@ +import { describe, expect, it, vi } from 'vitest' + +import { AggregateService } from '~/modules/aggregate/aggregate.service' + +describe('AggregateService canonical id handling', () => { + it('returns top articles with canonical ids from lean posts', async () => { + const lean = vi.fn().mockResolvedValue([ + { + id: 'post-1', + title: 'Canonical ID', + slug: 'canonical-id', + count: { read: 42, like: 7 }, + categoryId: { + name: 'Frontend', + slug: 'frontend', + }, + }, + ]) + const populate = vi.fn().mockReturnValue({ lean }) + const select = vi.fn().mockReturnValue({ populate }) + const limit = vi.fn().mockReturnValue({ select }) + const sort = vi.fn().mockReturnValue({ limit }) + const find = vi.fn().mockReturnValue({ sort }) + + const service = new AggregateService( + { model: { find } } as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + ) + + const result = await service.getTopArticles() + + expect(find).toHaveBeenCalledWith({ isPublished: true }) + expect(result).toEqual([ + { + id: 'post-1', + title: 'Canonical ID', + slug: 'canonical-id', + reads: 42, + likes: 7, + category: { + name: 'Frontend', + slug: 'frontend', + }, + }, + ]) + expect(result[0]).not.toHaveProperty('_id') + }) +}) diff --git a/apps/core/test/src/modules/ai/ai-slug-backfill.service.spec.ts b/apps/core/test/src/modules/ai/ai-slug-backfill.service.spec.ts index a8e8bf01649..37658c96dcf 100644 --- a/apps/core/test/src/modules/ai/ai-slug-backfill.service.spec.ts +++ b/apps/core/test/src/modules/ai/ai-slug-backfill.service.spec.ts @@ -60,7 +60,7 @@ describe('AiSlugBackfillService', () => { sort: vi.fn().mockReturnThis(), lean: vi .fn() - .mockResolvedValue([{ _id: 'note-1', title: 'First', nid: 1 }]), + .mockResolvedValue([{ id: 'note-1', title: 'First', nid: 1 }]), }) aiWriterService.generateSlugByTitleViaOpenAI.mockResolvedValue({ slug: 'first', @@ -82,4 +82,15 @@ describe('AiSlugBackfillService', () => { expect(logs[0]?.message).toContain('note-1, note-2') }) + + it('should update notes by canonical id when lean results omit _id', async () => { + const { context } = createContext() + + await registeredHandler!.execute({ noteIds: ['note-1'] }, context) + + expect(noteModel.updateOne).toHaveBeenCalledWith( + expect.objectContaining({ _id: 'note-1' }), + { $set: { slug: 'first' } }, + ) + }) }) diff --git a/apps/core/test/src/modules/ai/ai-summary.service.spec.ts b/apps/core/test/src/modules/ai/ai-summary.service.spec.ts index 8ce0cda108e..e834b56c9ff 100644 --- a/apps/core/test/src/modules/ai/ai-summary.service.spec.ts +++ b/apps/core/test/src/modules/ai/ai-summary.service.spec.ts @@ -257,4 +257,92 @@ describe('AiSummaryService', () => { expect(result).toBeNull() }) }) + + describe('getAllSummariesGrouped', () => { + it('uses canonical ids for search matches from lean queries', async () => { + const postModel = { + find: vi.fn().mockReturnValue({ + select: vi.fn().mockReturnValue({ + lean: vi.fn().mockResolvedValue([{ id: 'post-1' }]), + }), + }), + } + const noteModel = { + find: vi.fn().mockReturnValue({ + select: vi.fn().mockReturnValue({ + lean: vi.fn().mockResolvedValue([]), + }), + }), + } + + mockDatabaseService.getModelByRefType + .mockReturnValueOnce(postModel) + .mockReturnValueOnce(noteModel) + mockSummaryModel.aggregate.mockResolvedValue([ + { + metadata: [{ total: 1 }], + data: [{ _id: 'post-1', latestCreated: new Date(), summaryCount: 1 }], + }, + ]) + mockSummaryModel.find.mockReturnValue({ + sort: vi.fn().mockReturnValue({ + lean: vi.fn().mockResolvedValue([ + { + id: 'summary-1', + refId: 'post-1', + summary: 'Summary', + lang: 'zh', + }, + ]), + }), + }) + mockDatabaseService.findGlobalByIds.mockResolvedValue({ + posts: [{ id: 'post-1', title: 'Canonical Post' }], + notes: [], + }) + + const result = await service.getAllSummariesGrouped({ + page: 1, + size: 10, + search: 'canonical', + }) + + expect(postModel.find).toHaveBeenCalledWith({ + title: { $regex: 'canonical', $options: 'i' }, + }) + expect(mockSummaryModel.aggregate).toHaveBeenCalledWith([ + { $match: { refId: { $in: ['post-1'] } } }, + { + $group: { + _id: '$refId', + latestCreated: { $max: '$created' }, + summaryCount: { $sum: 1 }, + }, + }, + { $sort: { latestCreated: -1 } }, + { + $facet: { + metadata: [{ $count: 'total' }], + data: [{ $skip: 0 }, { $limit: 10 }], + }, + }, + ]) + expect(result).toMatchObject({ + data: [ + { + article: { + id: 'post-1', + title: 'Canonical Post', + type: CollectionRefTypes.Post, + }, + summaries: [{ id: 'summary-1', refId: 'post-1' }], + }, + ], + pagination: { + total: 1, + currentPage: 1, + }, + }) + }) + }) }) diff --git a/apps/core/test/src/modules/ai/translation-entry.interceptor.spec.ts b/apps/core/test/src/modules/ai/translation-entry.interceptor.spec.ts index 58f40ca1a25..d742efec093 100644 --- a/apps/core/test/src/modules/ai/translation-entry.interceptor.spec.ts +++ b/apps/core/test/src/modules/ai/translation-entry.interceptor.spec.ts @@ -24,7 +24,7 @@ describe('TranslationEntryInterceptor path utilities', () => { ) const data = { - categories: [{ _id: 'id-1', name: '前端' }], + categories: [{ id: 'id-1', name: '前端' }], notes: [{ mood: '开心' }], } @@ -34,7 +34,7 @@ describe('TranslationEntryInterceptor path utilities', () => { { keyPath: 'category.name', path: 'categories[].name', - idField: '_id', + idField: 'id', }, { keyPath: 'note.mood', path: 'notes[].mood' }, ], @@ -73,7 +73,7 @@ describe('TranslationEntryInterceptor path utilities', () => { translationEntryService as any, ) - const data = [{ _id: 'id-1', name: '前端' }] + const data = [{ id: 'id-1', name: '前端' }] const result = await realInterceptor['applyTranslations']( data, @@ -81,7 +81,7 @@ describe('TranslationEntryInterceptor path utilities', () => { { keyPath: 'topic.name', path: '[].name', - idField: '_id', + idField: 'id', }, ], 'en', @@ -115,13 +115,13 @@ describe('TranslationEntryInterceptor path utilities', () => { ) class CategoryDoc { - _id = 'id-1' + id = 'id-1' name = '前端' self = this toJSON() { return { - _id: this._id, + id: this.id, name: this.name, } } @@ -137,14 +137,14 @@ describe('TranslationEntryInterceptor path utilities', () => { { keyPath: 'category.name', path: 'categories[].name', - idField: '_id', + idField: 'id', }, ], 'en', ) expect(result.categories[0].name).toBe('Frontend') - expect(result.categories[0]._id).toBe('id-1') + expect(result.categories[0].id).toBe('id-1') }) it('should preserve non-structured-cloneable payloads when translating', async () => { @@ -241,12 +241,12 @@ describe('TranslationEntryInterceptor path utilities', () => { it('should recursively normalize document-like values nested under plain objects', () => { class TopicDoc { - toJSON = vi.fn(() => ({ _id: 'topic-1', name: '近况' })) + toJSON = vi.fn(() => ({ id: 'topic-1', name: '近况' })) } class NoteDoc { toJSON = vi.fn(() => ({ - _id: 'note-1', + id: 'note-1', mood: '开心', topic: new TopicDoc(), })) @@ -257,9 +257,9 @@ describe('TranslationEntryInterceptor path utilities', () => { expect(result).not.toBe(data) expect(result.docs[0]).toEqual({ - _id: 'note-1', + id: 'note-1', mood: '开心', - topic: { _id: 'topic-1', name: '近况' }, + topic: { id: 'topic-1', name: '近况' }, }) }) }) @@ -288,12 +288,12 @@ describe('TranslationEntryInterceptor path utilities', () => { it('should collect ids from array items', () => { const data = { categories: [ - { _id: 'id-1', name: '前端' }, - { _id: 'id-2', name: '后端' }, + { id: 'id-1', name: '前端' }, + { id: 'id-2', name: '后端' }, ], } const ids = new Set() - collect(data, 'categories[].name', '_id', ids) + collect(data, 'categories[].name', 'id', ids) expect([...ids].sort()).toEqual(['id-1', 'id-2']) }) }) @@ -319,12 +319,12 @@ describe('TranslationEntryInterceptor path utilities', () => { it('should replace by entity id', () => { const data = { categories: [ - { _id: 'id-1', name: '前端' }, - { _id: 'id-2', name: '后端' }, + { id: 'id-1', name: '前端' }, + { id: 'id-2', name: '后端' }, ], } const map = new Map([['id-1', 'Frontend']]) - replace(data, 'categories[].name', '_id', map) + replace(data, 'categories[].name', 'id', map) expect(data.categories[0].name).toBe('Frontend') expect(data.categories[1].name).toBe('后端') }) diff --git a/apps/core/test/src/modules/ai/translation-entry.service.spec.ts b/apps/core/test/src/modules/ai/translation-entry.service.spec.ts index 20998cbe97a..05ef80f6f06 100644 --- a/apps/core/test/src/modules/ai/translation-entry.service.spec.ts +++ b/apps/core/test/src/modules/ai/translation-entry.service.spec.ts @@ -272,7 +272,7 @@ describe('TranslationEntryService', () => { it('should collect from categories, topics, notes', async () => { mockCategoryModel.find.mockReturnValue({ select: vi.fn().mockReturnValue({ - lean: vi.fn().mockResolvedValue([{ _id: 'cat-1', name: '前端' }]), + lean: vi.fn().mockResolvedValue([{ id: 'cat-1', name: '前端' }]), }), }) @@ -281,7 +281,7 @@ describe('TranslationEntryService', () => { lean: vi .fn() .mockResolvedValue([ - { _id: 'topic-1', name: '日记', introduce: '每日记录' }, + { id: 'topic-1', name: '日记', introduce: '每日记录' }, ]), }), }) @@ -316,7 +316,7 @@ describe('TranslationEntryService', () => { it('should skip falsy values', async () => { mockCategoryModel.find.mockReturnValue({ select: vi.fn().mockReturnValue({ - lean: vi.fn().mockResolvedValue([{ _id: 'cat-1', name: '' }]), + lean: vi.fn().mockResolvedValue([{ id: 'cat-1', name: '' }]), }), }) mockTopicModel.find.mockReturnValue({ diff --git a/apps/core/test/src/modules/category/category.controller.spec.ts b/apps/core/test/src/modules/category/category.controller.spec.ts new file mode 100644 index 00000000000..9f15f932774 --- /dev/null +++ b/apps/core/test/src/modules/category/category.controller.spec.ts @@ -0,0 +1,72 @@ +import { describe, expect, it, vi } from 'vitest' + +import { CategoryController } from '~/modules/category/category.controller' + +describe('CategoryController canonical id translation', () => { + it('translates tag listings using canonical ids only', async () => { + const created = new Date('2026-03-14T00:00:00.000Z') + const modified = new Date('2026-03-15T00:00:00.000Z') + const findArticleWithTag = vi.fn().mockResolvedValue([ + { + id: 'post-1', + title: '原始标题', + slug: 'canonical-id', + category: { + id: 'cat-1', + name: '前端', + }, + created, + modified, + }, + ]) + const translateList = vi.fn( + async ({ items, targetLang, getInput, applyResult }) => { + expect(targetLang).toBe('en') + expect(getInput(items[0])).toMatchObject({ + id: 'post-1', + title: '原始标题', + created, + modified, + }) + + return items.map((item) => + applyResult(item, { + isTranslated: true, + title: 'Translated title', + translationMeta: { + sourceLang: 'zh', + targetLang: 'en', + translatedAt: new Date('2026-03-16T00:00:00.000Z'), + }, + }), + ) + }, + ) + + const controller = new CategoryController( + { findArticleWithTag } as any, + {} as any, + { translateList } as any, + ) + + const result = await controller.getCategoryById( + { query: 'frontend' } as any, + { tag: true } as any, + 'en', + ) + + expect(findArticleWithTag).toHaveBeenCalledWith('frontend') + expect(translateList).toHaveBeenCalledOnce() + expect(result).toMatchObject({ + tag: 'frontend', + data: [ + { + id: 'post-1', + title: 'Translated title', + isTranslated: true, + }, + ], + }) + expect(result.data[0]).not.toHaveProperty('_id') + }) +}) diff --git a/apps/core/test/src/modules/category/category.service.spec.ts b/apps/core/test/src/modules/category/category.service.spec.ts new file mode 100644 index 00000000000..c6f73507b57 --- /dev/null +++ b/apps/core/test/src/modules/category/category.service.spec.ts @@ -0,0 +1,92 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { CategoryService } from '~/modules/category/category.service' + +describe('CategoryService canonical id handling', () => { + let service: CategoryService + let categoryModel: any + let postService: any + + beforeEach(() => { + categoryModel = { + countDocuments: vi.fn().mockResolvedValue(1), + create: vi.fn(), + find: vi.fn(), + } + postService = { + model: { + countDocuments: vi.fn(), + find: vi.fn(), + }, + } + + service = new CategoryService( + categoryModel, + { emit: vi.fn() } as any, + {} as any, + { get: vi.fn().mockReturnValue(postService) } as any, + ) + ;(service as any).postService = postService + }) + + it('counts category posts by canonical id on lean results', async () => { + categoryModel.find.mockReturnValue({ + lean: vi.fn().mockResolvedValue([{ id: 'cat-1', name: 'Frontend' }]), + }) + postService.model.countDocuments.mockResolvedValue(3) + + const result = await service.findAllCategory() + + expect(postService.model.countDocuments).toHaveBeenCalledWith({ + categoryId: 'cat-1', + }) + expect(result).toEqual([ + { + id: 'cat-1', + name: 'Frontend', + count: 3, + }, + ]) + }) + + it('returns tag article payloads with id instead of _id', async () => { + const created = new Date('2026-03-14T00:00:00.000Z') + const modified = new Date('2026-03-15T00:00:00.000Z') + postService.model.find.mockReturnValue({ + populate: vi.fn().mockResolvedValue([ + { + id: 'post-1', + title: 'Canonical ID', + slug: 'canonical-id', + category: { + id: 'cat-1', + name: 'Frontend', + count: 9, + __v: 0, + created, + modified, + }, + created, + modified, + }, + ]), + }) + + const result = await service.findArticleWithTag('frontend') + + expect(result).toEqual([ + { + id: 'post-1', + title: 'Canonical ID', + slug: 'canonical-id', + category: { + id: 'cat-1', + name: 'Frontend', + }, + created, + modified, + }, + ]) + expect(result[0]).not.toHaveProperty('_id') + }) +}) diff --git a/apps/core/test/src/modules/comment/comment-anchor.spec.ts b/apps/core/test/src/modules/comment/comment-anchor.spec.ts index 0d198025202..67b84be14ec 100644 --- a/apps/core/test/src/modules/comment/comment-anchor.spec.ts +++ b/apps/core/test/src/modules/comment/comment-anchor.spec.ts @@ -99,8 +99,10 @@ describe('CommentService — lang-aware anchor resolution', () => { let mockAiTranslationModel: any let mockLexicalService: any let mockDatabaseService: any + let mockRefModel: any const refId = new Types.ObjectId() + const refIdString = refId.toString() const originalContent = makeLexicalContent([ { id: 'block-1', text: 'Original paragraph one' }, { id: 'block-2', text: 'Original paragraph two' }, @@ -139,17 +141,17 @@ describe('CommentService — lang-aware anchor resolution', () => { mockLexicalService = new LexicalService() - const mockRefModel = { + mockRefModel = { findById: vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue({ - _id: refId, + id: refIdString, content: originalContent, contentFormat: ContentFormat.Lexical, commentsIndex: 0, }), select: vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue({ - _id: refId, + id: refIdString, content: originalContent, contentFormat: ContentFormat.Lexical, }), @@ -163,7 +165,7 @@ describe('CommentService — lang-aware anchor resolution', () => { findGlobalById: vi.fn().mockResolvedValue({ type: 'Post', document: { - _id: refId, + id: refIdString, content: originalContent, contentFormat: ContentFormat.Lexical, commentsIndex: 0, @@ -223,6 +225,10 @@ describe('CommentService — lang-aware anchor resolution', () => { await service.createComment(refId.toString(), doc) expect(mockAiTranslationModel.findOne).not.toHaveBeenCalled() + expect(mockRefModel.updateOne).toHaveBeenCalledWith( + { _id: refIdString }, + { $inc: { commentsIndex: 1 } }, + ) expect(doc.anchor.blockId).toBe('block-1') expect(doc.anchor.lang).toBeUndefined() }) @@ -249,7 +255,7 @@ describe('CommentService — lang-aware anchor resolution', () => { await service.createComment(refId.toString(), doc) expect(mockAiTranslationModel.findOne).toHaveBeenCalledWith( - expect.objectContaining({ lang: 'en' }), + expect.objectContaining({ refId: refIdString, lang: 'en' }), ) expect(doc.anchor.lang).toBe('en') expect(doc.anchor.snapshotText).toBe('Translated paragraph one') diff --git a/apps/core/test/src/modules/comment/comment-lifecycle.spec.ts b/apps/core/test/src/modules/comment/comment-lifecycle.spec.ts index 15a5f85dcb2..1738a6db13b 100644 --- a/apps/core/test/src/modules/comment/comment-lifecycle.spec.ts +++ b/apps/core/test/src/modules/comment/comment-lifecycle.spec.ts @@ -2,6 +2,7 @@ import { Test } from '@nestjs/testing' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { BusinessEvents, EventScope } from '~/constants/business-event.constant' +import { CollectionRefTypes } from '~/constants/db.constant' import { CommentReplyMailType } from '~/modules/comment/comment.enum' import { CommentLifecycleService } from '~/modules/comment/comment.lifecycle.service' import { CommentSpamFilterService } from '~/modules/comment/comment.spam-filter' @@ -330,4 +331,42 @@ describe('CommentLifecycleService email routing', () => { }), ) }) + + it('builds recently reply links from canonical id', async () => { + mockCommentModel.findOne.mockReturnValue({ + lean: vi.fn().mockResolvedValue(null), + }) + mockRefModel.findById.mockResolvedValueOnce({ + id: 'recent-1', + title: 'Recent Title', + text: 'Recent Content', + created: new Date('2026-01-01T00:00:00.000Z'), + modified: null, + }) + + const sendCommentNotificationMailSpy = vi + .spyOn(service as any, 'sendCommentNotificationMail') + .mockResolvedValue(undefined) + + await service.sendEmail( + { + id: 'comment-1', + ref: 'recent-1', + refType: CollectionRefTypes.Recently, + parentCommentId: null, + text: 'recent reply', + created: new Date('2026-01-10T00:00:00.000Z'), + isWhispers: false, + } as any, + CommentReplyMailType.Owner, + ) + + expect(sendCommentNotificationMailSpy).toHaveBeenCalledWith( + expect.objectContaining({ + source: expect.objectContaining({ + link: 'https://mx.example.com/thinking/recent-1#comments-comment-1', + }), + }), + ) + }) }) diff --git a/apps/core/test/src/modules/draft/draft.service.spec.ts b/apps/core/test/src/modules/draft/draft.service.spec.ts index 23bdd1682ce..0a5dfdb8c23 100644 --- a/apps/core/test/src/modules/draft/draft.service.spec.ts +++ b/apps/core/test/src/modules/draft/draft.service.spec.ts @@ -1,4 +1,5 @@ import { Test } from '@nestjs/testing' +import { Types } from 'mongoose' import { afterEach, beforeEach, @@ -20,6 +21,7 @@ import { getModelToken } from '~/transformers/model.transformer' describe('DraftService with FileReference integration', () => { let draftService: DraftService + let mockDraftModel: ReturnType let mockFileReferenceService: { updateReferencesForDocument: Mock removeReferencesForDocument: Mock @@ -85,12 +87,53 @@ describe('DraftService with FileReference integration', () => { return Promise.resolve({ deletedCount: 0 }) }), - find: vi.fn().mockImplementation(() => ({ - sort: vi.fn().mockReturnThis(), - lean: vi.fn().mockImplementation(() => ({ - getters: true, - })), - })), + find: vi.fn().mockImplementation((query: any = {}) => { + const drafts = mockDrafts.filter((draft) => { + if (query.refType && draft.refType !== query.refType) { + return false + } + if ( + query.refId && + draft.refId?.toString() !== query.refId.toString() + ) { + return false + } + if (query.refId?.$exists === false && draft.refId !== undefined) { + return false + } + return true + }) + + let selectedFields: string[] | null = null + + const chain = { + select: vi.fn().mockImplementation((fields: string) => { + selectedFields = fields.split(/\s+/).filter(Boolean) + return chain + }), + sort: vi.fn().mockReturnValue(undefined), + lean: vi.fn().mockImplementation(() => { + return drafts.map((draft) => { + if (!selectedFields) { + return { ...draft } + } + + const projected = Object.fromEntries( + selectedFields + .filter((field) => field !== '_id') + .map((field) => [field, draft[field]]), + ) + return { + ...projected, + id: draft.id, + } + }) + }), + } + + chain.sort.mockReturnValue(chain) + return chain + }), findByIdAndUpdate: vi .fn() @@ -101,6 +144,23 @@ describe('DraftService with FileReference integration', () => { } return Promise.resolve(draft) }), + + deleteMany: vi.fn().mockImplementation((query: any) => { + const before = mockDrafts.length + mockDrafts = mockDrafts.filter((draft) => { + if (query.refType && draft.refType !== query.refType) { + return true + } + if ( + query.refId && + draft.refId?.toString() !== query.refId.toString() + ) { + return true + } + return false + }) + return Promise.resolve({ deletedCount: before - mockDrafts.length }) + }), } } @@ -110,7 +170,7 @@ describe('DraftService with FileReference integration', () => { removeReferencesForDocument: vi.fn().mockResolvedValue(undefined), } - const mockDraftModel = createMockDraftModel() + mockDraftModel = createMockDraftModel() const module = await Test.createTestingModule({ providers: [ @@ -251,6 +311,27 @@ describe('DraftService with FileReference integration', () => { BizException, ) }) + + it('should remove file references by canonical id when deleting by ref', async () => { + const refId = new Types.ObjectId().toHexString() + mockDrafts.push({ + _id: 'draft-by-ref', + id: 'draft-by-ref', + refType: DraftRefType.Post, + refId: new Types.ObjectId(refId), + title: 'By Ref', + text: 'content', + version: 1, + history: [], + }) + + await draftService.deleteByRef(DraftRefType.Post, refId) + + expect(mockDraftModel.deleteMany).toHaveBeenCalled() + expect( + mockFileReferenceService.removeReferencesForDocument, + ).toHaveBeenCalledWith('draft-by-ref', FileReferenceType.Draft) + }) }) describe('Draft lifecycle with file references', () => { diff --git a/apps/core/test/src/modules/file/file.controller.spec.ts b/apps/core/test/src/modules/file/file.controller.spec.ts index b20cf1b44fa..69187204745 100644 --- a/apps/core/test/src/modules/file/file.controller.spec.ts +++ b/apps/core/test/src/modules/file/file.controller.spec.ts @@ -100,4 +100,47 @@ describe('FileController', () => { expect(writeFile).not.toHaveBeenCalled() expect(createPendingReference).not.toHaveBeenCalled() }) + + it('lists orphan files with canonical ids', async () => { + const lean = vi.fn().mockResolvedValue([ + { + id: 'file-1', + fileName: 'origin.png', + fileUrl: 'http://example.com/origin.png', + created: new Date('2026-03-14T00:00:00.000Z'), + }, + ]) + const limit = vi.fn().mockReturnValue({ lean }) + const skip = vi.fn().mockReturnValue({ limit }) + const sort = vi.fn().mockReturnValue({ skip }) + const find = vi.fn().mockReturnValue({ sort }) + const countDocuments = vi.fn().mockResolvedValue(1) + + const controller = new FileController( + {} as any, + {} as any, + { + model: { find, countDocuments }, + } as any, + {} as any, + ) + + const result = await controller.getOrphanFiles({ page: 1, size: 20 } as any) + + expect(find).toHaveBeenCalledWith({ status: 'pending' }) + expect(result).toMatchObject({ + data: [ + { + id: 'file-1', + fileName: 'origin.png', + fileUrl: 'http://example.com/origin.png', + }, + ], + pagination: { + currentPage: 1, + total: 1, + }, + }) + expect(result.data[0]).not.toHaveProperty('_id') + }) }) diff --git a/apps/core/test/src/modules/note/note.service.spec.ts b/apps/core/test/src/modules/note/note.service.spec.ts index d140c05f405..105672b99f6 100644 --- a/apps/core/test/src/modules/note/note.service.spec.ts +++ b/apps/core/test/src/modules/note/note.service.spec.ts @@ -752,14 +752,13 @@ describe('NoteService', () => { describe('getIdByNid', () => { beforeEach(() => { mockNotes.push({ - _id: 'note-1', id: 'note-1', nid: 42, title: 'Note 42', }) }) - it('should return _id for valid nid', async () => { + it('should return canonical id for valid nid', async () => { const result = await noteService.getIdByNid(42) expect(result).toBe('note-1') diff --git a/apps/core/test/src/modules/note/note.translation-entry.e2e-spec.ts b/apps/core/test/src/modules/note/note.translation-entry.e2e-spec.ts index 02b728c1423..24e94ed3878 100644 --- a/apps/core/test/src/modules/note/note.translation-entry.e2e-spec.ts +++ b/apps/core/test/src/modules/note/note.translation-entry.e2e-spec.ts @@ -108,11 +108,8 @@ describe('NoteController translation entry (e2e)', () => { getTranslationsBatch.mockResolvedValueOnce({ entityMaps: new Map([ - ['topic.name', new Map([[topic._id.toString(), 'Recent']])], - [ - 'topic.introduce', - new Map([[topic._id.toString(), 'Recent updates']]), - ], + ['topic.name', new Map([[topic.id, 'Recent']])], + ['topic.introduce', new Map([[topic.id, 'Recent updates']])], ]), dictMaps: new Map([['note.mood', new Map([['开心', 'Happy']])]]), }) @@ -125,8 +122,8 @@ describe('NoteController translation entry (e2e)', () => { expect(res.statusCode).toBe(200) expect(getTranslationsBatch).toHaveBeenCalledWith('en', { entityLookups: [ - { keyPath: 'topic.name', lookupKeys: [topic._id.toString()] }, - { keyPath: 'topic.introduce', lookupKeys: [topic._id.toString()] }, + { keyPath: 'topic.name', lookupKeys: [topic.id] }, + { keyPath: 'topic.introduce', lookupKeys: [topic.id] }, ], dictLookups: [{ keyPath: 'note.mood', sourceTexts: ['开心'] }], }) diff --git a/apps/core/test/src/modules/owner/owner.service.spec.ts b/apps/core/test/src/modules/owner/owner.service.spec.ts new file mode 100644 index 00000000000..bb679bf0bf4 --- /dev/null +++ b/apps/core/test/src/modules/owner/owner.service.spec.ts @@ -0,0 +1,84 @@ +import { Types } from 'mongoose' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { + OWNER_PROFILE_COLLECTION_NAME, + READER_COLLECTION_NAME, +} from '~/constants/db.constant' +import { OwnerService } from '~/modules/owner/owner.service' + +describe('OwnerService canonical id output', () => { + let service: OwnerService + let ownerId: Types.ObjectId + + const ownerProfileModel = { + updateOne: vi.fn(), + } + + const eventManager = { + emit: vi.fn(), + } + + const readersCollection = { + find: vi.fn(), + } + + const ownerProfileCollection = { + findOne: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + ownerId = new Types.ObjectId() + + readersCollection.find.mockReturnValue({ + sort: vi.fn().mockReturnValue({ + limit: vi.fn().mockReturnValue({ + next: vi.fn().mockResolvedValue({ + _id: ownerId, + username: 'owner', + name: 'Owner Name', + email: 'owner@example.com', + image: 'https://example.com/avatar.png', + createdAt: new Date('2026-01-01T00:00:00.000Z'), + }), + }), + }), + }) + + ownerProfileCollection.findOne.mockResolvedValue({ + mail: 'owner@example.com', + url: 'https://example.com', + created: new Date('2026-01-02T00:00:00.000Z'), + }) + + service = new OwnerService( + { + db: { + collection: vi.fn((name: string) => { + switch (name) { + case READER_COLLECTION_NAME: { + return readersCollection + } + case OWNER_PROFILE_COLLECTION_NAME: { + return ownerProfileCollection + } + default: { + throw new Error(`Unexpected collection: ${name}`) + } + } + }), + }, + } as any, + ownerProfileModel as any, + eventManager as any, + ) + }) + + it('returns only canonical id in owner info', async () => { + const owner = await service.getOwnerInfo() + + expect(owner.id).toBe(ownerId.toHexString()) + expect(owner).not.toHaveProperty('_id') + }) +}) diff --git a/apps/core/test/src/modules/recently/recently.controller.e2e-spec.ts b/apps/core/test/src/modules/recently/recently.controller.e2e-spec.ts index 6feb0f551e2..4ee146c307d 100644 --- a/apps/core/test/src/modules/recently/recently.controller.e2e-spec.ts +++ b/apps/core/test/src/modules/recently/recently.controller.e2e-spec.ts @@ -214,4 +214,22 @@ describe('test /recently', async () => { }) expect(res.statusCode).toBe(422) }) + + test('GET /recently/latest returns canonical id without _id', async () => { + await model.create({ + content: 'Latest recently', + type: RecentlyTypeEnum.Text, + }) + + const res = await app.inject({ + method: 'GET', + url: `${apiRoutePrefix}/recently/latest`, + }) + + expect(res.statusCode).toBe(200) + const data = res.json() + expect(data.id).toBeDefined() + expect(data.comments).toBe(0) + expect(data._id).toBeUndefined() + }) }) diff --git a/apps/core/test/src/modules/recently/recently.model.spec.ts b/apps/core/test/src/modules/recently/recently.model.spec.ts new file mode 100644 index 00000000000..bd6f618f275 --- /dev/null +++ b/apps/core/test/src/modules/recently/recently.model.spec.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' + +import { RecentlyModel } from '~/modules/recently/recently.model' + +describe('RecentlyModel refId', () => { + it('prefers canonical id from normalized populated refs', () => { + const model = new RecentlyModel() + ;(model as any).ref = { id: 'post-1', title: 'Post' } + + expect(model.refId).toBe('post-1') + }) + + it('falls back to legacy _id for unnormalized populated refs', () => { + const model = new RecentlyModel() + ;(model as any).ref = { _id: 'post-2', title: 'Post' } + + expect(model.refId).toBe('post-2') + }) + + it('returns raw ref when relation is not populated', () => { + const model = new RecentlyModel() + ;(model as any).ref = 'post-3' + + expect(model.refId).toBe('post-3') + }) +}) diff --git a/apps/core/test/src/modules/search/search-document.util.spec.ts b/apps/core/test/src/modules/search/search-document.util.spec.ts index b215e9befce..0ca98fe83ef 100644 --- a/apps/core/test/src/modules/search/search-document.util.spec.ts +++ b/apps/core/test/src/modules/search/search-document.util.spec.ts @@ -5,7 +5,7 @@ import { buildSearchDocument } from '~/modules/search/search-document.util' describe('search-document.util', () => { it('should build normalized search document for cjk content', () => { const document = buildSearchDocument('note', { - _id: { toString: () => 'note-1' }, + id: 'note-1', title: '中文搜索', text: '这里记录中文搜索功能。', nid: 42, @@ -22,7 +22,7 @@ describe('search-document.util', () => { it('should extract searchable text from lexical content', () => { const document = buildSearchDocument('post', { - _id: { toString: () => 'post-1' }, + id: 'post-1', title: 'Lexical', text: '', contentFormat: 'lexical', diff --git a/apps/core/test/src/modules/search/search.service.spec.ts b/apps/core/test/src/modules/search/search.service.spec.ts index 6a36ecb722b..fb18426c395 100644 --- a/apps/core/test/src/modules/search/search.service.spec.ts +++ b/apps/core/test/src/modules/search/search.service.spec.ts @@ -144,7 +144,7 @@ describe('SearchService', () => { it('should prefer lexical content over stale text when building search document', () => { const document = (searchService as any).toSearchDocument('post', { - _id: { toString: () => 'post-lexical' }, + id: 'post-lexical', title: '富文本文章', text: '旧摘要', contentFormat: 'lexical', diff --git a/apps/core/test/src/modules/serverless/serverless.service.spec.ts b/apps/core/test/src/modules/serverless/serverless.service.spec.ts index 15ca0e5c978..84e127d048d 100644 --- a/apps/core/test/src/modules/serverless/serverless.service.spec.ts +++ b/apps/core/test/src/modules/serverless/serverless.service.spec.ts @@ -1,9 +1,16 @@ import { Test } from '@nestjs/testing' import { getModelForClass } from '@typegoose/typegoose' +import mongoose from 'mongoose' +import { redisHelper } from 'test/helper/redis-mock.helper' + +import { + OWNER_PROFILE_COLLECTION_NAME, + READER_COLLECTION_NAME, +} from '~/constants/db.constant' import { ConfigsService } from '~/modules/configs/configs.service' import { createMockedContextResponse } from '~/modules/serverless/mock-response.util' -import { ServerlessLogModel } from '~/modules/serverless/serverless-log.model' import { ServerlessService } from '~/modules/serverless/serverless.service' +import { ServerlessLogModel } from '~/modules/serverless/serverless-log.model' import { SnippetModel, SnippetType } from '~/modules/snippet/snippet.model' import { DatabaseService } from '~/processors/database/database.service' import { AssetService } from '~/processors/helper/helper.asset.service' @@ -11,8 +18,6 @@ import { EventManagerService } from '~/processors/helper/helper.event.service' import { HttpService } from '~/processors/helper/helper.http.service' import { RedisService } from '~/processors/redis/redis.service' import { getModelToken } from '~/transformers/model.transformer' -import mongoose from 'mongoose' -import { redisHelper } from 'test/helper/redis-mock.helper' describe('test serverless function service', () => { let service: ServerlessService @@ -220,4 +225,56 @@ describe('test serverless function service', () => { ).raw, ).not.toEqual('`') }) + + test('case-9: getOwner bridge preserves legacy _id alongside canonical id', async () => { + const ownerId = new mongoose.Types.ObjectId() + const db = (service as any).databaseService.db + const originalCollection = db.collection.bind(db) + + const collectionSpy = vi + .spyOn(db, 'collection') + .mockImplementation((name: string, ...args: any[]) => { + if (name === READER_COLLECTION_NAME) { + return { + find: vi.fn().mockReturnValue({ + sort: vi.fn().mockReturnValue({ + limit: vi.fn().mockReturnValue({ + next: vi.fn().mockResolvedValue({ + _id: ownerId, + username: 'owner', + name: 'Owner Name', + email: 'owner@example.com', + image: 'https://example.com/avatar.png', + }), + }), + }), + }), + } as any + } + + if (name === OWNER_PROFILE_COLLECTION_NAME) { + return { + findOne: vi.fn().mockResolvedValue({ + mail: 'owner@example.com', + socialIds: { github: 'innei' }, + }), + } as any + } + + return originalCollection(name, ...args) + }) + + try { + const owner = await (service as any).mockGetOwner() + + expect(owner).toMatchObject({ + _id: ownerId.toHexString(), + id: ownerId.toHexString(), + username: 'owner', + mail: 'owner@example.com', + }) + } finally { + collectionSpy.mockRestore() + } + }) }) diff --git a/apps/core/test/src/modules/topic/topic.controller.e2e-spec.ts b/apps/core/test/src/modules/topic/topic.controller.e2e-spec.ts index f21d3bad398..ed2953629a0 100644 --- a/apps/core/test/src/modules/topic/topic.controller.e2e-spec.ts +++ b/apps/core/test/src/modules/topic/topic.controller.e2e-spec.ts @@ -32,7 +32,7 @@ describe('TopicBaseController translation (e2e)', () => { }, ]) - translatedTopicId = translatedTopic._id.toString() + translatedTopicId = translatedTopic.id app = await setupE2EApp({ controllers: [TopicBaseController], diff --git a/apps/core/test/src/shared/id/id.util.spec.ts b/apps/core/test/src/shared/id/id.util.spec.ts new file mode 100644 index 00000000000..9d4fd521275 --- /dev/null +++ b/apps/core/test/src/shared/id/id.util.spec.ts @@ -0,0 +1,53 @@ +import { Types } from 'mongoose' +import { describe, expect, it } from 'vitest' + +import { + brandEntityId, + isObjectIdString, + parseObjectIdString, + toObjectId, + toObjectIdArray, + unsafeObjectIdString, +} from '~/shared/id' + +describe('shared id utilities', () => { + it('brands valid object id strings', () => { + const value = '507f1f77bcf86cd799439011' + const id = parseObjectIdString(value) + + expect(id).toBe(value) + expect(isObjectIdString(id)).toBe(true) + }) + + it('rejects invalid object id strings', () => { + expect(() => parseObjectIdString('id-1')).toThrow( + 'Invalid MongoDB ObjectId', + ) + }) + + it('converts branded ids to Types.ObjectId', () => { + const id = unsafeObjectIdString('507f1f77bcf86cd799439011') + const objectId = toObjectId(id) + + expect(objectId).toBeInstanceOf(Types.ObjectId) + expect(objectId.toHexString()).toBe(id) + }) + + it('converts branded id arrays to Types.ObjectId arrays', () => { + const ids = [ + unsafeObjectIdString('507f1f77bcf86cd799439011'), + unsafeObjectIdString('507f191e810c19729de860ea'), + ] + + const objectIds = toObjectIdArray(ids) + + expect(objectIds.map((item) => item.toHexString())).toEqual(ids) + }) + + it('brands entity ids without changing runtime values', () => { + const id = unsafeObjectIdString('507f1f77bcf86cd799439011') + const postId = brandEntityId<'post'>(id) + + expect(postId).toBe(id) + }) +}) diff --git a/apps/core/test/src/shared/model/lean-id.spec.ts b/apps/core/test/src/shared/model/lean-id.spec.ts new file mode 100644 index 00000000000..a64b1800928 --- /dev/null +++ b/apps/core/test/src/shared/model/lean-id.spec.ts @@ -0,0 +1,113 @@ +import mongoose from 'mongoose' +import { describe, expect, it } from 'vitest' + +import { normalizeDocumentIds } from '~/shared/model/plugins/lean-id' + +describe('normalizeDocumentIds', () => { + it('replaces root _id with id', () => { + const input = { + _id: '507f1f77bcf86cd799439011', + title: 'hello', + } + + const normalized = normalizeDocumentIds(input) + + expect(normalized).toBe(input) + expect(normalized).toEqual({ + id: '507f1f77bcf86cd799439011', + title: 'hello', + }) + expect(normalized).not.toHaveProperty('_id') + }) + + it('does not rewrite nested plain objects without schema context', () => { + const input = { + _id: '507f1f77bcf86cd799439011', + child: { + _id: '507f191e810c19729de860ea', + name: 'child', + }, + items: [ + { _id: '507f191e810c19729de860eb', name: 'a' }, + { _id: '507f191e810c19729de860ec', name: 'b' }, + ], + } + + normalizeDocumentIds(input) + + expect(input).toEqual({ + id: '507f1f77bcf86cd799439011', + child: { + _id: '507f191e810c19729de860ea', + name: 'child', + }, + items: [ + { _id: '507f191e810c19729de860eb', name: 'a' }, + { _id: '507f191e810c19729de860ec', name: 'b' }, + ], + }) + }) + + it('leaves primitive object id-like values under normal fields intact', () => { + const input = { + _id: '507f1f77bcf86cd799439011', + ref: '507f191e810c19729de860ea', + } + + normalizeDocumentIds(input) + + expect(input).toEqual({ + id: '507f1f77bcf86cd799439011', + ref: '507f191e810c19729de860ea', + }) + }) + + it('normalizes schema-declared child documents while preserving mixed payloads', () => { + const childSchema = new mongoose.Schema({ + name: String, + }) + const rootSchema = new mongoose.Schema({ + child: childSchema, + items: [childSchema], + meta: mongoose.Schema.Types.Mixed, + }) + + const input = { + _id: '507f1f77bcf86cd799439011', + child: { + _id: '507f191e810c19729de860ea', + name: 'child', + }, + items: [ + { _id: '507f191e810c19729de860eb', name: 'a' }, + { _id: '507f191e810c19729de860ec', name: 'b' }, + ], + meta: { + _id: 'user-defined', + nested: { + _id: 'still-user-defined', + }, + }, + } + + normalizeDocumentIds(input, rootSchema as any) + + expect(input).toEqual({ + id: '507f1f77bcf86cd799439011', + child: { + id: '507f191e810c19729de860ea', + name: 'child', + }, + items: [ + { id: '507f191e810c19729de860eb', name: 'a' }, + { id: '507f191e810c19729de860ec', name: 'b' }, + ], + meta: { + _id: 'user-defined', + nested: { + _id: 'still-user-defined', + }, + }, + }) + }) +}) diff --git a/docs/superpowers/plans/2026-04-11-canonical-id-refactor-implementation.md b/docs/superpowers/plans/2026-04-11-canonical-id-refactor-implementation.md new file mode 100644 index 00000000000..965ac194795 --- /dev/null +++ b/docs/superpowers/plans/2026-04-11-canonical-id-refactor-implementation.md @@ -0,0 +1,793 @@ +# Canonical String ID Refactor Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace all application-layer `_id` usage with a single canonical `id: ObjectIdString` contract, remove all compatibility fallbacks, and enforce the new model through types, serialization, events, tests, and lint rules. + +**Architecture:** Introduce a branded shared ID module that owns all `ObjectIdString <-> Types.ObjectId` conversion. Rebuild serialization and lean normalization so all documents, including nested populated objects, expose only `id`. Standardize lifecycle events to typed `{ id }` payloads, then sweep controllers, services, and utilities so business logic never sees `_id` or mixed identifier unions. + +**Tech Stack:** NestJS, TypeGoose/Mongoose, Zod (`nestjs-zod`), Vitest, pnpm, ESLint + +--- + +## Spec Reference + +| Document | Purpose | +| --- | --- | +| `docs/superpowers/specs/2026-04-11-canonical-id-refactor-design.md` | Authoritative architecture and breaking-change contract | + +## Phase Map + +| Phase | Objective | Primary verification | +| --- | --- | --- | +| Phase 1 | Shared branded ID primitives and DTO boundary | Shared ID unit tests, `typecheck` | +| Phase 2 | Canonical serialization, lean normalization, typed lifecycle events | Lean/plugin tests, CRUD/event tests | +| Phase 3 | Translation and article controller refactor | Translation interceptor tests, note/post/page controller tests | +| Phase 4 | Consumer sweep for activity, aggregate, search, owner, recently, serverless, comment | Module-specific Vitest suites | +| Phase 5 | Static enforcement and release verification | ESLint, TypeScript, targeted regression matrix | + +## File Map + +| Action | File | Responsibility | +| --- | --- | --- | +| Create | `apps/core/src/shared/id/id.type.ts` | Branded `ObjectIdString` and entity ID aliases | +| Create | `apps/core/src/shared/id/id.schema.ts` | Zod branded object ID schema | +| Create | `apps/core/src/shared/id/id.util.ts` | Canonical parse/brand/`toObjectId` helpers | +| Create | `apps/core/src/shared/id/index.ts` | Shared exports | +| Modify | `apps/core/src/common/zod/primitives.ts` | Re-export branded object ID schema or upgrade `zMongoId` | +| Modify | `apps/core/src/common/zod/index.ts` | Shared branded schema export | +| Modify | `apps/core/src/shared/dto/id.dto.ts` | DTOs return branded IDs | +| Modify | `apps/core/src/shared/dto/pager.dto.ts` | Cursor IDs use branded type | +| Modify | `apps/core/src/shared/model/base.model.ts` | Canonical `id` typing and serialization | +| Modify | `apps/core/src/shared/model/plugins/lean-id.ts` | Recursive normalization; strip `_id` | +| Create | `apps/core/src/processors/helper/helper.event.types.ts` | Typed event payload map | +| Modify | `apps/core/src/processors/helper/helper.event.service.ts` | Generic typed emit/on/register interfaces | +| Modify | `apps/core/src/transformers/crud-factor.transformer.ts` | Emit typed `{ id }` lifecycle payloads only | +| Modify | `apps/core/src/processors/helper/helper.event-payload.service.ts` | Enrich payloads by `id` only | +| Modify | `apps/core/src/processors/gateway/web/visitor-event-dispatch.service.ts` | Remove `_id` fallbacks from visitor broadcasting | +| Modify | `apps/core/src/common/decorators/translate-fields.decorator.ts` | Restrict `idField` to `'id'` | +| Modify | `apps/core/src/common/interceptors/translation-entry.interceptor.ts` | Collect and replace entity translations by `id` only | +| Modify | `apps/core/src/modules/ai/ai-translation/ai-translation.types.ts` | Remove mixed `_id`-based event payload types | +| Modify | `apps/core/src/modules/ai/ai-translation/ai-translation.service.ts` | Remove fallback extraction logic | +| Modify | `apps/core/src/modules/ai/ai-translation/ai-translation-event-handler.service.ts` | Consume canonical typed event payloads | +| Modify | `apps/core/src/modules/page/page.controller.ts` | Remove `doc._id` fallbacks | +| Modify | `apps/core/src/modules/note/note.controller.ts` | Remove `note._id` fallbacks | +| Modify | `apps/core/src/modules/post/post.controller.ts` | Remove `doc._id` fallbacks | +| Modify | `apps/core/src/modules/activity/activity.controller.ts` | Remove `_id` response assembly and translation fallback logic | +| Modify | `apps/core/src/modules/activity/activity.service.ts` | Build maps by canonical `id` | +| Modify | `apps/core/src/modules/aggregate/aggregate.controller.ts` | Remove `_id` fallback response shaping | +| Modify | `apps/core/src/modules/topic/topic.controller.ts` | Switch translation rules to `idField: 'id'` | +| Modify | `apps/core/src/modules/category/category.controller.ts` | Switch translation rules to `idField: 'id'` | +| Modify | `apps/core/src/modules/search/search-document.util.ts` | Build search refs from `id` only | +| Modify | `apps/core/src/modules/search/search.service.ts` | Canonical search source/result typing | +| Modify | `apps/core/src/modules/owner/owner.model.ts` | Remove `_id` from view model contract | +| Modify | `apps/core/src/modules/owner/owner.service.ts` | Return owner identity with `id` only | +| Modify | `apps/core/src/modules/recently/recently.model.ts` | Canonical `refId` getter semantics | +| Modify | `apps/core/src/modules/recently/recently.service.ts` | Keep query boundary conversion local; expose `id` only | +| Modify | `apps/core/src/modules/serverless/serverless.service.ts` | Remove owner `_id` fallbacks from returned identities | +| Modify | `apps/core/src/modules/markdown/markdown.service.ts` | Return category-like objects with canonical `id` | +| Modify | `apps/core/src/modules/comment/comment.service.ts` | Replace local mixed-ID helpers with shared canonical helpers | +| Modify | `apps/core/src/modules/comment/comment.lifecycle.service.ts` | Remove `(comment as any)._id` access | +| Modify | `apps/core/src/modules/comment/comment.controller.ts` | Remove fallback response assembly | +| Modify | `eslint.config.mjs` | Add `_id` restriction policy outside allowlist | +| Create | `apps/core/test/src/shared/id/id.util.spec.ts` | Shared ID boundary tests | +| Create | `apps/core/test/src/shared/model/lean-id.spec.ts` | Recursive serialization and lean normalization tests | +| Create | `apps/core/test/src/processors/helper/helper.event.service.spec.ts` | Typed lifecycle payload tests | +| Create | `apps/core/test/src/modules/page/page.controller.spec.ts` | Page controller canonical `id` translation tests | +| Create | `apps/core/test/src/modules/activity/activity.controller.spec.ts` | Activity controller `id`-only response tests | +| Create | `apps/core/test/src/modules/aggregate/aggregate.controller.spec.ts` | Aggregate controller `id`-only response tests | +| Modify | `apps/core/test/src/transformers/curd-factor.e2e-spec.ts` | CRUD lifecycle payload contract | +| Modify | `apps/core/test/src/modules/ai/translation-entry.interceptor.spec.ts` | `idField: 'id'` translation lookup behavior | +| Modify | `apps/core/test/src/modules/ai/ai-translation.service.spec.ts` | Canonical article event ID handling | +| Modify | `apps/core/test/src/modules/note/note.translation-entry.e2e-spec.ts` | Nested translated refs use `id` | +| Modify | `apps/core/test/src/modules/topic/topic.controller.e2e-spec.ts` | Topic translation rules use `id` | +| Modify | `apps/core/test/src/modules/note/note.controller.e2e-spec.ts` | No `_id` in note transport shape | +| Modify | `apps/core/test/src/modules/post/post.controller.e2e-spec.ts` | No `_id` in post transport shape | +| Modify | `apps/core/test/src/modules/search/search-document.util.spec.ts` | Search document builder rejects `_id`-only fixtures | +| Modify | `apps/core/test/src/modules/search/search.service.spec.ts` | Search aggregation uses canonical IDs | +| Modify | `apps/core/test/src/modules/owner/owner.controller.spec.ts` | Owner response has `id` only | +| Modify | `apps/core/test/src/modules/recently/recently.controller.e2e-spec.ts` | Recently responses and refs expose `id` only | +| Modify | `apps/core/test/src/modules/serverless/serverless.service.spec.ts` | Serverless owner identity uses `id` only | +| Modify | `apps/core/test/src/modules/comment/comment-write.spec.ts` | Comment writes use shared canonical IDs | +| Modify | `apps/core/test/src/modules/comment/comment-thread.spec.ts` | Thread loading and root resolution use `id` only | +| Modify | `apps/core/test/src/modules/comment/comment-lifecycle.spec.ts` | Lifecycle broadcasting uses `id` only | +| Modify | `apps/core/test/src/modules/comment/comment.controller.spec.ts` | Comment responses expose `id` only | + +--- + +## Phase 1: Shared ID Foundation + +### Task 1: Create the shared branded ID module + +**Files:** +- Create: `apps/core/src/shared/id/id.type.ts` +- Create: `apps/core/src/shared/id/id.schema.ts` +- Create: `apps/core/src/shared/id/id.util.ts` +- Create: `apps/core/src/shared/id/index.ts` +- Test: `apps/core/test/src/shared/id/id.util.spec.ts` + +- [ ] **Step 1: Write the failing shared ID tests** + +Add tests covering: +- valid 24-hex strings are branded as `ObjectIdString` +- invalid strings are rejected +- `toObjectId` converts branded IDs to `Types.ObjectId` +- entity branding helpers preserve the original value + +Suggested skeleton: + +```ts +it('brands valid object id strings') +it('rejects invalid object id strings') +it('converts branded ids to Types.ObjectId') +``` + +- [ ] **Step 2: Run the targeted test to verify RED** + +Run: `pnpm -C apps/core exec vitest run test/src/shared/id/id.util.spec.ts` + +Expected: FAIL because the shared ID module does not exist. + +- [ ] **Step 3: Implement the shared ID module** + +Create the branded types and boundary helpers described in the spec: + +```ts +export type ObjectIdString = string & { readonly [objectIdBrand]: 'ObjectIdString' } +export const zObjectIdString = z.string().regex(...).transform(...) +export function toObjectId(id: ObjectIdString): Types.ObjectId +``` + +- [ ] **Step 4: Re-run the targeted test to verify GREEN** + +Run: `pnpm -C apps/core exec vitest run test/src/shared/id/id.util.spec.ts` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/core/src/shared/id apps/core/test/src/shared/id/id.util.spec.ts +git commit -m "refactor(id): add canonical branded id primitives" +``` + +### Task 2: Upgrade Zod and shared DTO boundaries to branded IDs + +**Files:** +- Modify: `apps/core/src/common/zod/primitives.ts` +- Modify: `apps/core/src/common/zod/index.ts` +- Modify: `apps/core/src/shared/dto/id.dto.ts` +- Modify: `apps/core/src/shared/dto/pager.dto.ts` +- Test: `apps/core/test/src/shared/id/id.util.spec.ts` + +- [ ] **Step 1: Extend tests to cover DTO-facing branding** + +Add assertions that: +- `MongoIdDto`-compatible parsing returns `ObjectIdString` +- `IntIdOrMongoIdDto` still permits integer IDs where route semantics require it +- pager `before/after` values are branded IDs + +- [ ] **Step 2: Run targeted tests and typecheck to verify RED** + +Run: `pnpm -C apps/core exec vitest run test/src/shared/id/id.util.spec.ts` + +Run: `pnpm -C apps/core run typecheck` + +Expected: FAIL or TYPECHECK errors because DTOs still expose plain strings. + +- [ ] **Step 3: Refactor Zod exports and DTOs** + +Update: +- `zMongoId` to return a branded type or alias it to `zObjectIdString` +- `MongoIdDto.id` to use `ObjectIdString` +- `IntIdOrMongoIdDto.id` to use `number | ObjectIdString` +- `PagerDto.before/after` to use branded IDs + +- [ ] **Step 4: Re-run tests and typecheck to verify GREEN** + +Run: `pnpm -C apps/core exec vitest run test/src/shared/id/id.util.spec.ts` + +Run: `pnpm -C apps/core run typecheck` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/core/src/common/zod/primitives.ts apps/core/src/common/zod/index.ts apps/core/src/shared/dto/id.dto.ts apps/core/src/shared/dto/pager.dto.ts apps/core/test/src/shared/id/id.util.spec.ts +git commit -m "refactor(id): brand dto and zod id boundaries" +``` + +--- + +## Phase 2: Canonical Serialization and Typed Events + +### Task 3: Rebuild base serialization and recursive lean normalization + +**Files:** +- Modify: `apps/core/src/shared/model/base.model.ts` +- Modify: `apps/core/src/shared/model/plugins/lean-id.ts` +- Test: `apps/core/test/src/shared/model/lean-id.spec.ts` + +- [ ] **Step 1: Write the failing serialization tests** + +Add tests covering: +- `toJSON()` exposes `id` and omits `_id` +- `toObject()` exposes `id` and omits `_id` +- nested populated objects are normalized recursively +- arrays of nested populated objects are normalized recursively + +Suggested skeleton: + +```ts +expect(serialized).toEqual({ + id: expect.any(String), + child: { id: expect.any(String) }, +}) +expect(serialized).not.toHaveProperty('_id') +expect(serialized.child).not.toHaveProperty('_id') +``` + +- [ ] **Step 2: Run the targeted test to verify RED** + +Run: `pnpm -C apps/core exec vitest run test/src/shared/model/lean-id.spec.ts` + +Expected: FAIL because `_id` is still present and nested objects are not normalized. + +- [ ] **Step 3: Implement canonical serialization** + +Refactor: +- `BaseModel.id` to `ObjectIdString` +- base serialization hooks to delete `_id` +- recursive lean normalizer to rewrite nested documents from `_id` to `id` + +- [ ] **Step 4: Re-run the targeted test to verify GREEN** + +Run: `pnpm -C apps/core exec vitest run test/src/shared/model/lean-id.spec.ts` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/core/src/shared/model/base.model.ts apps/core/src/shared/model/plugins/lean-id.ts apps/core/test/src/shared/model/lean-id.spec.ts +git commit -m "refactor(id): canonicalize model serialization and lean results" +``` + +### Task 4: Verify CRUD transport emits `id` only + +**Files:** +- Modify: `apps/core/test/src/transformers/curd-factor.e2e-spec.ts` +- Modify: `apps/core/src/transformers/crud-factor.transformer.ts` + +- [ ] **Step 1: Write the failing CRUD transport assertions** + +Add assertions that: +- CRUD create/update/get responses include `id` +- CRUD responses do not include `_id` +- delete lifecycle payload carries `{ id }` only + +- [ ] **Step 2: Run the targeted test to verify RED** + +Run: `pnpm -C apps/core exec vitest run test/src/transformers/curd-factor.e2e-spec.ts` + +Expected: FAIL because transport still leaks `_id` and lifecycle payloads are inconsistent. + +- [ ] **Step 3: Refactor CRUD factory output and lifecycle emission** + +Update `crud-factor.transformer.ts` so: +- create/update/delete broadcasts are typed `{ id }` +- no raw document is emitted as a lifecycle payload + +- [ ] **Step 4: Re-run the targeted test to verify GREEN** + +Run: `pnpm -C apps/core exec vitest run test/src/transformers/curd-factor.e2e-spec.ts` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/core/src/transformers/crud-factor.transformer.ts apps/core/test/src/transformers/curd-factor.e2e-spec.ts +git commit -m "refactor(id): standardize crud transport and lifecycle payloads" +``` + +### Task 5: Add typed event payload contracts and update event infrastructure + +**Files:** +- Create: `apps/core/src/processors/helper/helper.event.types.ts` +- Modify: `apps/core/src/processors/helper/helper.event.service.ts` +- Modify: `apps/core/src/processors/helper/helper.event-payload.service.ts` +- Modify: `apps/core/src/processors/gateway/web/visitor-event-dispatch.service.ts` +- Create: `apps/core/test/src/processors/helper/helper.event.service.spec.ts` + +- [ ] **Step 1: Write the failing event payload tests** + +Add tests covering: +- `emit/on/registerHandler` are type-safe for lifecycle payloads +- payload enrichment reloads entities from `id` +- visitor event translation broadcasting never inspects `_id` + +- [ ] **Step 2: Run the targeted test to verify RED** + +Run: `pnpm -C apps/core exec vitest run test/src/processors/helper/helper.event.service.spec.ts` + +Expected: FAIL because typed event payload infrastructure does not exist. + +- [ ] **Step 3: Implement typed event contracts** + +Create a payload map such as: + +```ts +export interface BusinessEventPayloadMap { + POST_CREATE: { id: PostId } + POST_UPDATE: { id: PostId } + NOTE_CREATE: { id: NoteId } + COMMENT_CREATE: { id: CommentId } +} +``` + +Then refactor: +- `EventManagerService.emit/on/registerHandler` +- `EventPayloadEnricherService` +- `VisitorEventDispatchService` + +to use canonical typed payloads. + +- [ ] **Step 4: Re-run the targeted test to verify GREEN** + +Run: `pnpm -C apps/core exec vitest run test/src/processors/helper/helper.event.service.spec.ts` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/core/src/processors/helper/helper.event.types.ts apps/core/src/processors/helper/helper.event.service.ts apps/core/src/processors/helper/helper.event-payload.service.ts apps/core/src/processors/gateway/web/visitor-event-dispatch.service.ts apps/core/test/src/processors/helper/helper.event.service.spec.ts +git commit -m "refactor(id): type lifecycle events around canonical ids" +``` + +--- + +## Phase 3: Translation and Article Flow Refactor + +### Task 6: Remove mixed `_id` event payload support from AI translation flows + +**Files:** +- Modify: `apps/core/src/modules/ai/ai-translation/ai-translation.types.ts` +- Modify: `apps/core/src/modules/ai/ai-translation/ai-translation.service.ts` +- Modify: `apps/core/src/modules/ai/ai-translation/ai-translation-event-handler.service.ts` +- Modify: `apps/core/test/src/modules/ai/ai-translation.service.spec.ts` + +- [ ] **Step 1: Write the failing translation event tests** + +Add assertions that: +- article event payloads are `{ id }` only +- translation service no longer accepts `{ data }` or document-shaped payloads with `_id` +- delete/update/create handlers reload the entity by `id` + +- [ ] **Step 2: Run the targeted test to verify RED** + +Run: `pnpm -C apps/core exec vitest run test/src/modules/ai/ai-translation.service.spec.ts` + +Expected: FAIL because the translation service still contains fallback extraction logic. + +- [ ] **Step 3: Refactor AI translation event types and handlers** + +Remove: +- `_id`-based `ArticleEventDocument` +- `extractIdFromEvent` compatibility logic +- any `event?.id?.toString?.() ?? event?._id?.toString?.()` patterns + +Replace with typed `id` payload handling only. + +- [ ] **Step 4: Re-run the targeted test to verify GREEN** + +Run: `pnpm -C apps/core exec vitest run test/src/modules/ai/ai-translation.service.spec.ts` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/core/src/modules/ai/ai-translation/ai-translation.types.ts apps/core/src/modules/ai/ai-translation/ai-translation.service.ts apps/core/src/modules/ai/ai-translation/ai-translation-event-handler.service.ts apps/core/test/src/modules/ai/ai-translation.service.spec.ts +git commit -m "refactor(id): remove mixed id payload support from translation flows" +``` + +### Task 7: Narrow translation decorators and interceptor to `idField: 'id'` + +**Files:** +- Modify: `apps/core/src/common/decorators/translate-fields.decorator.ts` +- Modify: `apps/core/src/common/interceptors/translation-entry.interceptor.ts` +- Modify: `apps/core/src/modules/post/post.controller.ts` +- Modify: `apps/core/src/modules/note/note.controller.ts` +- Modify: `apps/core/src/modules/topic/topic.controller.ts` +- Modify: `apps/core/src/modules/category/category.controller.ts` +- Modify: `apps/core/test/src/modules/ai/translation-entry.interceptor.spec.ts` +- Modify: `apps/core/test/src/modules/note/note.translation-entry.e2e-spec.ts` +- Modify: `apps/core/test/src/modules/topic/topic.controller.e2e-spec.ts` + +- [ ] **Step 1: Write the failing translation-entry tests** + +Add assertions that: +- `TranslateFieldRule.idField` only accepts `'id'` +- translation lookups pull `parent.id`, not `parent._id` +- nested translated refs continue to resolve correctly after recursive normalization + +- [ ] **Step 2: Run the targeted tests to verify RED** + +Run: `pnpm -C apps/core exec vitest run test/src/modules/ai/translation-entry.interceptor.spec.ts test/src/modules/note/note.translation-entry.e2e-spec.ts test/src/modules/topic/topic.controller.e2e-spec.ts` + +Expected: FAIL because the decorator and tests still depend on `_id`. + +- [ ] **Step 3: Refactor decorator contract and call sites** + +Change: + +```ts +export interface TranslateFieldRule { + path: string + keyPath: TranslationEntryKeyPath + idField?: 'id' +} +``` + +Then update every call site from `idField: '_id'` to `idField: 'id'`. + +- [ ] **Step 4: Re-run the targeted tests to verify GREEN** + +Run: `pnpm -C apps/core exec vitest run test/src/modules/ai/translation-entry.interceptor.spec.ts test/src/modules/note/note.translation-entry.e2e-spec.ts test/src/modules/topic/topic.controller.e2e-spec.ts` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/core/src/common/decorators/translate-fields.decorator.ts apps/core/src/common/interceptors/translation-entry.interceptor.ts apps/core/src/modules/post/post.controller.ts apps/core/src/modules/note/note.controller.ts apps/core/src/modules/topic/topic.controller.ts apps/core/src/modules/category/category.controller.ts apps/core/test/src/modules/ai/translation-entry.interceptor.spec.ts apps/core/test/src/modules/note/note.translation-entry.e2e-spec.ts apps/core/test/src/modules/topic/topic.controller.e2e-spec.ts +git commit -m "refactor(id): translate entity fields by canonical id" +``` + +### Task 8: Remove article controller `_id` fallbacks in page, note, and post flows + +**Files:** +- Create: `apps/core/test/src/modules/page/page.controller.spec.ts` +- Modify: `apps/core/src/modules/page/page.controller.ts` +- Modify: `apps/core/src/modules/note/note.controller.ts` +- Modify: `apps/core/src/modules/post/post.controller.ts` +- Modify: `apps/core/test/src/modules/note/note.controller.e2e-spec.ts` +- Modify: `apps/core/test/src/modules/post/post.controller.e2e-spec.ts` + +- [ ] **Step 1: Write the failing article controller tests** + +Add assertions that: +- article translation input uses `doc.id` only +- note adjacency translation uses `note.id` +- responses contain `id` and omit `_id` +- page translation routes operate correctly with canonical `id` + +- [ ] **Step 2: Run the targeted tests to verify RED** + +Run: `pnpm -C apps/core exec vitest run test/src/modules/page/page.controller.spec.ts test/src/modules/note/note.controller.e2e-spec.ts test/src/modules/post/post.controller.e2e-spec.ts` + +Expected: FAIL because controllers still use `_id` fallback expressions. + +- [ ] **Step 3: Refactor article controllers** + +Replace all patterns such as: + +```ts +doc._id?.toString?.() ?? doc.id ?? String(doc._id) +``` + +with direct canonical reads: + +```ts +doc.id +``` + +- [ ] **Step 4: Re-run the targeted tests to verify GREEN** + +Run: `pnpm -C apps/core exec vitest run test/src/modules/page/page.controller.spec.ts test/src/modules/note/note.controller.e2e-spec.ts test/src/modules/post/post.controller.e2e-spec.ts` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/core/src/modules/page/page.controller.ts apps/core/src/modules/note/note.controller.ts apps/core/src/modules/post/post.controller.ts apps/core/test/src/modules/page/page.controller.spec.ts apps/core/test/src/modules/note/note.controller.e2e-spec.ts apps/core/test/src/modules/post/post.controller.e2e-spec.ts +git commit -m "refactor(id): remove article controller id fallbacks" +``` + +--- + +## Phase 4: Consumer Sweep + +### Task 9: Refactor activity, aggregate, category, and topic consumers to canonical IDs + +**Files:** +- Create: `apps/core/test/src/modules/activity/activity.controller.spec.ts` +- Create: `apps/core/test/src/modules/aggregate/aggregate.controller.spec.ts` +- Modify: `apps/core/src/modules/activity/activity.controller.ts` +- Modify: `apps/core/src/modules/activity/activity.service.ts` +- Modify: `apps/core/src/modules/aggregate/aggregate.controller.ts` +- Modify: `apps/core/src/modules/category/category.controller.ts` +- Modify: `apps/core/src/modules/topic/topic.controller.ts` +- Modify: `apps/core/test/src/modules/topic/topic.controller.e2e-spec.ts` + +- [ ] **Step 1: Write the failing consumer-facing controller tests** + +Add assertions that: +- presence/read-room/top-reading endpoints expose nested refs with `id` only +- aggregate summaries do not synthesize `id` from `_id` +- topic/category translation paths still work after removing `_id` + +- [ ] **Step 2: Run the targeted tests to verify RED** + +Run: `pnpm -C apps/core exec vitest run test/src/modules/activity/activity.controller.spec.ts test/src/modules/aggregate/aggregate.controller.spec.ts test/src/modules/topic/topic.controller.e2e-spec.ts` + +Expected: FAIL because controllers and services still reference `_id`. + +- [ ] **Step 3: Refactor activity and aggregate flows** + +Remove all `_id` response assembly from: +- `activity.controller.ts` +- `activity.service.ts` +- `aggregate.controller.ts` + +Require every translation input and response projection to use canonical `id`. + +- [ ] **Step 4: Re-run the targeted tests to verify GREEN** + +Run: `pnpm -C apps/core exec vitest run test/src/modules/activity/activity.controller.spec.ts test/src/modules/aggregate/aggregate.controller.spec.ts test/src/modules/topic/topic.controller.e2e-spec.ts` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/core/src/modules/activity/activity.controller.ts apps/core/src/modules/activity/activity.service.ts apps/core/src/modules/aggregate/aggregate.controller.ts apps/core/src/modules/category/category.controller.ts apps/core/src/modules/topic/topic.controller.ts apps/core/test/src/modules/activity/activity.controller.spec.ts apps/core/test/src/modules/aggregate/aggregate.controller.spec.ts apps/core/test/src/modules/topic/topic.controller.e2e-spec.ts +git commit -m "refactor(id): canonicalize aggregate and activity consumers" +``` + +### Task 10: Refactor search, owner, recently, serverless, and markdown support code + +**Files:** +- Modify: `apps/core/src/modules/search/search-document.util.ts` +- Modify: `apps/core/src/modules/search/search.service.ts` +- Modify: `apps/core/src/modules/owner/owner.model.ts` +- Modify: `apps/core/src/modules/owner/owner.service.ts` +- Modify: `apps/core/src/modules/recently/recently.model.ts` +- Modify: `apps/core/src/modules/recently/recently.service.ts` +- Modify: `apps/core/src/modules/serverless/serverless.service.ts` +- Modify: `apps/core/src/modules/markdown/markdown.service.ts` +- Modify: `apps/core/test/src/modules/search/search-document.util.spec.ts` +- Modify: `apps/core/test/src/modules/search/search.service.spec.ts` +- Modify: `apps/core/test/src/modules/owner/owner.controller.spec.ts` +- Modify: `apps/core/test/src/modules/recently/recently.controller.e2e-spec.ts` +- Modify: `apps/core/test/src/modules/serverless/serverless.service.spec.ts` + +- [ ] **Step 1: Write the failing support-layer tests** + +Add assertions that: +- search document builders require `id` +- owner responses do not carry `_id` +- recently refs resolve to canonical `id` +- serverless owner identity only carries `id` +- markdown category helpers return canonical IDs + +- [ ] **Step 2: Run the targeted tests to verify RED** + +Run: `pnpm -C apps/core exec vitest run test/src/modules/search/search-document.util.spec.ts test/src/modules/search/search.service.spec.ts test/src/modules/owner/owner.controller.spec.ts test/src/modules/recently/recently.controller.e2e-spec.ts test/src/modules/serverless/serverless.service.spec.ts` + +Expected: FAIL because utility and view contracts still reference `_id`. + +- [ ] **Step 3: Refactor support modules** + +Implement the following: +- remove `_id` from `OwnerModel` +- require `SearchDocumentSource.id` +- update `RecentlyModel.refId` getter to resolve from populated `id` +- stop returning owner `_id` from serverless flows +- stop returning category `_id` from markdown helpers + +- [ ] **Step 4: Re-run the targeted tests to verify GREEN** + +Run: `pnpm -C apps/core exec vitest run test/src/modules/search/search-document.util.spec.ts test/src/modules/search/search.service.spec.ts test/src/modules/owner/owner.controller.spec.ts test/src/modules/recently/recently.controller.e2e-spec.ts test/src/modules/serverless/serverless.service.spec.ts` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/core/src/modules/search/search-document.util.ts apps/core/src/modules/search/search.service.ts apps/core/src/modules/owner/owner.model.ts apps/core/src/modules/owner/owner.service.ts apps/core/src/modules/recently/recently.model.ts apps/core/src/modules/recently/recently.service.ts apps/core/src/modules/serverless/serverless.service.ts apps/core/src/modules/markdown/markdown.service.ts apps/core/test/src/modules/search/search-document.util.spec.ts apps/core/test/src/modules/search/search.service.spec.ts apps/core/test/src/modules/owner/owner.controller.spec.ts apps/core/test/src/modules/recently/recently.controller.e2e-spec.ts apps/core/test/src/modules/serverless/serverless.service.spec.ts +git commit -m "refactor(id): remove _id from support-layer contracts" +``` + +### Task 11: Refactor the comment module to use canonical IDs only + +**Files:** +- Modify: `apps/core/src/modules/comment/comment.service.ts` +- Modify: `apps/core/src/modules/comment/comment.lifecycle.service.ts` +- Modify: `apps/core/src/modules/comment/comment.controller.ts` +- Modify: `apps/core/test/src/modules/comment/comment-write.spec.ts` +- Modify: `apps/core/test/src/modules/comment/comment-thread.spec.ts` +- Modify: `apps/core/test/src/modules/comment/comment-lifecycle.spec.ts` +- Modify: `apps/core/test/src/modules/comment/comment.controller.spec.ts` +- Modify: `apps/core/test/src/modules/comment/comment-anchor.spec.ts` + +- [ ] **Step 1: Write the failing comment module tests** + +Add assertions that: +- comment service does not define local mixed-ID unions +- thread/root lookup logic works from canonical `id` +- lifecycle broadcasting sends `{ id }` +- controller responses expose `id` only + +- [ ] **Step 2: Run the targeted tests to verify RED** + +Run: `pnpm -C apps/core exec vitest run test/src/modules/comment/comment-write.spec.ts test/src/modules/comment/comment-thread.spec.ts test/src/modules/comment/comment-lifecycle.spec.ts test/src/modules/comment/comment.controller.spec.ts test/src/modules/comment/comment-anchor.spec.ts` + +Expected: FAIL because comment code still relies on `_id` and mixed ID helpers. + +- [ ] **Step 3: Refactor comment ID handling** + +Replace local helper patterns such as: + +```ts +private toObjectId(id: string | Types.ObjectId | { _id?: unknown }) +private buildMixedIdCandidates(ids: Array) +``` + +with shared branded boundary conversions. All internal identity comparisons must use canonical `id`. + +- [ ] **Step 4: Re-run the targeted tests to verify GREEN** + +Run: `pnpm -C apps/core exec vitest run test/src/modules/comment/comment-write.spec.ts test/src/modules/comment/comment-thread.spec.ts test/src/modules/comment/comment-lifecycle.spec.ts test/src/modules/comment/comment.controller.spec.ts test/src/modules/comment/comment-anchor.spec.ts` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/core/src/modules/comment/comment.service.ts apps/core/src/modules/comment/comment.lifecycle.service.ts apps/core/src/modules/comment/comment.controller.ts apps/core/test/src/modules/comment/comment-write.spec.ts apps/core/test/src/modules/comment/comment-thread.spec.ts apps/core/test/src/modules/comment/comment-lifecycle.spec.ts apps/core/test/src/modules/comment/comment.controller.spec.ts apps/core/test/src/modules/comment/comment-anchor.spec.ts +git commit -m "refactor(id): canonicalize comment module identifiers" +``` + +--- + +## Phase 5: Enforcement and Release Verification + +### Task 12: Add lint enforcement and sweep residual `_id` leaks + +**Files:** +- Modify: `eslint.config.mjs` +- Modify: any remaining violating source files discovered by lint + +- [ ] **Step 1: Add restricted `_id` usage rules** + +Configure ESLint to: +- disallow `._id` property access outside the approved allowlist +- disallow `idField: '_id'` +- disallow non-model `_id?:` utility types in application code + +- [ ] **Step 2: Run lint to verify RED** + +Run: `pnpm exec eslint "apps/core/src/**/*.ts" "packages/**/*.ts"` + +Expected: FAIL with residual `_id` violations outside the allowlist. + +- [ ] **Step 3: Fix all remaining violations** + +Use the lint output as a checklist. Do not weaken the rule. Eliminate or relocate every remaining illegal `_id` usage. + +- [ ] **Step 4: Re-run lint to verify GREEN** + +Run: `pnpm exec eslint "apps/core/src/**/*.ts" "packages/**/*.ts"` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add eslint.config.mjs apps/core/src packages +git commit -m "refactor(id): enforce canonical id usage with lint" +``` + +### Task 13: Run the full regression matrix and verify the breaking-change contract + +**Files:** +- Modify: tests only if regressions remain + +- [ ] **Step 1: Run the final targeted regression matrix** + +Run: + +```bash +pnpm -C apps/core exec vitest run \ + test/src/shared/id/id.util.spec.ts \ + test/src/shared/model/lean-id.spec.ts \ + test/src/processors/helper/helper.event.service.spec.ts \ + test/src/transformers/curd-factor.e2e-spec.ts \ + test/src/modules/ai/translation-entry.interceptor.spec.ts \ + test/src/modules/ai/ai-translation.service.spec.ts \ + test/src/modules/page/page.controller.spec.ts \ + test/src/modules/note/note.controller.e2e-spec.ts \ + test/src/modules/post/post.controller.e2e-spec.ts \ + test/src/modules/activity/activity.controller.spec.ts \ + test/src/modules/aggregate/aggregate.controller.spec.ts \ + test/src/modules/search/search-document.util.spec.ts \ + test/src/modules/search/search.service.spec.ts \ + test/src/modules/owner/owner.controller.spec.ts \ + test/src/modules/recently/recently.controller.e2e-spec.ts \ + test/src/modules/serverless/serverless.service.spec.ts \ + test/src/modules/comment/comment-write.spec.ts \ + test/src/modules/comment/comment-thread.spec.ts \ + test/src/modules/comment/comment-lifecycle.spec.ts \ + test/src/modules/comment/comment.controller.spec.ts +``` + +Expected: PASS. + +- [ ] **Step 2: Run static verification** + +Run: + +```bash +pnpm typecheck +pnpm lint +``` + +Expected: PASS. + +- [ ] **Step 3: Run a contract grep for forbidden patterns** + +Run: + +```bash +rg -n "idField: '_id'|\\._id\\?\\.toString|\\?_id\\?\\.toString|event\\?\\._id|id \\?\\? .*_id|string \\| Types\\.ObjectId" apps/core/src packages +``` + +Expected: No matches outside migrations or explicit persistence allowlist files. + +- [ ] **Step 4: Commit the completed refactor** + +```bash +git add apps/core/src apps/core/test/src packages eslint.config.mjs +git commit -m "refactor(id): complete canonical string id migration" +``` + +- [ ] **Step 5: Prepare release notes** + +Document the breaking changes: +- `_id` removed from transport payloads +- lifecycle events now carry `{ id }` only +- application code must use branded `ObjectIdString` + +Suggested location: append to the current changelog or release notes workflow used by `apps/core/CHANGELOG.md`. + +--- + +## Execution Notes + +| Constraint | Instruction | +| --- | --- | +| Compatibility code | Do not introduce any adapter, shim, fallback chain, or dual-field payload | +| Query boundary | `Types.ObjectId` conversion is allowed only adjacent to model filters | +| Aggregation | Internal `_id` is allowed inside pipelines, but public results must project to `id` before returning | +| Tests | Prefer behavioral assertions on `id` presence and `_id` absence; avoid low-signal implementation snapshots | +| Review focus | Reject any implementation that leaves `string \| Types.ObjectId` unions in business-layer signatures | + +## Completion Gate + +The implementation is complete only when all of the following are true: + +| Gate | Required state | +| --- | --- | +| Transport contract | HTTP and WebSocket payloads contain `id` and never `_id` | +| Event contract | Lifecycle events use typed `{ id }` payloads only | +| Type contract | No business-layer API accepts `string \| Types.ObjectId` | +| Translation contract | All `TranslateFields` rules use `idField: 'id'` | +| Static enforcement | ESLint blocks future `_id` leakage outside the allowlist | +| Regression matrix | All targeted tests, `pnpm typecheck`, and `pnpm lint` pass | + +Plan complete and saved to `docs/superpowers/plans/2026-04-11-canonical-id-refactor-implementation.md`. Ready to execute? diff --git a/docs/superpowers/specs/2026-04-11-canonical-id-refactor-design.md b/docs/superpowers/specs/2026-04-11-canonical-id-refactor-design.md new file mode 100644 index 00000000000..923632b267a --- /dev/null +++ b/docs/superpowers/specs/2026-04-11-canonical-id-refactor-design.md @@ -0,0 +1,531 @@ +# Canonical String ID Refactor Design + +## Summary + +Refactor the application to adopt a single canonical identifier model: + +| Layer | Canonical representation | +| --- | --- | +| MongoDB storage | `_id: ObjectId` | +| Mongoose query boundary | `Types.ObjectId` | +| Application, transport, events, DTOs, views | `id: ObjectIdString` | + +This refactor is intentionally **non-compatible**: + +| Rule | Decision | +| --- | --- | +| `_id` in HTTP responses | Removed | +| `_id` in business event payloads | Removed | +| `_id` fallback reads such as `doc.id ?? doc._id?.toString?.()` | Forbidden | +| Union inputs such as `string \| ObjectId \| { _id?: ... }` in business logic | Forbidden | +| Mixed `id/_id` translation lookup rules | Removed | + +The database primary key mechanism remains MongoDB-native. The refactor changes the application contract, not the storage primitive. + +## Motivation + +### Current Failure Modes + +| Symptom | Example location | Root cause | +| --- | --- | --- | +| Repeated fallback chains | `page.controller.ts`, `note.controller.ts`, `post.controller.ts` | No canonical identifier contract after query serialization | +| Event handlers accept multiple payload shapes | `ai-translation.service.ts` | Create/update/delete events are not standardized | +| Nested populated objects expose `_id` but not `id` | `TranslateFields(... idField: '_id')` usages | Current lean plugin does not recursively normalize nested objects | +| Service signatures accept mixed identifier types | `comment.service.ts`, `auth.service.ts` | Type system does not distinguish storage IDs from application IDs | +| View models still retain `_id` | `owner.model.ts`, `search-document.util.ts` | DTO/view layer is not isolated from persistence structure | + +### Architectural Objective + +```text +┌─────────────────────────────┐ +│ MongoDB / Mongoose internals│ +│ _id: ObjectId │ +└──────────────┬──────────────┘ + │ parse / serialize exactly once + ▼ +┌─────────────────────────────┐ +│ Shared ID boundary │ +│ ObjectIdString (branded) │ +└──────────────┬──────────────┘ + │ reused everywhere else + ▼ +┌───────────────────────────────────────────────────────┐ +│ Controllers │ Services │ Events │ Interceptors │ DTOs │ +│ id only, no _id, no fallback, no mixed unions │ +└───────────────────────────────────────────────────────┘ +``` + +## Goals + +| Goal | Description | +| --- | --- | +| Single identifier contract | All application-facing entities expose only `id` | +| Type safety | Branded ID types prevent accidental use of arbitrary `string` values | +| Zero compatibility paths | No dual-shape payloads, no fallbacks, no conditional coercion outside the persistence boundary | +| Recursive normalization | Nested populated documents and lean results also expose only `id` | +| Enforceability | Lint and type definitions make regressions difficult to reintroduce | + +## Non-Goals + +| Non-goal | Rationale | +| --- | --- | +| Replacing MongoDB `_id` with a custom primary key field | High cost, no benefit for the current problem | +| Retaining transitional support for `_id` in application code | Explicitly rejected by requirement | +| Preserving wire compatibility for undocumented WebSocket or internal event consumers | This is a deliberate breaking change | +| Refactoring Mongo aggregation internals to avoid `_id` inside pipeline stages | `_id` remains acceptable inside aggregation machinery, but must not escape the boundary | + +## Design Decisions + +1. **`id` is the sole application identifier.** +2. **`_id` is a storage detail and may appear only in schema definitions, Mongoose filters, populate metadata, and aggregation internals.** +3. **All request DTOs, response DTOs, event payloads, view models, utility types, and translation rules must use `id` only.** +4. **All business events carry a structured typed payload with `id`; raw document payloads are removed.** +5. **The shared normalization layer must remove `_id`, not merely mirror it.** +6. **No service or controller may accept `string | Types.ObjectId` unions.** +7. **Breaking change rollout is atomic; there is no compatibility flag, adapter, or shim.** + +## Target Invariants + +| Invariant | Required state | +| --- | --- | +| HTTP JSON payloads | Never contain `_id` | +| WebSocket payloads | Never contain `_id` | +| Business event payloads | Always structured, always include `id`, never include `_id` | +| Lean query result | Contains `id`, does not contain `_id`, including nested populated objects | +| `toJSON()` / `toObject()` result | Contains `id`, does not contain `_id` | +| Route parameter type | Branded `ObjectIdString` or an explicit union such as `number \| ObjectIdString` where route semantics demand it | +| Translation field rules | `idField` may only be `'id'` | +| Search indexing inputs | `refId` derived from `id`, not from `_id` | + +## Type System Design + +### Canonical ID Types + +Create a dedicated shared ID module: + +| File | Responsibility | +| --- | --- | +| `apps/core/src/shared/id/id.type.ts` | Branded type declarations | +| `apps/core/src/shared/id/id.schema.ts` | Zod schemas for branded IDs | +| `apps/core/src/shared/id/id.util.ts` | Boundary-only conversion helpers | +| `apps/core/src/shared/id/index.ts` | Public exports | + +### Required Types + +```ts +declare const objectIdBrand: unique symbol + +export type ObjectIdString = string & { + readonly [objectIdBrand]: 'ObjectIdString' +} + +export type EntityId = ObjectIdString & { + readonly __entity: Name +} + +export type PostId = EntityId<'post'> +export type NoteId = EntityId<'note'> +export type PageId = EntityId<'page'> +export type RecentlyId = EntityId<'recently'> +export type CommentId = EntityId<'comment'> +export type ReaderId = EntityId<'reader'> +export type CategoryId = EntityId<'category'> +export type TopicId = EntityId<'topic'> +``` + +### Required Schemas + +```ts +export const zObjectIdString = z + .string() + .regex(/^[0-9a-f]{24}$/i, 'Invalid MongoDB ObjectId') + .transform((value) => value as ObjectIdString) +``` + +### Conversion Rules + +| Function | Allowed location | Purpose | +| --- | --- | --- | +| `parseObjectIdString(value)` | DTO and request boundary | Validate and brand | +| `toObjectId(id: ObjectIdString)` | Persistence boundary only | Convert branded string to `Types.ObjectId` | +| `toObjectIdArray(ids: readonly ObjectIdString[])` | Persistence boundary only | Bulk conversion | +| `brandEntityId<'post'>(id)` | Service boundary | Narrow generic ID to domain-specific ID | + +### Explicitly Forbidden Types + +```ts +string | Types.ObjectId +string | Types.ObjectId | { _id?: unknown } +{ id?: string; _id?: unknown } +``` + +These unions are prohibited in controllers, services, interceptors, event handlers, utility functions, and view models. + +## Serialization and Lean Normalization + +### Base Model Contract + +`apps/core/src/shared/model/base.model.ts` must be updated so that: + +| Method | Required behavior | +| --- | --- | +| `toJSON` | Add `id`, remove `_id`, retain getters and virtuals | +| `toObject` | Add `id`, remove `_id`, retain getters and virtuals | +| `BaseModel.id` type | `ObjectIdString` | + +### Lean Plugin Contract + +`apps/core/src/shared/model/plugins/lean-id.ts` must be replaced with a recursive canonicalization plugin. + +Required behavior: + +| Case | Result | +| --- | --- | +| Root lean document | `id` added, `_id` removed | +| Nested populated object | `id` added, `_id` removed | +| Nested array of populated objects | Every element normalized recursively | +| Primitive `ObjectId` value that is not a document | Left intact unless the field is explicitly an identifier field | + +This plugin is the enabling change for removing `idField: '_id'` from translation decorators. + +### JSON Transform Interceptor + +`apps/core/src/common/interceptors/json-transform.interceptor.ts` must not be used as a compatibility scrubber. Its role remains structural serialization only. The canonical `id` contract must already hold before response serialization. + +## Boundary Rules + +### Allowed `_id` Usage + +| Allowed area | Examples | +| --- | --- | +| Mongoose schema metadata | `foreignField: '_id'`, `schemaOptions._id` | +| Query filters adjacent to model calls | `{ _id: toObjectId(id) }` | +| Aggregation internal stages | `$group: { _id: ... }`, `$project: { id: '$_id' }` | +| Migration scripts | Historical data maintenance | + +### Forbidden `_id` Usage + +| Forbidden area | Examples | +| --- | --- | +| Controller response assembly | `doc._id?.toString?.()` | +| Event payload shape | `{ _id: ... }` | +| Service-level identity comparison | `String(doc._id) === ...` | +| Utility types | `_id?: { toString(): string }` | +| Translation decorators | `idField: '_id'` | +| View model contracts | `OwnerModel._id` | + +## Event Model Refactor + +### Canonical Event Payload + +Create typed event payload definitions: + +| File | Responsibility | +| --- | --- | +| `apps/core/src/processors/helper/helper.event.types.ts` | Event payload map and typed helpers | + +Required lifecycle payload shape: + +```ts +type EntityLifecyclePayload = { + id: EntityId +} +``` + +### Event Contract Changes + +| Event family | Old shape | New shape | +| --- | --- | --- | +| `POST_CREATE`, `POST_UPDATE` | Full document | `{ id: PostId }` | +| `NOTE_CREATE`, `NOTE_UPDATE` | Full document | `{ id: NoteId }` | +| `PAGE_CREATE`, `PAGE_UPDATE` | Full document | `{ id: PageId }` | +| `CATEGORY_*`, `TOPIC_*` | Mixed document / `{ id }` | `{ id: CategoryId }`, `{ id: TopicId }` | +| `COMMENT_CREATE` | Full comment document | `{ id: CommentId }` | +| `*_DELETE` | Already close to `{ id }` | Remain `{ id }`, strongly typed | + +### Consequences + +| Consumer | Required change | +| --- | --- | +| `ai-translation-event-handler.service.ts` | Load entity by `id`; delete handlers use `id` directly | +| `helper.event-payload.service.ts` | Enrich from `id` only | +| `visitor-event-dispatch.service.ts` | Never inspect `_id`; operate on canonical `id` | +| `comment.lifecycle.service.ts` | Broadcast `id` only; reload comment if payload enrichment is required | + +### Explicit Removal + +`apps/core/src/modules/ai/ai-translation/ai-translation.types.ts` must remove: + +```ts +type ArticleEventDocument = { _id?: ... } +type ArticleEventPayload = ArticleEventDocument | { data: string } | { id: string } +``` + +Replace with: + +```ts +type ArticleEventPayload = { id: PostId | NoteId | PageId } +``` + +## DTO and Validation Refactor + +### Required Changes + +| File | Required modification | +| --- | --- | +| `apps/core/src/common/zod/primitives.ts` | Export branded `zObjectIdString` or upgrade `zMongoId` to return branded type | +| `apps/core/src/common/zod/index.ts` | Re-export branded schema | +| `apps/core/src/shared/dto/id.dto.ts` | `MongoIdDto.id` becomes `ObjectIdString`; `IntIdOrMongoIdDto` becomes `number | ObjectIdString` | +| `apps/core/src/shared/dto/pager.dto.ts` | `before/after` use branded IDs | + +### DTO Rule + +Route handlers must receive already-branded IDs. No controller may re-validate or re-coerce the same identifier. + +## Translation Field Refactor + +### Decorator Contract + +`apps/core/src/common/decorators/translate-fields.decorator.ts` must narrow: + +```ts +idField?: 'id' +``` + +There is no remaining reason to allow arbitrary identifier field names. + +### Required Call-Site Changes + +The following files must replace every `idField: '_id'` with `idField: 'id'`: + +| File | +| --- | +| `apps/core/src/modules/post/post.controller.ts` | +| `apps/core/src/modules/note/note.controller.ts` | +| `apps/core/src/modules/topic/topic.controller.ts` | +| `apps/core/src/modules/category/category.controller.ts` | + +`apps/core/src/common/interceptors/translation-entry.interceptor.ts` must collect entity IDs from `parent.id` only. + +## Service and Controller Refactor Inventory + +### Shared Infrastructure + +| File | Change | +| --- | --- | +| `apps/core/src/shared/model/base.model.ts` | Brand `id`, strip `_id` in serialization | +| `apps/core/src/shared/model/plugins/lean-id.ts` | Recursive canonical normalization | +| `apps/core/src/transformers/crud-factor.transformer.ts` | Broadcast typed `{ id }` payloads only | +| `apps/core/src/processors/database/database.service.ts` | Accept branded IDs; return documents with canonical `id` only | + +### HTTP Controllers + +Remove every fallback expression shaped like `item._id?.toString?.() ?? item.id ?? ...` from: + +| File | Required state after refactor | +| --- | --- | +| `apps/core/src/modules/page/page.controller.ts` | Read `doc.id` only | +| `apps/core/src/modules/note/note.controller.ts` | Read `note.id` only | +| `apps/core/src/modules/post/post.controller.ts` | Read `doc.id` only | +| `apps/core/src/modules/activity/activity.controller.ts` | Read `item.id` only | +| `apps/core/src/modules/aggregate/aggregate.controller.ts` | Read `item.id` only | +| `apps/core/src/modules/category/category.controller.ts` | Read `item.id` only | +| `apps/core/src/modules/comment/comment.controller.ts` | Read `comment.id` only | +| `apps/core/src/modules/file/file.controller.ts` | Return string `id`, not raw `_id` | + +### Domain Services + +| File | Change | +| --- | --- | +| `apps/core/src/modules/comment/comment.service.ts` | Replace local mixed-ID helpers with branded boundary conversion; internal comparisons use canonical `id` | +| `apps/core/src/modules/comment/comment.lifecycle.service.ts` | Remove `(comment as any)._id` reads | +| `apps/core/src/modules/ai/ai-translation/ai-translation.service.ts` | Eliminate event fallback extraction logic; use `id` only | +| `apps/core/src/modules/ai/ai-translation/ai-translation-event-handler.service.ts` | Load documents by typed `id` | +| `apps/core/src/modules/activity/activity.service.ts` | Build maps by `id`, not `_id.toHexString()` | +| `apps/core/src/modules/search/search-document.util.ts` | Source type exposes `id` only | +| `apps/core/src/modules/search/search.service.ts` | Typed search sources and result grouping use canonical IDs | +| `apps/core/src/modules/recently/recently.service.ts` | Internal foreign ID maps may use ObjectId at query time, but view models expose only `id` | +| `apps/core/src/modules/owner/owner.service.ts` | Return `OwnerModel` without `_id` | +| `apps/core/src/modules/serverless/serverless.service.ts` | Remove owner `_id` fallback in returned identity objects | +| `apps/core/src/modules/markdown/markdown.service.ts` | Returned category objects expose `id`, not `_id` | + +### Model and View Types + +| File | Change | +| --- | --- | +| `apps/core/src/modules/owner/owner.model.ts` | Remove `_id` property entirely | +| `apps/core/src/modules/recently/recently.model.ts` | `refId` getter returns canonical `id` from populated refs, never `_id` | +| `apps/core/src/modules/ai/ai-translation/ai-translation.types.ts` | Replace mixed document payload types with `id`-only event payloads | +| `apps/core/src/modules/search/search-document.util.ts` | Remove `_id` from `SearchDocumentSource` | + +## Query and Persistence Rules + +### Query Rule + +Every direct Mongoose lookup by identifier must follow this pattern: + +```ts +const objectId = toObjectId(id) +return this.model.findOne({ _id: objectId }) +``` + +There is no acceptable pattern in which a service accepts both `string` and `Types.ObjectId`. + +### Aggregation Rule + +Aggregation pipelines may use Mongo `_id` internally, but must project to named fields before the result leaves the service: + +```ts +{ $group: { _id: '$categoryId', count: { $sum: 1 } } }, +{ $project: { id: '$_id', count: 1, _id: 0 } } +``` + +The public result type must not contain `_id`. + +## Lint Enforcement + +`eslint.config.mjs` must add a restricted-usage policy: + +| Rule | Scope | +| --- | --- | +| Disallow property access `._id` | All application files except allowlisted persistence files | +| Disallow string literals `idField: '_id'` | Entire application source | +| Disallow types containing `_id?:` in non-model, non-migration files | Entire application source | + +### Allowlist + +| Allowed path pattern | +| --- | +| `apps/core/src/**/migration/**` | +| `apps/core/src/**/model.ts` | +| `apps/core/src/shared/model/**` | +| `apps/core/src/**/schema.ts` when defining Mongoose schema shape | +| Explicit query-boundary helpers under `apps/core/src/shared/id/**` | + +## API and Package Surface Changes + +### Core API + +| Surface | Breaking change | +| --- | --- | +| HTTP JSON | `_id` removed everywhere | +| WebSocket entity payloads | `_id` removed everywhere | +| Internal business event payloads | Full documents removed; typed `{ id }` only | + +### `packages/api-client` + +`packages/api-client/models/base.ts` already models `id` only. This refactor aligns the server with the client contract. Any tests that still rely on `_id` fixtures at the transport layer must be updated. + +### `packages/webhook` + +Generated model extraction must be verified after the refactor so that generated types do not regress to `_id`-dependent transport contracts. + +## Breaking Change and Rollout Strategy + +### Release Strategy + +| Property | Decision | +| --- | --- | +| Rollout mode | Single cutover | +| Compatibility shims | None | +| Feature flags | None | +| Required coordination | Core server, WebSocket consumers, tests, generated types | + +### Data Migration + +No database data migration is required because MongoDB continues to store `_id` as usual. This is an application contract refactor, not a persistence rewrite. + +## Test Plan + +### Unit Tests + +| Area | Required assertions | +| --- | --- | +| Shared ID utils | Valid ID branding, invalid ID rejection, `toObjectId` conversion | +| Lean normalization plugin | Root object, nested object, populated object, array recursion, `_id` removal | +| Base model serialization | `toJSON()` and `toObject()` expose `id` and omit `_id` | +| Translation interceptor | Entity lookup uses `id` only | +| Search document builder | `refId` derives from `id` only | + +### Integration Tests + +| Area | Required assertions | +| --- | --- | +| CRUD endpoints | Response payloads contain `id` and never `_id` | +| Create/update/delete events | Event payloads are `{ id }` only | +| Translation flows | Article and entity translation handlers work without `_id` fallback | +| Activity and aggregate endpoints | Nested refs expose `id` only | +| Comment flows | Comment lifecycle and reply flows operate without `(comment as any)._id` access | + +### Static Verification + +| Check | Purpose | +| --- | --- | +| `tsc --noEmit` | Type contract enforcement | +| ESLint | `_id` usage policy enforcement | +| Targeted Vitest suites | Regression coverage for touched modules | + +### Test Design Constraints + +Follow the repository policy: + +| Constraint | Requirement | +| --- | --- | +| Snapshot tests | Do not add implementation snapshots of static tables or literal structures | +| Behavioral coverage | Prefer assertions on canonical `id` visibility and absence of `_id` | + +## Implementation Sequence + +```text +┌────────────────────────────┐ +│ 1. Shared ID module │ +└─────────────┬──────────────┘ + ▼ +┌────────────────────────────┐ +│ 2. Base serialization and │ +│ recursive lean plugin │ +└─────────────┬──────────────┘ + ▼ +┌────────────────────────────┐ +│ 3. DTO and event typing │ +└─────────────┬──────────────┘ + ▼ +┌────────────────────────────┐ +│ 4. Controller/service │ +│ fallback removal │ +└─────────────┬──────────────┘ + ▼ +┌────────────────────────────┐ +│ 5. Translation/search/ │ +│ owner/recently cleanup │ +└─────────────┬──────────────┘ + ▼ +┌────────────────────────────┐ +│ 6. Lint rule + tests │ +└────────────────────────────┘ +``` + +## Acceptance Criteria + +| Criterion | Pass condition | +| --- | --- | +| Canonical transport | No HTTP or WebSocket payload contains `_id` | +| Canonical event model | No lifecycle event consumer needs `_id` fallback logic | +| Type safety | No business-layer signature accepts `string \| Types.ObjectId` | +| Translation consistency | All `TranslateFields` rules use `idField: 'id'` | +| Static enforcement | ESLint fails on newly introduced `_id` access outside allowlist | +| Runtime consistency | Populated nested objects expose `id` recursively | + +## Explicit Rejection List + +The following patterns are prohibited after this refactor: + +```ts +doc.id ?? doc._id?.toString?.() +event?.id?.toString?.() ?? event?._id?.toString?.() +type X = { id?: string; _id?: unknown } +type Y = string | Types.ObjectId +@TranslateFields({ path: 'topic.name', keyPath: 'topic.name', idField: '_id' }) +``` + +These are not transitional code smells. They are direct violations of the target architecture. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8302cd74bdf..e43096018d1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -95,11 +95,11 @@ importers: specifier: ^7.29.0 version: 7.29.0 '@better-auth/api-key': - specifier: ^1.5.1 - version: 1.5.1(0a53bf63a56f6e4a68f85e527e77f23d) + specifier: ^1.6.2 + version: 1.6.2(9b1e211888a2a82481b329eda018abb5) '@better-auth/passkey': - specifier: ^1.5.1 - version: 1.5.1(c2250912b5bb0cf34cb701dea4e568a5) + specifier: ^1.6.2 + version: 1.6.2(894b15d7b528e2db91e6d8c0135b8f13) '@fastify/cookie': specifier: 11.0.2 version: 11.0.2 @@ -197,8 +197,8 @@ importers: specifier: ^3.0.3 version: 3.0.3 better-auth: - specifier: ^1.5.1 - version: 1.5.1(@cloudflare/workers-types@4.20260411.1)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(typescript@6.0.2))(typescript@6.0.2))(drizzle-orm@0.45.1(@cloudflare/workers-types@4.20260411.1)(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.0)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(typescript@6.0.2))(typescript@6.0.2))(kysely@0.28.11)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(typescript@6.0.2)))(mongodb@6.12.0)(mysql2@3.15.3)(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(typescript@6.0.2))(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(vitest@4.1.4) + specifier: ^1.6.2 + version: 1.6.2(@cloudflare/workers-types@4.20260411.1)(@opentelemetry/api@1.9.0)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(typescript@6.0.2))(typescript@6.0.2))(drizzle-orm@0.45.1(@cloudflare/workers-types@4.20260411.1)(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.0)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(typescript@6.0.2))(typescript@6.0.2))(kysely@0.28.16)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(typescript@6.0.2)))(mongodb@6.12.0)(mysql2@3.15.3)(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(typescript@6.0.2))(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(vitest@4.1.4) blurhash: specifier: 2.0.5 version: 2.0.5 @@ -724,20 +724,21 @@ packages: resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} - '@better-auth/api-key@1.5.1': - resolution: {integrity: sha512-yZ24TrMySPG8eGrmcOFdZD7RyJvaQZvbqyQL4dZAvGJkl65r3CLjYSSxbUE8gxs//R5x/cLto+tGxOjx8ssmcw==} + '@better-auth/api-key@1.6.2': + resolution: {integrity: sha512-eEGfiPKS4qnd8MoV+GtPM9PP74cNfvQow3/0jARRNu8piI8R4NjqpojaSJVjZa5VrsRDKyYCpXAKz8u+7eaHog==} peerDependencies: - '@better-auth/core': 1.5.1 - '@better-auth/utils': 0.3.1 - better-auth: 1.5.1 + '@better-auth/core': ^1.6.2 + '@better-auth/utils': 0.4.0 + better-auth: ^1.6.2 - '@better-auth/core@1.5.1': - resolution: {integrity: sha512-lHDoChK6FFy3+oawt/tl9S08LuYNnbT1o3HKxleFrRtQIyIdUKu38X8AUJ8ueB6sE+ju5YhxBqoCFhdb6Aa67A==} + '@better-auth/core@1.6.2': + resolution: {integrity: sha512-nBftDp+eN1fwXor1O4KQorCXa0tJNDgpab7O1z4NcWUU+3faDpdzqLn5mbXZer2E8ZD4VhjqOfYZ041xnBF5NA==} peerDependencies: - '@better-auth/utils': 0.3.1 + '@better-auth/utils': 0.4.0 '@better-fetch/fetch': 1.1.21 '@cloudflare/workers-types': '>=4' - better-call: 1.3.2 + '@opentelemetry/api': ^1.9.0 + better-call: 1.3.5 jose: ^6.1.0 kysely: ^0.28.5 nanostores: ^1.0.1 @@ -745,58 +746,74 @@ packages: '@cloudflare/workers-types': optional: true - '@better-auth/drizzle-adapter@1.5.1': - resolution: {integrity: sha512-0KKIpDTi1IWXVHL//H8w0S8oQ9KjdlE5YgN9mMloMbU1uyxZ0shhUiT8mC9025vwkZ7OXLywI2hFeGJ+mcdRBQ==} + '@better-auth/drizzle-adapter@1.6.2': + resolution: {integrity: sha512-KawrNNuhgmpcc5PgLs6HesMckxCscz5J+BQ99iRmU1cLzG/A87IcydrmYtep+K8WHPN0HmZ/i4z/nOBCtxE2qA==} peerDependencies: - '@better-auth/core': 1.5.1 - '@better-auth/utils': ^0.3.0 + '@better-auth/core': ^1.6.2 + '@better-auth/utils': 0.4.0 drizzle-orm: '>=0.41.0' + peerDependenciesMeta: + drizzle-orm: + optional: true - '@better-auth/kysely-adapter@1.5.1': - resolution: {integrity: sha512-OuhmNKjxpHlSw214kww4/tGfLHjtyC/HzN6Q/HulUeRF5QyCCHqj0y44ba6WGj3hcGsvPUkdUk4SayKXCrUCFw==} + '@better-auth/kysely-adapter@1.6.2': + resolution: {integrity: sha512-YMMm75jek/MNCAFWTAaq/U3VPmFnrwZW4NhBjjAwruHQJEIrSZZaOaUEXuUpFRRBhWqg7OOltQcHMwU/45CkuA==} peerDependencies: - '@better-auth/core': 1.5.1 - '@better-auth/utils': ^0.3.0 + '@better-auth/core': ^1.6.2 + '@better-auth/utils': 0.4.0 kysely: ^0.27.0 || ^0.28.0 + peerDependenciesMeta: + kysely: + optional: true - '@better-auth/memory-adapter@1.5.1': - resolution: {integrity: sha512-FSacaykLJXEizbnShF2FWZtWk5j0f87iq5Esjfd/7XHcF9nZeXn9Ju8jItDKeOWbEasOdfErEBqWSn30hmueRQ==} + '@better-auth/memory-adapter@1.6.2': + resolution: {integrity: sha512-QvuK5m7NFgkzLPHyab+NORu3J683nj36Tix58qq6DPcniyY6KZk5gY2yyh4+z1wgSjrxwY5NFx/DC2qz8B8NJg==} peerDependencies: - '@better-auth/core': 1.5.1 - '@better-auth/utils': ^0.3.0 + '@better-auth/core': ^1.6.2 + '@better-auth/utils': 0.4.0 - '@better-auth/mongo-adapter@1.5.1': - resolution: {integrity: sha512-BrbVuH1cqjs86Z6ae8OFEbvNLNxibddU4hDApVNtJcz7a/BaUPUdIM1Ep7HFhDozK7DrODXWZGLFXk+yV2pt3g==} + '@better-auth/mongo-adapter@1.6.2': + resolution: {integrity: sha512-IvR2Q+1pjzxA4JXI3ED76+6fsqervIpZ2K5MxoX/+miLQhLEmNcbqqcItg4O2kfkxN8h33/ev57sjTW8QH9Tuw==} peerDependencies: - '@better-auth/core': 1.5.1 - '@better-auth/utils': ^0.3.0 + '@better-auth/core': ^1.6.2 + '@better-auth/utils': 0.4.0 mongodb: 6.12.0 + peerDependenciesMeta: + mongodb: + optional: true - '@better-auth/passkey@1.5.1': - resolution: {integrity: sha512-stGKhBDpi5mva+IfCbucU8hD9iUPmFXdAyhv7Z10p2i2Oy7cMbZGZMpPbVU0NiuHsQtFURQmlpflJdKomnfrMw==} + '@better-auth/passkey@1.6.2': + resolution: {integrity: sha512-jMfLAoCS+hI3nCZw3CepWIW/hAvw5l7CoN4PzhaSOt16uuAKHXbZPJOT7pz+E4l2d20+L7eshN4pH9wBh2L+uA==} peerDependencies: - '@better-auth/core': 1.5.1 - '@better-auth/utils': 0.3.1 + '@better-auth/core': ^1.6.2 + '@better-auth/utils': 0.4.0 '@better-fetch/fetch': 1.1.21 - better-auth: 1.5.1 - better-call: 1.3.2 + better-auth: ^1.6.2 + better-call: 1.3.5 nanostores: ^1.0.1 - '@better-auth/prisma-adapter@1.5.1': - resolution: {integrity: sha512-24kBkBVaQbLnGe3/V/H+nX0hYaI+wNZFJhMnK16GQV7g6LyQw7UOcsd1xPDKtC61JCDmXEEEqXfnyuw5QITMVw==} + '@better-auth/prisma-adapter@1.6.2': + resolution: {integrity: sha512-bQkXYTo1zPau+xAiMpo1yCjEDSy7i7oeYlkYO+fSfRDCo52DE/9oPOOuI+EStmFkPUNSk9L2rhk8Fulifi8WCg==} peerDependencies: - '@better-auth/core': 1.5.1 - '@better-auth/utils': ^0.3.0 + '@better-auth/core': ^1.6.2 + '@better-auth/utils': 0.4.0 '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 prisma: ^5.0.0 || ^6.0.0 || ^7.0.0 + peerDependenciesMeta: + '@prisma/client': + optional: true + prisma: + optional: true - '@better-auth/telemetry@1.5.1': - resolution: {integrity: sha512-bHn9sdmf1bWUiC75J8FIF3rAEnL5ICihY5IBoXKVwxemaB3jSXL/sG9OpmadapPXlC8BY2OuATxdg39qbg+JlA==} + '@better-auth/telemetry@1.6.2': + resolution: {integrity: sha512-o4gHKXqizUxVUUYChZZTowLEzdsz3ViBE/fKFzfHqNFUnF+aVt8QsbLSfipq1WpTIXyJVT/SnH0hgSdWxdssbQ==} peerDependencies: - '@better-auth/core': 1.5.1 + '@better-auth/core': ^1.6.2 + '@better-auth/utils': 0.4.0 + '@better-fetch/fetch': 1.1.21 - '@better-auth/utils@0.3.1': - resolution: {integrity: sha512-+CGp4UmZSUrHHnpHhLPYu6cV+wSUSvVbZbNykxhUDocpVNTo9uFFxw/NqJlh1iC4wQ9HKKWGCKuZ5wUgS0v6Kg==} + '@better-auth/utils@0.4.0': + resolution: {integrity: sha512-RpMtLUIQAEWMgdPLNVbIF5ON2mm+CH0U3rCdUCU1VyeAUui4m38DyK7/aXMLZov2YDjG684pS1D0MBllrmgjQA==} '@better-fetch/fetch@1.1.21': resolution: {integrity: sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==} @@ -2090,6 +2107,10 @@ packages: resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} engines: {node: '>=8.0.0'} + '@opentelemetry/semantic-conventions@1.40.0': + resolution: {integrity: sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==} + engines: {node: '>=14'} + '@oxc-parser/binding-android-arm64@0.74.0': resolution: {integrity: sha512-lgq8TJq22eyfojfa2jBFy2m66ckAo7iNRYDdyn9reXYA3I6Wx7tgGWVx1JAp1lO+aUiqdqP/uPlDaETL9tqRcg==} engines: {node: '>=20.0.0'} @@ -3426,8 +3447,8 @@ packages: resolution: {integrity: sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==} hasBin: true - better-auth@1.5.1: - resolution: {integrity: sha512-Hnr4Ar49WpC0wyHFKYA86eQL5HhN5sNhtNquHrsH0T0r/IDqxDxAfW1VdSnTaXv4zc2WCXCQ8b1+InAopR2hAw==} + better-auth@1.6.2: + resolution: {integrity: sha512-5nqDAIj5xexmnk+GjjdrBknJCabi1mlvsVWJbxs4usHreao4vNdxIxINWDzCyDF9iDR1ildRZdXWSiYPAvTHhA==} peerDependencies: '@lynx-js/react': '*' '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 @@ -3488,8 +3509,8 @@ packages: vue: optional: true - better-call@1.3.2: - resolution: {integrity: sha512-4cZIfrerDsNTn3cm+MhLbUePN0gdwkhSXEuG7r/zuQ8c/H7iU0/jSK5TD3FW7U0MgKHce/8jGpPYNO4Ve+4NBw==} + better-call@1.3.5: + resolution: {integrity: sha512-kOFJkBP7utAQLEYrobZm3vkTH8mXq5GNgvjc5/XEST1ilVHaxXUXfeDeFlqoETMtyqS4+3/h4ONX2i++ebZrvA==} peerDependencies: zod: 4.3.6 peerDependenciesMeta: @@ -5323,8 +5344,8 @@ packages: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} - kysely@0.28.11: - resolution: {integrity: sha512-zpGIFg0HuoC893rIjYX1BETkVWdDnzTzF5e0kWXJFg5lE0k1/LfNWBejrcnOFu8Q2Rfq/hTDTU7XLUM8QOrpzg==} + kysely@0.28.16: + resolution: {integrity: sha512-3i5pmOiZvMDj00qhrIVbH0AnioVTx22DMP7Vn5At4yJO46iy+FM8Y/g61ltenLVSo3fiO8h8Q3QOFgf/gQ72ww==} engines: {node: '>=20.0.0'} language-subtag-registry@0.3.23: @@ -7744,75 +7765,83 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} - '@better-auth/api-key@1.5.1(0a53bf63a56f6e4a68f85e527e77f23d)': + '@better-auth/api-key@1.6.2(9b1e211888a2a82481b329eda018abb5)': dependencies: - '@better-auth/core': 1.5.1(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260411.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1) - '@better-auth/utils': 0.3.1 - better-auth: 1.5.1(@cloudflare/workers-types@4.20260411.1)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(typescript@6.0.2))(typescript@6.0.2))(drizzle-orm@0.45.1(@cloudflare/workers-types@4.20260411.1)(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.0)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(typescript@6.0.2))(typescript@6.0.2))(kysely@0.28.11)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(typescript@6.0.2)))(mongodb@6.12.0)(mysql2@3.15.3)(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(typescript@6.0.2))(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(vitest@4.1.4) + '@better-auth/core': 1.6.2(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260411.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.1.3)(kysely@0.28.16)(nanostores@1.1.1) + '@better-auth/utils': 0.4.0 + better-auth: 1.6.2(@cloudflare/workers-types@4.20260411.1)(@opentelemetry/api@1.9.0)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(typescript@6.0.2))(typescript@6.0.2))(drizzle-orm@0.45.1(@cloudflare/workers-types@4.20260411.1)(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.0)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(typescript@6.0.2))(typescript@6.0.2))(kysely@0.28.16)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(typescript@6.0.2)))(mongodb@6.12.0)(mysql2@3.15.3)(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(typescript@6.0.2))(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(vitest@4.1.4) zod: 4.3.6 - '@better-auth/core@1.5.1(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260411.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1)': + '@better-auth/core@1.6.2(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260411.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.1.3)(kysely@0.28.16)(nanostores@1.1.1)': dependencies: - '@better-auth/utils': 0.3.1 + '@better-auth/utils': 0.4.0 '@better-fetch/fetch': 1.1.21 + '@opentelemetry/api': 1.9.0 + '@opentelemetry/semantic-conventions': 1.40.0 '@standard-schema/spec': 1.1.0 - better-call: 1.3.2(zod@4.3.6) + better-call: 1.3.5(zod@4.3.6) jose: 6.1.3 - kysely: 0.28.11 + kysely: 0.28.16 nanostores: 1.1.1 zod: 4.3.6 optionalDependencies: '@cloudflare/workers-types': 4.20260411.1 - '@better-auth/drizzle-adapter@1.5.1(@better-auth/core@1.5.1(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260411.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(drizzle-orm@0.45.1(@cloudflare/workers-types@4.20260411.1)(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.0)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(typescript@6.0.2))(typescript@6.0.2))(kysely@0.28.11)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(typescript@6.0.2)))': + '@better-auth/drizzle-adapter@1.6.2(@better-auth/core@1.6.2(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260411.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.1.3)(kysely@0.28.16)(nanostores@1.1.1))(@better-auth/utils@0.4.0)(drizzle-orm@0.45.1(@cloudflare/workers-types@4.20260411.1)(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.0)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(typescript@6.0.2))(typescript@6.0.2))(kysely@0.28.16)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(typescript@6.0.2)))': dependencies: - '@better-auth/core': 1.5.1(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260411.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1) - '@better-auth/utils': 0.3.1 - drizzle-orm: 0.45.1(@cloudflare/workers-types@4.20260411.1)(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.0)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(typescript@6.0.2))(typescript@6.0.2))(kysely@0.28.11)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(typescript@6.0.2)) + '@better-auth/core': 1.6.2(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260411.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.1.3)(kysely@0.28.16)(nanostores@1.1.1) + '@better-auth/utils': 0.4.0 + optionalDependencies: + drizzle-orm: 0.45.1(@cloudflare/workers-types@4.20260411.1)(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.0)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(typescript@6.0.2))(typescript@6.0.2))(kysely@0.28.16)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(typescript@6.0.2)) - '@better-auth/kysely-adapter@1.5.1(@better-auth/core@1.5.1(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260411.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(kysely@0.28.11)': + '@better-auth/kysely-adapter@1.6.2(@better-auth/core@1.6.2(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260411.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.1.3)(kysely@0.28.16)(nanostores@1.1.1))(@better-auth/utils@0.4.0)(kysely@0.28.16)': dependencies: - '@better-auth/core': 1.5.1(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260411.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1) - '@better-auth/utils': 0.3.1 - kysely: 0.28.11 + '@better-auth/core': 1.6.2(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260411.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.1.3)(kysely@0.28.16)(nanostores@1.1.1) + '@better-auth/utils': 0.4.0 + optionalDependencies: + kysely: 0.28.16 - '@better-auth/memory-adapter@1.5.1(@better-auth/core@1.5.1(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260411.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)': + '@better-auth/memory-adapter@1.6.2(@better-auth/core@1.6.2(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260411.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.1.3)(kysely@0.28.16)(nanostores@1.1.1))(@better-auth/utils@0.4.0)': dependencies: - '@better-auth/core': 1.5.1(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260411.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1) - '@better-auth/utils': 0.3.1 + '@better-auth/core': 1.6.2(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260411.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.1.3)(kysely@0.28.16)(nanostores@1.1.1) + '@better-auth/utils': 0.4.0 - '@better-auth/mongo-adapter@1.5.1(@better-auth/core@1.5.1(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260411.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(mongodb@6.12.0)': + '@better-auth/mongo-adapter@1.6.2(@better-auth/core@1.6.2(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260411.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.1.3)(kysely@0.28.16)(nanostores@1.1.1))(@better-auth/utils@0.4.0)(mongodb@6.12.0)': dependencies: - '@better-auth/core': 1.5.1(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260411.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1) - '@better-auth/utils': 0.3.1 + '@better-auth/core': 1.6.2(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260411.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.1.3)(kysely@0.28.16)(nanostores@1.1.1) + '@better-auth/utils': 0.4.0 + optionalDependencies: mongodb: 6.12.0 - '@better-auth/passkey@1.5.1(c2250912b5bb0cf34cb701dea4e568a5)': + '@better-auth/passkey@1.6.2(894b15d7b528e2db91e6d8c0135b8f13)': dependencies: - '@better-auth/core': 1.5.1(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260411.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1) - '@better-auth/utils': 0.3.1 + '@better-auth/core': 1.6.2(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260411.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.1.3)(kysely@0.28.16)(nanostores@1.1.1) + '@better-auth/utils': 0.4.0 '@better-fetch/fetch': 1.1.21 '@simplewebauthn/browser': 13.2.2 '@simplewebauthn/server': 13.2.3 - better-auth: 1.5.1(@cloudflare/workers-types@4.20260411.1)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(typescript@6.0.2))(typescript@6.0.2))(drizzle-orm@0.45.1(@cloudflare/workers-types@4.20260411.1)(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.0)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(typescript@6.0.2))(typescript@6.0.2))(kysely@0.28.11)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(typescript@6.0.2)))(mongodb@6.12.0)(mysql2@3.15.3)(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(typescript@6.0.2))(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(vitest@4.1.4) - better-call: 1.3.2(zod@4.3.6) + better-auth: 1.6.2(@cloudflare/workers-types@4.20260411.1)(@opentelemetry/api@1.9.0)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(typescript@6.0.2))(typescript@6.0.2))(drizzle-orm@0.45.1(@cloudflare/workers-types@4.20260411.1)(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.0)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(typescript@6.0.2))(typescript@6.0.2))(kysely@0.28.16)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(typescript@6.0.2)))(mongodb@6.12.0)(mysql2@3.15.3)(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(typescript@6.0.2))(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(vitest@4.1.4) + better-call: 1.3.5(zod@4.3.6) nanostores: 1.1.1 zod: 4.3.6 - '@better-auth/prisma-adapter@1.5.1(@better-auth/core@1.5.1(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260411.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(typescript@6.0.2))(typescript@6.0.2))(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(typescript@6.0.2))': + '@better-auth/prisma-adapter@1.6.2(@better-auth/core@1.6.2(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260411.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.1.3)(kysely@0.28.16)(nanostores@1.1.1))(@better-auth/utils@0.4.0)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(typescript@6.0.2))(typescript@6.0.2))(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(typescript@6.0.2))': dependencies: - '@better-auth/core': 1.5.1(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260411.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1) - '@better-auth/utils': 0.3.1 + '@better-auth/core': 1.6.2(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260411.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.1.3)(kysely@0.28.16)(nanostores@1.1.1) + '@better-auth/utils': 0.4.0 + optionalDependencies: '@prisma/client': 7.4.2(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(typescript@6.0.2))(typescript@6.0.2) prisma: 7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(typescript@6.0.2) - '@better-auth/telemetry@1.5.1(@better-auth/core@1.5.1(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260411.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))': + '@better-auth/telemetry@1.6.2(@better-auth/core@1.6.2(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260411.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.1.3)(kysely@0.28.16)(nanostores@1.1.1))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)': dependencies: - '@better-auth/core': 1.5.1(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260411.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1) - '@better-auth/utils': 0.3.1 + '@better-auth/core': 1.6.2(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260411.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.1.3)(kysely@0.28.16)(nanostores@1.1.1) + '@better-auth/utils': 0.4.0 '@better-fetch/fetch': 1.1.21 - '@better-auth/utils@0.3.1': {} + '@better-auth/utils@0.4.0': + dependencies: + '@noble/hashes': 2.0.1 '@better-fetch/fetch@1.1.21': {} @@ -7828,15 +7857,19 @@ snapshots: '@chevrotain/gast': 10.5.0 '@chevrotain/types': 10.5.0 lodash: 4.17.21 + optional: true '@chevrotain/gast@10.5.0': dependencies: '@chevrotain/types': 10.5.0 lodash: 4.17.21 + optional: true - '@chevrotain/types@10.5.0': {} + '@chevrotain/types@10.5.0': + optional: true - '@chevrotain/utils@10.5.0': {} + '@chevrotain/utils@10.5.0': + optional: true '@cloudflare/kv-asset-handler@0.4.2': {} @@ -7875,12 +7908,15 @@ snapshots: '@electric-sql/pglite-socket@0.0.20(@electric-sql/pglite@0.3.15)': dependencies: '@electric-sql/pglite': 0.3.15 + optional: true '@electric-sql/pglite-tools@0.2.20(@electric-sql/pglite@0.3.15)': dependencies: '@electric-sql/pglite': 0.3.15 + optional: true - '@electric-sql/pglite@0.3.15': {} + '@electric-sql/pglite@0.3.15': + optional: true '@emnapi/core@1.8.1': dependencies: @@ -8198,6 +8234,7 @@ snapshots: '@hono/node-server@1.19.9(hono@4.11.4)': dependencies: hono: 4.11.4 + optional: true '@humanfs/core@0.19.1': {} @@ -8786,6 +8823,7 @@ snapshots: dependencies: chevrotain: 10.5.0 lilconfig: 2.1.0 + optional: true '@napi-rs/nice-android-arm-eabi@1.1.1': optional: true @@ -9047,8 +9085,9 @@ snapshots: dependencies: consola: 3.4.2 - '@opentelemetry/api@1.9.0': - optional: true + '@opentelemetry/api@1.9.0': {} + + '@opentelemetry/semantic-conventions@1.40.0': {} '@oxc-parser/binding-android-arm64@0.74.0': optional: true @@ -9223,7 +9262,8 @@ snapshots: oxc-parser: 0.74.0 optional: true - '@prisma/client-runtime-utils@7.4.2': {} + '@prisma/client-runtime-utils@7.4.2': + optional: true '@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(typescript@6.0.2))(typescript@6.0.2)': dependencies: @@ -9231,6 +9271,7 @@ snapshots: optionalDependencies: prisma: 7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(typescript@6.0.2) typescript: 6.0.2 + optional: true '@prisma/config@7.4.2': dependencies: @@ -9240,10 +9281,13 @@ snapshots: empathic: 2.0.0 transitivePeerDependencies: - magicast + optional: true - '@prisma/debug@7.2.0': {} + '@prisma/debug@7.2.0': + optional: true - '@prisma/debug@7.4.2': {} + '@prisma/debug@7.4.2': + optional: true '@prisma/dev@0.20.0(typescript@6.0.2)': dependencies: @@ -9266,8 +9310,10 @@ snapshots: zeptomatch: 2.1.0 transitivePeerDependencies: - typescript + optional: true - '@prisma/engines-version@7.5.0-10.94a226be1cf2967af2541cca5529f0f7ba866919': {} + '@prisma/engines-version@7.5.0-10.94a226be1cf2967af2541cca5529f0f7ba866919': + optional: true '@prisma/engines@7.4.2': dependencies: @@ -9275,28 +9321,34 @@ snapshots: '@prisma/engines-version': 7.5.0-10.94a226be1cf2967af2541cca5529f0f7ba866919 '@prisma/fetch-engine': 7.4.2 '@prisma/get-platform': 7.4.2 + optional: true '@prisma/fetch-engine@7.4.2': dependencies: '@prisma/debug': 7.4.2 '@prisma/engines-version': 7.5.0-10.94a226be1cf2967af2541cca5529f0f7ba866919 '@prisma/get-platform': 7.4.2 + optional: true '@prisma/get-platform@7.2.0': dependencies: '@prisma/debug': 7.2.0 + optional: true '@prisma/get-platform@7.4.2': dependencies: '@prisma/debug': 7.4.2 + optional: true - '@prisma/query-plan-executor@7.2.0': {} + '@prisma/query-plan-executor@7.2.0': + optional: true '@prisma/studio-core@0.13.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)': dependencies: '@types/react': 19.2.14 react: 19.2.3 react-dom: 19.2.4(react@19.2.3) + optional: true '@quansync/fs@1.0.0': dependencies: @@ -9740,6 +9792,7 @@ snapshots: '@types/react@19.2.14': dependencies: csstype: 3.2.3 + optional: true '@types/remove-markdown@0.3.4': {} @@ -10346,7 +10399,8 @@ snapshots: '@fastify/error': 4.2.0 fastq: 1.20.1 - aws-ssl-profiles@1.1.2: {} + aws-ssl-profiles@1.1.2: + optional: true axe-core@4.11.1: {} @@ -10389,28 +10443,28 @@ snapshots: bcryptjs@3.0.3: {} - better-auth@1.5.1(@cloudflare/workers-types@4.20260411.1)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(typescript@6.0.2))(typescript@6.0.2))(drizzle-orm@0.45.1(@cloudflare/workers-types@4.20260411.1)(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.0)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(typescript@6.0.2))(typescript@6.0.2))(kysely@0.28.11)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(typescript@6.0.2)))(mongodb@6.12.0)(mysql2@3.15.3)(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(typescript@6.0.2))(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(vitest@4.1.4): + better-auth@1.6.2(@cloudflare/workers-types@4.20260411.1)(@opentelemetry/api@1.9.0)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(typescript@6.0.2))(typescript@6.0.2))(drizzle-orm@0.45.1(@cloudflare/workers-types@4.20260411.1)(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.0)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(typescript@6.0.2))(typescript@6.0.2))(kysely@0.28.16)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(typescript@6.0.2)))(mongodb@6.12.0)(mysql2@3.15.3)(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(typescript@6.0.2))(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(vitest@4.1.4): dependencies: - '@better-auth/core': 1.5.1(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260411.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1) - '@better-auth/drizzle-adapter': 1.5.1(@better-auth/core@1.5.1(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260411.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(drizzle-orm@0.45.1(@cloudflare/workers-types@4.20260411.1)(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.0)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(typescript@6.0.2))(typescript@6.0.2))(kysely@0.28.11)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(typescript@6.0.2))) - '@better-auth/kysely-adapter': 1.5.1(@better-auth/core@1.5.1(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260411.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(kysely@0.28.11) - '@better-auth/memory-adapter': 1.5.1(@better-auth/core@1.5.1(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260411.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1) - '@better-auth/mongo-adapter': 1.5.1(@better-auth/core@1.5.1(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260411.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(mongodb@6.12.0) - '@better-auth/prisma-adapter': 1.5.1(@better-auth/core@1.5.1(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260411.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(typescript@6.0.2))(typescript@6.0.2))(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(typescript@6.0.2)) - '@better-auth/telemetry': 1.5.1(@better-auth/core@1.5.1(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260411.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1)) - '@better-auth/utils': 0.3.1 + '@better-auth/core': 1.6.2(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260411.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.1.3)(kysely@0.28.16)(nanostores@1.1.1) + '@better-auth/drizzle-adapter': 1.6.2(@better-auth/core@1.6.2(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260411.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.1.3)(kysely@0.28.16)(nanostores@1.1.1))(@better-auth/utils@0.4.0)(drizzle-orm@0.45.1(@cloudflare/workers-types@4.20260411.1)(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.0)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(typescript@6.0.2))(typescript@6.0.2))(kysely@0.28.16)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(typescript@6.0.2))) + '@better-auth/kysely-adapter': 1.6.2(@better-auth/core@1.6.2(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260411.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.1.3)(kysely@0.28.16)(nanostores@1.1.1))(@better-auth/utils@0.4.0)(kysely@0.28.16) + '@better-auth/memory-adapter': 1.6.2(@better-auth/core@1.6.2(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260411.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.1.3)(kysely@0.28.16)(nanostores@1.1.1))(@better-auth/utils@0.4.0) + '@better-auth/mongo-adapter': 1.6.2(@better-auth/core@1.6.2(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260411.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.1.3)(kysely@0.28.16)(nanostores@1.1.1))(@better-auth/utils@0.4.0)(mongodb@6.12.0) + '@better-auth/prisma-adapter': 1.6.2(@better-auth/core@1.6.2(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260411.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.1.3)(kysely@0.28.16)(nanostores@1.1.1))(@better-auth/utils@0.4.0)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(typescript@6.0.2))(typescript@6.0.2))(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(typescript@6.0.2)) + '@better-auth/telemetry': 1.6.2(@better-auth/core@1.6.2(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260411.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.1.3)(kysely@0.28.16)(nanostores@1.1.1))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21) + '@better-auth/utils': 0.4.0 '@better-fetch/fetch': 1.1.21 '@noble/ciphers': 2.1.1 '@noble/hashes': 2.0.1 - better-call: 1.3.2(zod@4.3.6) + better-call: 1.3.5(zod@4.3.6) defu: 6.1.4 jose: 6.1.3 - kysely: 0.28.11 + kysely: 0.28.16 nanostores: 1.1.1 zod: 4.3.6 optionalDependencies: '@prisma/client': 7.4.2(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(typescript@6.0.2))(typescript@6.0.2) - drizzle-orm: 0.45.1(@cloudflare/workers-types@4.20260411.1)(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.0)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(typescript@6.0.2))(typescript@6.0.2))(kysely@0.28.11)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(typescript@6.0.2)) + drizzle-orm: 0.45.1(@cloudflare/workers-types@4.20260411.1)(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.0)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(typescript@6.0.2))(typescript@6.0.2))(kysely@0.28.16)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(typescript@6.0.2)) mongodb: 6.12.0 mysql2: 3.15.3 prisma: 7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(typescript@6.0.2) @@ -10419,10 +10473,11 @@ snapshots: vitest: 4.1.4(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.4)(happy-dom@20.5.0)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.44.1)(yaml@2.8.2)) transitivePeerDependencies: - '@cloudflare/workers-types' + - '@opentelemetry/api' - better-call@1.3.2(zod@4.3.6): + better-call@1.3.5(zod@4.3.6): dependencies: - '@better-auth/utils': 0.3.1 + '@better-auth/utils': 0.4.0 '@better-fetch/fetch': 1.1.21 rou3: 0.7.12 set-cookie-parser: 3.0.1 @@ -10528,6 +10583,7 @@ snapshots: perfect-debounce: 1.0.0 pkg-types: 2.3.0 rc9: 2.1.2 + optional: true cac@7.0.0: {} @@ -10599,6 +10655,7 @@ snapshots: '@chevrotain/utils': 10.5.0 lodash: 4.17.21 regexp-to-ast: 0.5.0 + optional: true chokidar@4.0.3: dependencies: @@ -10613,8 +10670,10 @@ snapshots: citty@0.1.6: dependencies: consola: 3.4.2 + optional: true - citty@0.2.1: {} + citty@0.2.1: + optional: true class-transformer@0.5.1: optional: true @@ -10691,7 +10750,8 @@ snapshots: concat-map@0.0.1: {} - confbox@0.2.4: {} + confbox@0.2.4: + optional: true consola@3.4.2: {} @@ -10783,7 +10843,8 @@ snapshots: cssom@0.5.0: {} - csstype@3.2.3: {} + csstype@3.2.3: + optional: true damerau-levenshtein@1.0.8: {} @@ -10821,7 +10882,8 @@ snapshots: deep-is@0.1.4: {} - deepmerge-ts@7.1.5: {} + deepmerge-ts@7.1.5: + optional: true deepmerge@4.3.1: {} @@ -10853,7 +10915,8 @@ snapshots: dequal@2.0.3: {} - destr@2.0.5: {} + destr@2.0.5: + optional: true detect-europe-js@0.1.2: {} @@ -10895,16 +10958,17 @@ snapshots: dotenv@17.4.1: {} - drizzle-orm@0.45.1(@cloudflare/workers-types@4.20260411.1)(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.0)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(typescript@6.0.2))(typescript@6.0.2))(kysely@0.28.11)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(typescript@6.0.2)): + drizzle-orm@0.45.1(@cloudflare/workers-types@4.20260411.1)(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.0)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(typescript@6.0.2))(typescript@6.0.2))(kysely@0.28.16)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(typescript@6.0.2)): optionalDependencies: '@cloudflare/workers-types': 4.20260411.1 '@electric-sql/pglite': 0.3.15 '@opentelemetry/api': 1.9.0 '@prisma/client': 7.4.2(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(typescript@6.0.2))(typescript@6.0.2) - kysely: 0.28.11 + kysely: 0.28.16 mysql2: 3.15.3 postgres: 3.4.7 prisma: 7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(typescript@6.0.2) + optional: true dts-resolver@2.1.3: {} @@ -10926,6 +10990,7 @@ snapshots: dependencies: '@standard-schema/spec': 1.1.0 fast-check: 3.23.2 + optional: true ejs@5.0.1: {} @@ -11571,7 +11636,8 @@ snapshots: transitivePeerDependencies: - supports-color - exsolve@1.0.8: {} + exsolve@1.0.8: + optional: true ext-list@2.2.2: dependencies: @@ -11595,6 +11661,7 @@ snapshots: fast-check@3.23.2: dependencies: pure-rand: 6.1.0 + optional: true fast-decode-uri-component@1.0.1: {} @@ -11840,6 +11907,7 @@ snapshots: generate-function@2.3.1: dependencies: is-property: 1.0.2 + optional: true generator-function@2.0.1: {} @@ -11860,7 +11928,8 @@ snapshots: hasown: 2.0.2 math-intrinsics: 1.1.0 - get-port-please@3.2.0: {} + get-port-please@3.2.0: + optional: true get-port@5.1.1: {} @@ -11902,6 +11971,7 @@ snapshots: node-fetch-native: 1.6.7 nypm: 0.6.5 pathe: 2.0.3 + optional: true glob-parent@5.1.2: dependencies: @@ -11967,9 +12037,11 @@ snapshots: graceful-fs@4.2.11: {} - grammex@3.1.12: {} + grammex@3.1.12: + optional: true - graphmatch@1.1.1: {} + graphmatch@1.1.1: + optional: true happy-dom@20.5.0: dependencies: @@ -12015,7 +12087,8 @@ snapshots: dependencies: hermes-estree: 0.25.1 - hono@4.11.4: {} + hono@4.11.4: + optional: true hookable@6.1.0: {} @@ -12059,7 +12132,8 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 - http-status-codes@2.3.0: {} + http-status-codes@2.3.0: + optional: true http2-wrapper@2.2.1: dependencies: @@ -12254,7 +12328,8 @@ snapshots: is-promise@4.0.0: {} - is-property@1.0.2: {} + is-property@1.0.2: + optional: true is-regex@1.2.1: dependencies: @@ -12360,7 +12435,8 @@ snapshots: merge-stream: 2.0.0 supports-color: 8.1.1 - jiti@2.6.1: {} + jiti@2.6.1: + optional: true jose@6.1.3: {} @@ -12463,7 +12539,7 @@ snapshots: kleur@4.1.5: {} - kysely@0.28.11: {} + kysely@0.28.16: {} language-subtag-registry@0.3.23: {} @@ -12543,7 +12619,8 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 - lilconfig@2.1.0: {} + lilconfig@2.1.0: + optional: true lines-and-columns@1.2.4: {} @@ -12609,7 +12686,8 @@ snapshots: lodash.once@4.1.1: {} - lodash@4.17.21: {} + lodash@4.17.21: + optional: true lodash@4.17.23: {} @@ -12628,7 +12706,8 @@ snapshots: loglevel@1.9.2: {} - long@5.3.2: {} + long@5.3.2: + optional: true loose-envify@1.4.0: dependencies: @@ -12644,7 +12723,8 @@ snapshots: dependencies: yallist: 3.1.1 - lru.min@1.1.4: {} + lru.min@1.1.4: + optional: true luxon@3.6.1: {} @@ -12895,10 +12975,12 @@ snapshots: named-placeholders: 1.1.6 seq-queue: 0.0.5 sqlstring: 2.3.3 + optional: true named-placeholders@1.1.6: dependencies: lru.min: 1.1.4 + optional: true nanoid@3.3.11: {} @@ -12937,7 +13019,8 @@ snapshots: dependencies: lodash: 4.17.23 - node-fetch-native@1.6.7: {} + node-fetch-native@1.6.7: + optional: true node-fetch@1.7.3: dependencies: @@ -12972,6 +13055,7 @@ snapshots: citty: 0.2.1 pathe: 2.0.3 tinyexec: 1.0.4 + optional: true object-assign@4.1.1: {} @@ -13015,7 +13099,8 @@ snapshots: obug@2.1.1: {} - ohash@2.0.11: {} + ohash@2.0.11: + optional: true on-finished@2.4.1: dependencies: @@ -13182,7 +13267,8 @@ snapshots: pend@1.2.0: {} - perfect-debounce@1.0.0: {} + perfect-debounce@1.0.0: + optional: true picocolors@1.1.1: {} @@ -13205,6 +13291,7 @@ snapshots: confbox: 0.2.4 exsolve: 1.0.8 pathe: 2.0.3 + optional: true pluralize@8.0.0: {} @@ -13218,7 +13305,8 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 - postgres@3.4.7: {} + postgres@3.4.7: + optional: true prelude-ls@1.2.1: {} @@ -13263,6 +13351,7 @@ snapshots: - magicast - react - react-dom + optional: true process-nextick-args@2.0.1: {} @@ -13281,6 +13370,7 @@ snapshots: graceful-fs: 4.2.11 retry: 0.12.0 signal-exit: 3.0.7 + optional: true proxy-addr@2.0.7: dependencies: @@ -13298,7 +13388,8 @@ snapshots: punycode@2.3.1: {} - pure-rand@6.1.0: {} + pure-rand@6.1.0: + optional: true pvtsutils@1.3.6: dependencies: @@ -13331,11 +13422,13 @@ snapshots: dependencies: defu: 6.1.4 destr: 2.0.5 + optional: true react-dom@19.2.4(react@19.2.3): dependencies: react: 19.2.3 scheduler: 0.27.0 + optional: true react-is@16.13.1: {} @@ -13344,7 +13437,8 @@ snapshots: fast-deep-equal: 2.0.1 optional: true - react@19.2.3: {} + react@19.2.3: + optional: true readable-stream@2.3.8: dependencies: @@ -13411,7 +13505,8 @@ snapshots: '@eslint-community/regexpp': 4.12.2 refa: 0.12.1 - regexp-to-ast@0.5.0: {} + regexp-to-ast@0.5.0: + optional: true regexp-tree@0.1.27: {} @@ -13428,7 +13523,8 @@ snapshots: dependencies: jsesc: 3.1.0 - remeda@2.33.4: {} + remeda@2.33.4: + optional: true remove-markdown@0.6.3: {} @@ -13471,7 +13567,8 @@ snapshots: ret@0.5.0: {} - retry@0.12.0: {} + retry@0.12.0: + optional: true reusify@1.1.0: {} @@ -13612,7 +13709,8 @@ snapshots: safer-buffer@2.1.2: {} - scheduler@0.27.0: {} + scheduler@0.27.0: + optional: true schema-utils@3.3.0: dependencies: @@ -13668,7 +13766,8 @@ snapshots: transitivePeerDependencies: - supports-color - seq-queue@0.0.5: {} + seq-queue@0.0.5: + optional: true serve-static@2.2.1: dependencies: @@ -13870,7 +13969,8 @@ snapshots: dependencies: memory-pager: 1.5.0 - sqlstring@2.3.3: {} + sqlstring@2.3.3: + optional: true stable-hash-x@0.2.0: {} @@ -14399,6 +14499,7 @@ snapshots: valibot@1.2.0(typescript@6.0.2): optionalDependencies: typescript: 6.0.2 + optional: true validator@13.15.26: optional: true @@ -14711,6 +14812,7 @@ snapshots: dependencies: grammex: 3.1.12 graphmatch: 1.1.1 + optional: true zod-validation-error@3.5.4(zod@3.24.2): dependencies: