toeverything/AFFiNE · error · Error

App config paths must not overlap

Error message

App config paths must not overlap: ${overlappingKey} and ${key}

What it means

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.

Solutions

  1. Remove one of the overlapping keys from the updates array - store only leaf paths so every key is unambiguous
  2. Load the stored keys first with appConfigModel.load() and drop any update whose key prefixes or is prefixed by an existing id
  3. Delete or migrate the conflicting app_config row, then retry the save
  4. 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))

Example fix

// before
await config.save(user, [
  { key: 'flags', value: { copilot: true } },
  { key: 'flags.copilot', value: true }, // overlaps 'flags'
]);

// after - leaf keys only
await config.save(user, [
  { key: 'flags.copilot', value: true },
]);
Defensive patterns

Strategy: validation

Validate before calling

const keysOverlap = (a: string, b: string) =>
  a !== b && (a.startsWith(`${b}.`) || b.startsWith(`${a}.`));

// before save(): check within the batch and against stored keys
const existing = (await config.load()).map(c => c.id);
const bad = updates.filter(u =>
  existing.some(id => keysOverlap(id, u.key)) ||
  updates.some(o => o !== u && keysOverlap(o.key, u.key))
);
if (bad.length) {
  throw new Error(`Overlapping config keys: ${bad.map(b => b.key).join(', ')}`);
}
await config.save(user, updates);

Type guard

function keysOverlap(a: string, b: string): boolean {
  return a !== b && (a.startsWith(`${b}.`) || b.startsWith(`${a}.`));
}

Try / catch

try {
  await config.save(user, updates);
} catch (e) {
  if (e instanceof Error && e.message.startsWith('App config paths must not overlap')) {
    // drop the offending key pair and retry, or report to the admin UI
  }
  throw e;
}

Prevention

When it happens

Trigger: 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.

Common situations: 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.

Related errors


AI-assisted analysis of toeverything/AFFiNE@b4c8548c09 (2026-08-18). Data as JSON: /api/errors/cf00bb4d69a0b39b. Report an issue: GitHub.

Appendix: source

Thrown at packages/backend/server/src/models/config.ts:34

  @Transactional()
  async save(user: string, updates: Array<{ key: string; value: any }>) {
    await this.db
      .$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${'app-config-paths'}, 0))`;
    const existing = await this.db.appConfig.findMany({
      select: { id: true },
    });
    const updateKeys = updates.map(update => update.key);
    for (const [index, key] of updateKeys.entries()) {
      const overlappingKey = [
        ...existing.map(config => config.id),
        ...updateKeys.slice(0, index),
      ].find(
        candidate =>
          candidate !== key &&
          (candidate.startsWith(`${key}.`) || key.startsWith(`${candidate}.`))
      );
      if (overlappingKey) {
        throw new Error(
          `App config paths must not overlap: ${overlappingKey} and ${key}`
        );
      }
    }

    return await Promise.allSettled(
      updates.map(async update => {
        return this.db.appConfig.upsert({
          where: { id: update.key },
          update: { value: update.value, lastUpdatedBy: user },
          create: { id: update.key, value: update.value, lastUpdatedBy: user },
        });
      })
    );
  }

  async get(key: string) {
    return await this.db.appConfig.findUnique({ where: { id: key } });

View on GitHub (pinned to b4c8548c09)