Skip to content

refactor(api): V2 response envelope, snake_case schema, named views#2729

Merged
Innei merged 47 commits into
masterfrom
refactor/v2-api-response
May 22, 2026
Merged

refactor(api): V2 response envelope, snake_case schema, named views#2729
Innei merged 47 commits into
masterfrom
refactor/v2-api-response

Conversation

@Innei

@Innei Innei commented May 15, 2026

Copy link
Copy Markdown
Member

Summary

Breaking 4-phase refactor of the mx-core HTTP response layer per docs/superpowers/specs/2026-05-15-v2-api-response-design.md.

Every successful JSON response is now { data, meta? }; every error is { error: { code, message, details? } }.

Phases

  • Phase 0 — envelope infrastructure. src/common/response/* (envelope/meta/error types, MetaObjectBuilder, ResponseInterceptorV2, AppExceptionFilter, @RawResponse), src/common/views/view.types.ts (parseView), createPagerSchema factory.
  • Phase 1 — snake_case at the schema layer. Drizzle column TS prop names renamed to snake_case across packages/db-schema and rippled through ~32 repositories. The 6 Better Auth tables (readers, accounts, sessions, apiKeys, passkeys, verifications) keep camelCase props for drizzleAdapter compatibility.
  • Phase 2 — per-module migration. All ~45 modules migrated to the { data, meta? } envelope, named *.views.ts Zod views, MetaObjectBuilder, and AppException subclasses.
  • Phase 3 — cleanup. Deleted JSONTransformInterceptor, legacy ResponseInterceptor, translation-entry.interceptor, @TranslateFields, and src/utils/case.util.ts (with its snakeCaseKeys helper); removed the Bypass alias; generic exceptions migrated to AppException; ResponseInterceptorV2 + AppExceptionFilter wired as global APP_INTERCEPTOR / APP_FILTER.

Breaking changes

  • Response envelope. Every JSON endpoint now emits { data, meta? } on success and { error: { code, message, details? } } on failure. Existing V1 consumers must either upgrade to V2-aware client code or pin @mx-space/api-client to the version that ships the legacy response adapter (this PR includes packages/api-client/legacy/response-adapter.ts covering the in-tree consumers).
  • Wire format is snake_case. created_at, is_published, category_id, etc. The Drizzle TS code remains camelCase; ResponseInterceptorV2 converts at the wire boundary.
  • Pager query fields removed. V2 endpoints route through createPagerSchema and drop the following V1 query parameters with no direct replacement:
    • select — use named *.views.ts Zod views (e.g. ?view=card) instead.
    • state — query by domain-specific filters (e.g. ?is_published=…) instead.
    • db_query — removed permanently; raw query passthrough will not return.
    • sortBy / sortOrder — renamed to sort_by / sort_order on the wire.
  • Error codes. Generic plumbing (HTTP_ERROR, VALIDATION_FAILED, RATE_LIMITED, INTERNAL_ERROR) plus per-domain SCREAMING_SNAKE codes via AppErrorCode. Existing numeric ErrorCodeEnum values still flow through BizException and surface as their string names on the wire.

Notable deviations

  • Better Auth tables excluded from the snake_case rename (renaming their Drizzle props breaks drizzleAdapter) — a design-spec gap resolved here.
  • AppErrorCode (new, SCREAMING_SNAKE strings) and legacy ErrorCodeEnum (numeric, used by BizException) coexist by design — generic plumbing moved to AppException, business codes still go through BizException. A follow-up issue can unify them.

Review fixes folded into this PR

  • note.controller.ts /notes/list/:id empty-fallback double-wrap. The no-currentDocument branch returned { data: [], size: 0 }, which ResponseInterceptorV2 wraps to { data: { data: [], size: 0 } } (Symbol-marked envelopes only, by design). Replaced with withMeta([], builder.build()). The contract test for Array.isArray(body.data) would have caught this had the fixture exercised the empty path.
  • Envelope linter widened. scripts/check-controller-response-envelope.ts now flags any controller return literal whose top-level keys include data (previously only { data } and { data, meta } were flagged).
  • CLAUDE.md envelope description corrected. The pass-through rule now reflects the actual Symbol-based detection, and explicitly warns that a literal { data, ... } will be double-wrapped.
  • snakeKey boundary-aware. articleURL -> article_url, HTMLContent -> html_content (was _a_r_t_i_c_l_e__u_r_l). Added a unit test for acronym boundaries.
  • AppExceptionFilter deduped. Triple status >= 500 block factored into logServerError(exception); throttle handling into handleThrottle(ip, url).
  • @BypassCaseTransform JSDoc spells out that the matched subtree is emitted verbatim regardless of depth.
  • PagerDto deprecation expanded to enumerate the V1 fields dropped in V2 (select, state, db_query, sortBy/sortOrder rename).

Verification

208 files changed, +6125 / -3381. Typecheck clean, lint clean, full Vitest suite 1088 passed / 3 skipped / 0 failed.

@safedep

safedep Bot commented May 15, 2026

Copy link
Copy Markdown

⚠️ Scan Failed: Pull Request Too Large

This pull request exceeds GitHub's diff limits and cannot be scanned.

GitHub Limits:

  • Maximum 300 files per diff
  • Maximum 1 MB total diff size

Recommendations:

  • Split this PR into smaller, focused changes
  • Review critical dependency changes manually
  • Contact your team if this is blocking your workflow
    This report is generated by SafeDep Github App

Innei added a commit that referenced this pull request May 20, 2026
Squash PR #2729 into one commit before rebasing onto latest master.
@Innei Innei force-pushed the refactor/v2-api-response branch from da6a054 to 87e6381 Compare May 20, 2026 14:24
Innei added a commit that referenced this pull request May 20, 2026
Squash PR #2729 into one commit before rebasing onto latest master.
@Innei Innei force-pushed the refactor/v2-api-response branch from 87e6381 to fa634ec Compare May 20, 2026 14:39
Innei added a commit that referenced this pull request May 20, 2026
Squash PR #2729 into one commit before rebasing onto latest master.
@Innei Innei force-pushed the refactor/v2-api-response branch from fa634ec to aefbbf1 Compare May 20, 2026 14:52
Innei added a commit that referenced this pull request May 20, 2026
Squash PR #2729 into one commit before rebasing onto latest master.
@Innei Innei force-pushed the refactor/v2-api-response branch from aefbbf1 to 9d42b19 Compare May 20, 2026 16:24
Squash PR #2729 into one commit before rebasing onto latest master.
@Innei Innei force-pushed the refactor/v2-api-response branch from 9d42b19 to 5543aa4 Compare May 20, 2026 17:42
// runs camelcaseKeys on the raw snake_case wire payload before it reaches this
// adapter. So `is_liked` arrives as `isLiked`, `source_lang` as `sourceLang`, etc.

const stripPath = (p: string) => p.split('?')[0].replace(/\/+$/, '') || '/'

const NEW_PORT = process.env.NEW_PORT ?? '2333'
const OLD_PORT = process.env.OLD_PORT ?? '3333'
const filter = process.argv[2] ? new RegExp(process.argv[2], 'i') : null
Innei added 2 commits May 21, 2026 20:39
Previously post/page/note detail handlers only attached translation meta
when the article had actually been translated. V1 consumers expect the
default fields (is_translated, source_lang, available_translations) to
always be present so they can render the language picker / source lang
badge without conditional checks.

Also widen ArticleTranslationSchema.source_lang and target_lang to allow
null so PostgreSQL rows that lack a language column still validate.
The legacy adapter now reconstructs the V1 response shape from V2 raw
payloads on a per-endpoint basis:

- Flattens meta.{translation,interaction,insights,enrichments,related}
  back into items (camelCase, matching default transformResponse output)
- Renames pagination fields (page/totalPages → currentPage/totalPage)
  and synthesizes hasNextPage/hasPrevPage
- Wraps NoteModel detail responses as { data, next, prev } again
- Hoists comment.getByRefId readers/pagination back to the top level
- Strips text/content from aggregate.getTop list items (V2 server omits
  bodies for SSR payload size; V1 didn't, this aligns at the adapter)
- Restores singleFileSizeMb (V2 snake-case emits single_file_size_m_b
  which camelcase upper-cases to singleFileSizeMB)
- Other endpoint-specific tweaks: comment threads, note middle list,
  note topic list, activity presence

Also fix the fetch adaptor losing the Response status getter when
shallow-copying via Object.assign, which caused error handlers to fall
back to status 500 instead of the real HTTP code.

Adds packages/api-client/scripts/smoke-diff.mjs — a parity harness that
hits both ends of a V2/V1 setup with the legacy adapter applied and
deep-diffs the normalized payloads. Used to drive this fix to 0 diffs
across Yohaku's 52-endpoint call surface.

bump @mx-space/api-client to v5.0.2-next.3
@Innei Innei force-pushed the refactor/v2-api-response branch from f7944bc to 9b2363f Compare May 21, 2026 12:39
…ith BasicPagerDto/createPagerSchema

Removes the legacy BizException / BusinessException / ErrorCodeEnum +
CannotFindException / BanInDemoExcpetion / NoContentCanBeModifiedException
dual-track in favor of a single AppErrorCode enum, AppErrorPayloadMap, and
createAppException factory. All 50+ throw sites and 13 test files migrated.
~/common/exceptions/* and ~/constants/error-code.constant.ts are deleted.

Removes the legacy PagerSchema / PagerDto / PagerInput (with V1 select /
state / db_query / camelCase sort fields). The new pager API exposes
BasicPagerSchema/BasicPagerDto/BasicPagerInput for sort-less endpoints
and createPagerSchema(sortKeys) for sortable endpoints; 27 controllers
and schemas migrated. sort_by/sort_order are snake_case on the wire and
typed via z.enum(sortKeys).

Also folds in the earlier ultrareview fixes:
- note.controller.ts /notes/list/:id empty-fallback double-wrap fix
- check-controller-response-envelope.ts now flags any literal whose top
  keys include `data` (catches the bug above)
- CLAUDE.md envelope description corrected (Symbol-based detection)
- snakeKey boundary-aware (articleURL -> article_url, HTMLContent ->
  html_content)
- AppExceptionFilter deduped (logServerError, handleThrottle helpers);
  log/Bark/throttle copy in English
- BypassCaseTransform JSDoc spells out subtree verbatim semantics

All AppErrorCode messages are in English. ENRICHMENT_BROWSER_MODE_REQUIRED
and ENRICHMENT_SCREENSHOT_DISABLED return 409 to match contract tests.

Verification: tsc --noEmit clean, vitest 1163 passed / 0 failed / 3 pending.
@Get('/:type')
@Auth()
async getTypes(@Query() query: PagerDto, @Param() params: FileUploadDto) {
async getTypes(
Innei added 5 commits May 21, 2026 22:53
- file: add CommentUploadsListQueryDto so /comment-uploads/list coerces
  flat ?page=&size= query into numbers; raw @query() previously echoed
  strings into withMeta(...).pagination(...) and tripped ResponseMetaSchema
- markdown: cast meta.slug to string in generateArchive — notes set
  meta.slug to nid (number) and .concat threw a TypeError on export
- say: expose missing GET/:id, POST, PUT/:id, DELETE/:id routes;
  repository already had the methods, controller now wires them so
  admin's manage-says CRUD works
- errors: NOT_FOUND now carries an optional `id` detail to match the
  per-resource error payloads (used by the new say routes)
- api-client: probe both axios-style and ofetch-style envelopes when
  extracting body + $meta; covered by new test
- AggregateController.getSiteMetadata / getAggregateData / getTop now
  accept a CacheableOptions arg ({ next, cache }) so server callers can
  pass Next.js cache hints without dropping to .proxy.* raw path access.
- getAggregateData also accepts `lang` to push the locale into the
  query string (previously consumers had to plumb it through
  ofetch-level langStorage or .proxy.get({ params: { lang } })).
- RequestOptions exposes typed `next?: NextFetchRequestConfig` and
  `cache?: RequestCache` fields, declared structurally so the SDK does
  not pull `next` as a dependency. Adapters that don't recognise them
  (axios, umi) silently drop the keys.
Innei added 28 commits May 22, 2026 02:07
Single-file shim (`src/domain/envelope-compat.ts`) that normalizes wire bodies
to the V3 shape before reaching schema decoding, error mapping, and renderer
views. Lets the CLI work against either a V2 or V3 mx-core deployment.

- `normalizeSuccessBody`: lifts V2 root `pagination` under `meta.pagination`.
- `normalizeErrorBody`: unwraps V3 `{ error: { code, message } }` and flattens
  V2 `{ message: string | string[], code? }` to one tuple.
- `mapHttpStatusToError`: prefers V3 SCREAMING_SNAKE codes
  (`<RESOURCE>_NOT_FOUND`, `VALIDATION_FAILED`) over status-derived tags when
  present, so `POST_NOT_FOUND` correctly maps to `ResourceNotFound`.
- `--verbose` prints the detected wire version once per `ApiService`.

View cleanup: `post`/`comment` list renderers now read `meta.pagination` only;
the V2 root-`pagination` read path is dead post-adapter.

Removal is grep-driven via `// COMPAT:envelope` markers and a deletion
checklist in `envelope-compat.ts`'s JSDoc. Design doc:
`packages/cli/docs/specs/2026-05-21-v2-v3-envelope-compat-layer.md`.

Tests: 22 new unit tests for the adapter, V3-fixture branches added to
`cli-post-list` and `cli-error-envelope` integration suites. Full suite:
637/637 passing.
Align response meta with the camelCase-internal / snake_case-wire
convention enforced by `ResponseInterceptorV2`. Snake_case keys
(`total_pages`, `is_translated`, `source_lang`, `target_lang`,
`content_format`, `available_translations`, `is_liked`, `like_count`,
`read_count`, `has_in_locale`) leaked into Zod schemas, the meta
builder, the translation helper, and every controller call site —
forcing manual snake_case in business code and double-converting
through the interceptor.

- meta.types: schemas now declare camelCase keys
- meta-builder: drop `total_pages` alias on LegacyPaginationLike
- helper.translation: buildArticleTranslationMeta emits camelCase
- 12 controllers (note, post, page, draft, reader, webhook, say, file,
  snippet, topic, project, category, aggregate): builder call sites
  switched to camelCase
- unit specs updated; wire shape unchanged (contract specs pass)
- drop dead dep snakecase-keys
Translate all Chinese in comments, logger calls, error messages,
thrown exceptions, Zod descriptions, email subjects/bodies, and
other user-facing strings across apps/core/src.

Preserved (intentional):
- AI prompt example fixtures in modules/ai/ai.prompts.ts
- i18n locale data (LABELS.zh/ja) in mx-space enrichment provider
- Spam-detection dictionaries (block-keywords.json, meaningless-words.json)
- Test fixtures
ResponseInterceptorV2 handles camelCase→snake_case conversion at the
wire boundary. Controllers were duplicating that work with hand-rolled
key renames and migration-era `?? c.snake_case` fallbacks. Removed
across aggregate/comment/enrichment/note/subscribe controllers; wire
shape is unchanged because the interceptor still emits snake_case.
Signed-off-by: Innei <tukon479@gmail.com>
…anslation meta

Collapse repeated translateArticleList + isTranslated filter + EntryTranslation
map construction boilerplate from five controllers (post, page, note, category,
aggregate) into a single helper on TranslationService. Drops three `as any`
casts in the meta path; pulls sourceLang from translationMeta so it is
actually populated.
Flatten common/response/ to wire-format core only (envelope.types,
meta.types, meta-builder, case-transform). Move out NestJS-typed
files to their matching common/ subdirectory so the layout matches
the rest of common/:

  - bypass-case-transform.decorator.ts → common/decorators/
  - raw-response.decorator.ts → common/decorators/
  - error.types.ts → common/errors/exception.types.ts
  - app-exception.filter.ts → common/filters/
  - response.interceptor.ts → common/interceptors/

Updates 54 importers across src and test, plus CLAUDE.md path
references.
…asses it through

The legacy adapter's transformLegacyData assumed defaultGetDataFromResponse
had already peeled the V3 `{ data, meta }` envelope, so per-endpoint rules
read fields off the inner object. When a consumer ships an identity
getDataFromResponse (common with ofetch stacks that already deliver the
parsed body), the envelope survives and rules like aggregateTopRule look
for `notes`/`posts` on the wrapper, find nothing, and emit an output
shaped like the envelope instead of the inner data. Apps using getTop(5)
then crash with `notes is not iterable`.

destructureData only collapses single-key `{ data }` wrappers, so the
two-key `{ data, meta }` envelope slipped through whenever meta was
populated (e.g. `/aggregate/top?lang=en` returning a translation map).

Strip the V3 envelope explicitly at the top of transformLegacyData so
rules see the same inner shape regardless of how the consumer hands the
payload over. Adds a regression test that drives the adapter with an
identity getDataFromResponse to lock the behavior in.
Restores V1 behaviour where ?lang=xx produces a response with translated
content directly in `data` (title/text/content/topic.name/mood/weather
etc.), with `meta.translation.<id>.article` reduced to slim metadata
(is_translated/source_lang/target_lang/translated_at/model/
available_translations) and no duplicated content fields.
Adopt all codex review findings: strict ArticleTranslationSchema, missing
V1 endpoints (/notes/:id, /notes/list/:id, /posts/:id, /pages/:id,
/search/:type, /activity/last-year/publication, /aggregate/stat/top-articles),
/pages/slug/:slug rename, builder reads translatedAt/model from
translationMeta, paired content/contentFormat overwrite, lookup-group
wording, mandatory translation-before-enrichment pipeline order,
translated-items-only meta policy on list endpoints, refId-keyed
activity reading rule for legacy adapter, smoke-diff script declaration,
existing adapter test updates, category /:query tag=true branch row.

Keep /notes/topics/:id topic.* coverage as an intentional expansion
beyond V1.
- List envelope example now omits an untranslated item, matching the
  "translated items only" policy.
- Cached-title meta paragraph rewritten: sub-entity entries never emit
  meta; article-row title translations may emit slim metadata when
  available.
- Pseudocode `if (translatedContent)` → `if (translatedContent != null)`
  so empty-string translated bodies are still considered overwrites.
- Post detail table column renamed to "Overwritten in response"; moved
  related[].title into the meta.related lane explicitly.
- "9 affected controllers" → "8 affected controllers" with names listed.
- New "Nested-array adapter rules" section closes the V1-wire parity
  gap on aggregate / activity / category nested lists.
Drop translated-content fields (title/text/subtitle/summary/tags/content/
contentFormat) from meta.translation.<id>.article — those values now
overwrite their counterparts in `data` in place per the in-place
overwrite design. ArticleTranslationSchema becomes .strict() so any
controller still emitting the removed fields fails fast at
MetaObjectBuilder.build() rather than silently stripping the keys.

Drop EntryTranslationSchema.fields — entry-driven translations also
overwrite data in place.

Tests assert that supplying any removed field causes
ArticleTranslationSchema.safeParse() to return success: false (not a
stripped result).
buildArticleTranslationMeta now returns only slim metadata
(isTranslated/sourceLang/targetLang/translatedAt/model/availableTranslations)
matching the slim ArticleTranslationSchema. translatedAt/model/targetLang
are read from result.translationMeta.* with targetLang falling back to
the lang argument.

Add applyArticleTranslationInPlace that overwrites article body fields
on the target object in place — uses nullish checks so empty-string
overwrites apply, and pairs content with contentFormat so the latter
never flips without the former.

Add applyTranslationEntriesInPlace for dotted-path overwrites driven by
entityMaps (lookup by an idField on the parent) or dictMaps (lookup by
the current source value). Powers note mood/weather, topic.*, and
category.name translations.

collectArticleTranslations now returns { results, meta }: results is the
per-id TranslationResult for downstream applyArticleTranslationInPlace,
meta is the slim metadata map suitable for MetaObjectBuilder.translation.

Remove getTopicTranslationFields — its meta.translation.<id>.fields
output no longer exists; callers will switch to
translationEntryService.getTranslationsBatch + applyTranslationEntriesInPlace.

Fix a latent sourceLang propagation bug in translateArticleList where
the value was nested in translationMeta but not surfaced at the result
top level.
…ules

Slim buildTranslationFlat: drop the article-or-translation fallback,
remove fields handling, and reduce translationMeta to sourceLang/
targetLang/translatedAt/model — translated display fields (title/text/
content/...) now arrive inline in data, so V1-shape consumers no longer
read them from translationMeta.

Add flattenNestedArrays helper and dedicated rules for endpoints that
return item arrays at non-data keys: aggregate /latest /timeline,
activity /rooms /recent /reading/{top,rank} /last-year/publication, and
aggregate /top (composed with the existing body-strip behaviour). Each
rule flattens meta.translation entries onto the items so V1 consumers
keep getting translationMeta per item.

readingRankRule synthesizes id from refId before flatten and strips it
after, so refId-keyed items match meta.translation entries.

Declare smoke:diff npm script (the script file already existed but had
no package entry). Bump version to 5.0.2-next.9.

Update existing adapter tests to assert the slim translationMeta and
that translated display values live in data; add new tests for
activity/rooms and activity/reading/top.
note.controller: every detail/list endpoint now overwrites article body
fields, mood/weather (dict), and topic.{name,introduce,description}
(entity) directly on data — including next/prev adjacents — and emits
only the slim translation metadata in meta.translation. One batched
TranslationEntryService.getTranslationsBatch call per request covers
all topic entity ids and mood/weather source values across current +
next + prev or all list items. Translation runs before
enrichmentService.attachEnrichments so link cards extract from the
translated body.

aggregate.controller: /top, /latest (combined + separate), /timeline,
/stat/top-articles now use collectArticleTranslations + in-place
applyArticleTranslationInPlace per item, plus a single
getTranslationsBatch for dict (note.mood/weather) and entity
(posts[].category.name on timeline). Translated-items-only meta.

Test infrastructure: translation.mock now returns {results, meta} from
collectArticleTranslations and exposes translationEntryProvider for
controllers that inject TranslationEntryService. Several contract spec
files register translationEntryProvider.

C1, C3, C4, C5, C6, C8 controllers (post/page/topic/category/search/
activity) follow in a subsequent commit; their work in the parallel
dispatch did not persist to the main worktree and is being redone.
Apply the in-place article + entry translation pattern to post, page,
topic, category, search, and activity. Each affected endpoint:

- Translates article body fields (title/text/content/contentFormat/
  subtitle/summary/tags) in place on data, never duplicating them into
  meta.
- Translates sub-entity fields (topic.{name,introduce,description},
  category.name, note.mood/weather) in place via a single batched
  TranslationEntryService.getTranslationsBatch call per request.
- Emits meta.translation.<id>.article in slim metadata form
  (isTranslated/sourceLang/targetLang/translatedAt/model/
  availableTranslations) for detail endpoints (always) or for
  translated items only (lists/aggregate/activity).
- Runs all translation steps before enrichmentService.attachEnrichments
  so URL extraction sees the translated body.

post: getById (auth detail) gains @lang() + the full detail pipeline;
getByCateAndSlug + getPaginate batch category.name entity lookups
alongside article translation.

page: getPageBySlug + getById + getPagesSummary translate
title/text/subtitle/content/contentFormat in place.

topic: /all + /:id + /slug/:slug overwrite name/introduce/description
via entity entries; no meta.translation emission. TopicModule gains
forwardRef(AiModule) for TranslationEntryService DI.

category: GET / (by-type + ids branch), GET /:query regular branch, and
the tag=true branch each handle category.name + child post titles.
CategoryModule gains forwardRef(AiModule).

search: GET / and GET /:type translate data[].category.name on post
items only; no meta.translation emission.

activity: /rooms flattens all object-type buckets into one translation
batch; /reading/{top,rank} use refId as the meta key (matching the new
legacy adapter readingRankRule); /recent and /last-year/publication
aggregate translation across like/comment/post/note buckets and emit
slim per-item meta.

This consolidates the controller portion of the V3 in-place translation
redesign (commits 553f91e, c6eb940, d17bde5, 73e7ae6 build the
foundation; this commit completes the rest).
AggregateController now injects TranslationEntryService; AggregateModule
imports AiModule via forwardRef so DI resolves at boot.

CategoryController's Promise.resolve fallback for getTranslationsBatch
now types itself as EntryMaps so TranslationEntryKeyPath flows through
correctly.
The note/post/page views' readable + llm modes had a source_lang and
translated row that has been silently empty since the V3 API envelope
moved translation metadata out of the document body and into
meta.translation.<id>.article. Restore parity by introducing
pickArticleTranslationMeta in Renderer/content.ts and threading the
article meta into collectFields as a fallback source.

Applies to:
- mxs note view (detail readable + llm)
- mxs post view (detail readable + llm) and post list readable
- mxs page view (detail readable + llm)

The XML envelope mode is unchanged — it never emitted these fields.

Tests cover all four view paths plus the per-row list fallback.
Merge the standalone RawResponse decorator into the HTTPDecorators
object alongside Idempotence/SkipLogging. Drops raw-response.decorator.ts
and rewrites 23 controllers to use @HTTPDecorators.RawResponse.
Drops redundant `const X = await this.service.foo(); return X` blocks
in favor of `return this.service.foo()` across 17 controllers, and
strips async on the now-await-free one-liners.
Catches the cases missed by the first sweep where the awaited call
spans multiple lines (analyze, draft, enrichment, ai-insights,
ai-summary, translation-entry).
Adds a global RequestCaseNormalizationPipe that runs ahead of the zod
validation pipe and folds incoming query/param keys (deep) and body
keys (top-level only, to preserve freeform JSON like meta/socialIds/AI
messages) to camelCase. Both ?sort_by= and ?sortBy= now reach the
controller as sortBy, restoring camelCase end-to-end inside core
without losing snake_case wire ergonomics.

- New: src/common/request/{case-transform,case-normalization.pipe}.ts
- Reverts snake_case keys in pager/draft/file/activity/markdown schemas
- Updates post/note/file/activity/markdown controllers to camelCase
  destructuring (drops the sort_by: sortBy aliasing)
- Wires the pipe into bootstrap and the e2e test harness
- Tests: unit (camelKey, pipe) + e2e (snake/camel query parity, body
  freeform preservation)
Per project convention, organize ~/common by NestJS artifact type
(decorators/filters/guards/interceptors/middlewares/pipes) rather than
domain (request/response). Drops common/request/ in favor of the
existing pipes layout.
sortOrder was a hard z.enum(['asc','desc']) after the V2 refactor, so
existing clients still sending ?sortOrder=1 / -1 (the pre-V2 wire form)
got a 422. The case-normalization pipe handles key casing, not value
shapes — value-level compat needs its own preprocess.

Reshape zSortOrder in ~/common/zod/primitives.ts to coerce the legacy
numeric/string forms to 'asc' / 'desc', keep core typed as the enum,
and route createPagerSchema + DraftPagerSchema through it. Adds
parametrized tests covering all four wire shapes plus the rejection
case.
…e into envelope

generic Array path no-pagination case used to return a bare array, dropping
the V3 `{data:[...]}` envelope. Callers throughout host apps destructure
`.data` off list responses (matching the controller method's typed return),
so the bare-array shape made `.data` undefined at runtime — crashed
useInfiniteQuery getNextPageParam on /thinking, broke /timeline data, etc.

Add an /aggregate/timeline rule that re-wraps `{posts,notes}` into envelope
to match the typed `getTimeline` return as well.
V3 backends only accept `createdAt`/`modifiedAt` as `sortBy` enum values;
V1 callers still pass `created`/`modified`. The legacy client now
intercepts request envelopes (covering GET via attach-request's $$get
and POST/PUT/PATCH/DELETE via the same boundary) and rewrites these
aliases before the host adapter sends them — keeping host code untouched
during migration.

Also bumps version to 5.0.2-next.10.
…ware

When `x-skip-translation: 1` is set, restrict the lang-resolution chain to the explicit `x-lang` header only. Implicit sources (NEXT_LOCALE cookie, Accept-Language) are skipped so admin callers, which always send the flag, never receive auto-translated responses driven by the browser locale. Non-admin callers are unaffected.
@Innei Innei merged commit f84b946 into master May 22, 2026
7 of 10 checks passed
Innei added a commit that referenced this pull request May 26, 2026
V2 server returns `{ presence, readers }` (renamed from V1 `{ data, readers }`
in #2729) but the client still read `payload.data`, producing an empty map.
Also drop the outer `...camelcaseKeys(spread)` which deep-recursed into the
presence subtree and silently mangled identity keys like `owner_xyz` → `ownerXyz`.

Tests updated to the new envelope shape.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants