entity_details JSON integrity
The bug
Every business keeps a business_details row in the entity_details KV table. Its details column is declared as text('details', { mode: 'json' }) (packages/db/db/entityDetails/entityDetails.ts), so Drizzle serializes any value it is given. The create route broke that contract:
// packages/connect/server/api/v1/business/index.post.ts (before)
details: JSON.stringify(body.entityDetails) as any,JSON.stringify + mode: 'json' = a double-encoded row: the column holds a JSON string whose value is itself JSON. Reads then hand back a string, not an object.
That string then met a spread in the org service:
// packages/auth/server/services/business-org.service.ts (before)
const orgMetadata = (business.details ?? {}) as Record<string, unknown>
await entityDetailsService.updateDetails(business.id, {
details: { ...orgMetadata, organizationId: org.id },
}){ ...someString } produces one digit key per character — { 0: "{", 1: "\"", 2: "c", … }. That object was written back, so the row was permanently corrupted (the "Company info" tab in /app/business/[id]/edit rendered exactly that shape). A local workaround in edit.vue already reconstructed the string from numeric keys, which confirmed the shape.
Third defect: PUT /api/v1/business/[id] only updated profile columns and never touched entity_details, so edits to company info / brand details were silently dropped.
The fix
- One tolerant codec —
coerceJsonObjectinpackages/shared/utils/json.ts. It normalizes all four shapes the column accumulated in the wild: a parsed object, a single-encoded string, a double-encoded string, and the character-indexed object. It recurses until it lands on a plain object. - Stop double-encoding on write — the create route now passes the object straight to Drizzle (
mode: 'json'stringifies once). - Normalize every read —
EntityDetailsServicewraps all four read/write returns inwithNormalizedDetails, so no consumer sees a corrupt shape. - Stop spreading raw — the three
{ ...details }sites now build fromcoerceJsonObject(...). - Merge on update —
EntityDetailsService.updateByEntity(entityId, entityType, patch)merges a patch into the existing row;PUT /api/v1/business/[id]accepts an optionalentityDetailsobject and calls it;edit.vuenow round-trips company info / brand (and preserves channels, competitors, targetAudience) instead of dropping them.
The corruption is self-healing: the first write through updateByEntity (or any normalized update) rewrites the row correctly.
Why not a migration
The entitydetails-kv pattern is deliberately migration-free. Normalizing on read removes the need for a data migration, and normalizing on write prevents recurrence.
Gotchas
- Brand details are edited as JSON text; parse them back to an object before storing (
toBrandObjectinedit.vue). brand-playbook.service.tsreadsbusiness_detailsdirectly (not through the service). ItsfindLegacyDetailspasses a raw string through unchanged, because a non-JSON string may be legacy markdown treated as company info — only objects are re-encoded.entity_detailsrows for otherentity_types (feedback, social accounts) were never part of this bug; the codec is shared but each family parses its own shape.
Verify
- Create a business with entity details →
detailsis an object on read (no nested string). - Open
/app/business/[id]/edit, change company info, save → value persists and re-renders as plain text (never digit-keyed JSON). pnpm audit:layerspasses; DB service tests pass.