{"record":{"id":"cf00bb4d69a0b39b","repo":"toeverything/AFFiNE","slug":"app-config-paths-must-not-overlap-overlappingke","errorCode":null,"errorMessage":"App config paths must not overlap: ${overlappingKey} and ${key}","messagePattern":"App config paths must not overlap: (.+?) and (.+?)","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"packages/backend/server/src/models/config.ts","lineNumber":34,"sourceCode":"  @Transactional()\n  async save(user: string, updates: Array<{ key: string; value: any }>) {\n    await this.db\n      .$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${'app-config-paths'}, 0))`;\n    const existing = await this.db.appConfig.findMany({\n      select: { id: true },\n    });\n    const updateKeys = updates.map(update => update.key);\n    for (const [index, key] of updateKeys.entries()) {\n      const overlappingKey = [\n        ...existing.map(config => config.id),\n        ...updateKeys.slice(0, index),\n      ].find(\n        candidate =>\n          candidate !== key &&\n          (candidate.startsWith(`${key}.`) || key.startsWith(`${candidate}.`))\n      );\n      if (overlappingKey) {\n        throw new Error(\n          `App config paths must not overlap: ${overlappingKey} and ${key}`\n        );\n      }\n    }\n\n    return await Promise.allSettled(\n      updates.map(async update => {\n        return this.db.appConfig.upsert({\n          where: { id: update.key },\n          update: { value: update.value, lastUpdatedBy: user },\n          create: { id: update.key, value: update.value, lastUpdatedBy: user },\n        });\n      })\n    );\n  }\n\n  async get(key: string) {\n    return await this.db.appConfig.findUnique({ where: { id: key } });","sourceCodeStart":16,"sourceCodeEnd":52,"githubUrl":"https://github.com/toeverything/AFFiNE/blob/b4c8548c09da21b2898443559a5b846f0ccf5dd8/packages/backend/server/src/models/config.ts#L16-L52","documentation":"Thrown by AppConfigModel.save() in packages/backend/server/src/models/config.ts. App config keys are dot-separated paths forming a tree ('a' is the parent of 'a.b'), and save() upserts each update by id, so writing both a prefix and its descendant would make the stored config ambiguous. Under a pg_advisory_xact_lock, save() loads all existing app_config ids and rejects the whole batch when any update key overlaps a stored id or an earlier key in the same request (candidate.startsWith(`${key}.`) or key.startsWith(`${candidate}.`)). No upsert runs after the throw.","triggerScenarios":"Calling save(user, updates) where the updates array contains both 'flags' and 'flags.copilot' (an earlier key is a dotted ancestor of a later one), or an update key such as 'flags' that prefixes an id already stored in the app_config table. The match requires an exact dot boundary: 'flag' vs 'flags' does not overlap, 'flags' vs 'flags.x' does.","commonSituations":"Admin UI bulk-saving a parent namespace together with its leaf settings; migrating a flat key to a hierarchical name whose prefix matches legacy rows still present in app_config; seeding scripts that write 'a' and 'a.b' in one array.","solutions":["Remove one of the overlapping keys from the updates array - store only leaf paths so every key is unambiguous","Load the stored keys first with appConfigModel.load() and drop any update whose key prefixes or is prefixed by an existing id","Delete or migrate the conflicting app_config row, then retry the save","If hierarchical writes are needed, ensure no key in the batch is a dotted ancestor of another (earlier keys are compared against later ones via updateKeys.slice(0, index))"],"exampleFix":"// before\nawait config.save(user, [\n  { key: 'flags', value: { copilot: true } },\n  { key: 'flags.copilot', value: true }, // overlaps 'flags'\n]);\n\n// after - leaf keys only\nawait config.save(user, [\n  { key: 'flags.copilot', value: true },\n]);","handlingStrategy":"validation","validationCode":"const keysOverlap = (a: string, b: string) =>\n  a !== b && (a.startsWith(`${b}.`) || b.startsWith(`${a}.`));\n\n// before save(): check within the batch and against stored keys\nconst existing = (await config.load()).map(c => c.id);\nconst bad = updates.filter(u =>\n  existing.some(id => keysOverlap(id, u.key)) ||\n  updates.some(o => o !== u && keysOverlap(o.key, u.key))\n);\nif (bad.length) {\n  throw new Error(`Overlapping config keys: ${bad.map(b => b.key).join(', ')}`);\n}\nawait config.save(user, updates);","typeGuard":"function keysOverlap(a: string, b: string): boolean {\n  return a !== b && (a.startsWith(`${b}.`) || b.startsWith(`${a}.`));\n}","tryCatchPattern":"try {\n  await config.save(user, updates);\n} catch (e) {\n  if (e instanceof Error && e.message.startsWith('App config paths must not overlap')) {\n    // drop the offending key pair and retry, or report to the admin UI\n  }\n  throw e;\n}","preventionTips":["Store only leaf config keys; never write a parent namespace and its children in one batch","Run the dot-boundary prefix check in the admin UI before submit","When renaming keys during migration, delete or migrate the old row in the same change"],"tags":["app-config","dot-path","validation","prisma","backend"],"backgroundTag":"config-key-overlap","analyzedSha":"b4c8548c09da21b2898443559a5b846f0ccf5dd8","analyzedAt":"2026-08-18T21:16:52.546Z","contentChangedAt":"2026-08-18T21:16:52.546Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}