{"record":{"id":"ede3de5b217a520a","repo":"thedotmack/claude-mem","slug":"validationerror-ede3de","errorCode":"ValidationError","errorMessage":"memory_items requires at least one searchable text field (narrative, text, title, subtitle, facts, or concepts) so the FTS index is populated; refusing to persist an empty record","messagePattern":"memory_items requires at least one searchable text field \\(narrative, text, title, subtitle, facts, or concepts\\) so the FTS index is populated; refusing to persist an empty record","errorType":"http","errorClass":null,"httpStatus":400,"severity":"error","filePath":"src/server/routes/v1/ServerV1Routes.ts","lineNumber":191,"sourceCode":"      const event = new AgentEventsRepository(this.options.getDatabase()).getById(id);\n      if (!event) {\n        res.status(404).json({ error: 'NotFound', message: 'Event not found' });\n        return;\n      }\n      if (!this.ensureProjectAllowed(req, res, event.projectId)) return;\n      this.audit(req, 'event.read', event.id, event.projectId);\n      res.json({ event });\n    });\n\n    app.post('/v1/memories', writeAuth, this.handleCreate(CreateMemoryItemSchema, (req, res, body) => {\n      if (!this.ensureProjectAllowed(req, res, body.projectId)) return;\n      // Write-path contract (#2684/#2533): the FTS trigger (trg_memory_items_fts_insert)\n      // copies title/subtitle/text/narrative/facts/concepts into the search index.\n      // A row with NONE of the searchable text columns populated is invisible to\n      // search — it looks \"frozen\". Reject it LOUDLY here instead of silently\n      // persisting an unsearchable record.\n      if (!hasSearchableContent(body)) {\n        res.status(400).json({\n          error: 'ValidationError',\n          message: 'memory_items requires at least one searchable text field (narrative, text, title, subtitle, facts, or concepts) so the FTS index is populated; refusing to persist an empty record',\n        });\n        return;\n      }\n      const memory = new MemoryItemsRepository(this.options.getDatabase()).create(body);\n      this.audit(req, 'memory.write', memory.id, memory.projectId);\n      res.status(201).json({ memory });\n    }));\n\n    app.get('/v1/memories/:id', readAuth, (req, res) => {\n      const id = this.routeParam(req.params.id);\n      const memory = new MemoryItemsRepository(this.options.getDatabase()).getById(id);\n      if (!memory) {\n        res.status(404).json({ error: 'NotFound', message: 'Memory not found' });\n        return;\n      }\n      if (!this.ensureProjectAllowed(req, res, memory.projectId)) return;","sourceCodeStart":173,"sourceCodeEnd":209,"githubUrl":"https://github.com/thedotmack/claude-mem/blob/e2d1df569a8f04075d40e92461128ece7cf04c82/src/server/routes/v1/ServerV1Routes.ts#L173-L209","documentation":"POST /v1/memories enforces a cross-field rule beyond Zod: at least one searchable text field (narrative, text, title, subtitle, facts, concepts) must be populated, because the FTS insert trigger (trg_memory_items_fts_insert) copies exactly those columns into the search index. A row with none would be invisible to /v1/search and look 'frozen', so the route rejects it loudly with 400 ValidationError (issues #2684/#2533).","triggerScenarios":"POST /v1/memories with a body that passes CreateMemoryItemSchema but carries only non-searchable metadata (projectId, timestamps, scores, tags) with every text field absent or empty.","commonSituations":"Pipelines writing metrics-only or relation-only records; clients mapping content to the wrong key name (e.g. 'body' instead of 'text'); upstream trimming that leaves empty strings.","solutions":["Include at least one of narrative, text, title, subtitle, facts, or concepts in the payload","If the record genuinely has no text yet, skip or buffer the write until text exists","Replicate the hasSearchableContent check client-side so the failure never reaches the server"],"exampleFix":"// before\nPOST /v1/memories { \"projectId\": \"p1\", \"score\": 0.9 } // 400: no searchable field\n\n// after\nPOST /v1/memories { \"projectId\": \"p1\", \"score\": 0.9, \"title\": \"deploy incident\", \"text\": \"summary of incident\" } // 201","handlingStrategy":"validation","validationCode":"const SEARCHABLE_FIELDS = ['narrative', 'text', 'title', 'subtitle', 'facts', 'concepts'] as const;\n\nfunction hasSearchableContent(body: Record<string, unknown>): boolean {\n  return SEARCHABLE_FIELDS.some(f => {\n    const v = body[f];\n    if (v == null) return false;\n    return typeof v === 'string' ? v.trim().length > 0 : Array.isArray(v) ? v.length > 0 : Boolean(v);\n  });\n}\n\nif (!hasSearchableContent(payload)) throw new Error('memory needs at least one of: ' + SEARCHABLE_FIELDS.join(', '));","typeGuard":"function isSearchableMemory(body: unknown): body is Record<string, string | string[] | number | undefined> {\n  if (typeof body !== 'object' || body === null) return false;\n  const b = body as Record<string, unknown>;\n  return ['narrative', 'text', 'title', 'subtitle', 'facts', 'concepts'].some(k =>\n    (typeof b[k] === 'string' && (b[k] as string).trim() !== '') || (Array.isArray(b[k]) && (b[k] as unknown[]).length > 0)\n  );\n}","tryCatchPattern":"const res = await fetch(`${base}/v1/memories`, { method: 'POST', headers: jsonHeaders, body: JSON.stringify(payload) });\nif (res.status === 400) {\n  const body = await res.json().catch(() => ({}));\n  if (typeof body.message === 'string' && body.message.includes('searchable text field')) {\n    payload.title = payload.title ?? fallbackSummary; // enrich and retry once\n    return retry(payload);\n  }\n  throw new Error(`validation failed: ${JSON.stringify(body.issues ?? body.message)}`);\n}","preventionTips":["Mirror hasSearchableContent client-side and fail before the HTTP call","Map content keys carefully — 'body' or 'content' are not searchable fields","Never write metrics-only memory records; give each one a title or narrative"],"tags":["fts","search","validation","http-400","memories"],"backgroundTag":"missing-required-field","analyzedSha":"e2d1df569a8f04075d40e92461128ece7cf04c82","analyzedAt":"2026-08-20T23:58:13.836Z","schemaVersion":2},"datasetVersion":"2026-08-29T07:17:48.351Z"}