fix(share): guard alias swap/rename against concurrent-delete race; share unique-violation helpers

Address PR #227 re-review (comment 2193).

- Stability: `updatePageId`/`updateAlias` now `executeTakeFirstOrThrow`, so a row
  reaped by a concurrent `removeAlias` between the read and the UPDATE (READ
  COMMITTED) raises `NoResultError` instead of returning `undefined`. The service
  maps that to a retryable `ConflictException` (`ALIAS_PAGE_RACE`) rather than a
  200-without-alias (swap) or a generic 400 from `undefined.id` (rename). Tests
  cover both branches.
- Simplification: drop the redundant secondary "unexpected unique index" warn and
  the now-unused `UNIQUE_ALIAS_INDEX` const (the constraint name is already logged
  unconditionally; both index branches still distinguish "Alias already taken" vs
  ALIAS_PAGE_RACE).
- Architecture: extract `isUniqueViolation`/`violatedConstraint` into
  database/utils.ts; adopt them in the share-alias service and favorite.repo
  (the bare `23505` check). ai-agent-roles (#222) is on a separate unmerged branch
  and should adopt them after #227 merges (noted at the helpers). Helper unit test
  added.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
claude code agent 227
2026-06-27 03:33:33 +03:00
parent 309719abc6
commit 767ac9e7e2
7 changed files with 198 additions and 27 deletions
@@ -7,7 +7,7 @@ import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagin
import { jsonObjectFrom } from 'kysely/helpers/postgres';
import { ExpressionBuilder, SelectQueryBuilder, sql } from 'kysely';
import { DB } from '@docmost/db/types/db';
import { dbOrTx } from '@docmost/db/utils';
import { dbOrTx, isUniqueViolation } from '@docmost/db/utils';
export const FavoriteType = {
PAGE: 'page',
@@ -29,7 +29,8 @@ export class FavoriteRepo {
.returningAll()
.executeTakeFirst();
} catch (err: any) {
if (err?.code === '23505') return undefined;
// Idempotent favorite: a duplicate (already-favorited) is not an error.
if (isUniqueViolation(err)) return undefined;
throw err;
}
}
@@ -94,7 +94,9 @@ describe('ShareAliasRepo', () => {
return builder;
}),
returning: jest.fn(() => builder),
executeTakeFirst: jest.fn().mockResolvedValue({ id: 'a-1' }),
// Retarget uses executeTakeFirstOrThrow so a row reaped by a concurrent
// delete (0 rows matched) raises NoResultError instead of returning undefined.
executeTakeFirstOrThrow: jest.fn().mockResolvedValue({ id: 'a-1' }),
};
const db = { updateTable: jest.fn(() => builder) } as unknown as KyselyDB;
const repo = new ShareAliasRepo(db);
@@ -121,7 +123,11 @@ describe('ShareAliasRepo', () => {
return builder;
}),
returning: jest.fn(() => builder),
executeTakeFirst: jest.fn().mockResolvedValue({ id: 'a-1', alias: 'ted' }),
// Rename uses executeTakeFirstOrThrow so a row reaped by a concurrent
// delete (0 rows matched) raises NoResultError instead of returning undefined.
executeTakeFirstOrThrow: jest
.fn()
.mockResolvedValue({ id: 'a-1', alias: 'ted' }),
};
const db = { updateTable: jest.fn(() => builder) } as unknown as KyselyDB;
const repo = new ShareAliasRepo(db);
@@ -92,6 +92,12 @@ export class ShareAliasRepo {
* Rename an existing alias row in place (the vanity-slug edit, e.g.
* `te` -> `ted`). Keeps the row's id/page_id/creator so the page's single
* alias pointer is preserved — only the human-readable name changes.
*
* Uses `executeTakeFirstOrThrow`: if a concurrent `delete` reaps this row
* between the service's read and this UPDATE (READ COMMITTED), the UPDATE
* matches 0 rows and kysely throws `NoResultError` rather than returning
* `undefined` for a `Promise<ShareAlias>`. The service maps that to a
* retryable conflict instead of dereferencing `undefined.id`.
*/
async updateAlias(
id: string,
@@ -105,7 +111,7 @@ export class ShareAliasRepo {
.where('id', '=', id)
.where('workspaceId', '=', workspaceId)
.returning(this.baseFields)
.executeTakeFirst();
.executeTakeFirstOrThrow();
}
/**
@@ -127,7 +133,15 @@ export class ShareAliasRepo {
.execute();
}
/** Retarget an existing alias to a new page (the "swap" operation). */
/**
* Retarget an existing alias to a new page (the "swap" operation).
*
* Uses `executeTakeFirstOrThrow`: if a concurrent `delete` reaps this row
* between the service's read and this UPDATE, the UPDATE matches 0 rows and
* kysely throws `NoResultError` instead of returning `undefined` into the 200
* response (a "success" with no alias). The service maps that to a retryable
* conflict.
*/
async updatePageId(
id: string,
pageId: string,
@@ -140,7 +154,7 @@ export class ShareAliasRepo {
.where('id', '=', id)
.where('workspaceId', '=', workspaceId)
.returning(this.baseFields)
.executeTakeFirst();
.executeTakeFirstOrThrow();
}
async delete(
@@ -0,0 +1,51 @@
import { isUniqueViolation, violatedConstraint } from './utils';
/**
* Unit tests for the driver-bound Postgres unique-violation helpers extracted
* from the share-alias service (and now shared with favorite.repo). They encode
* two `kysely-postgres-js` / `postgres@3.x` quirks: the SQLSTATE is the string
* `'23505'`, and the violated index name arrives as `constraint_name` (with
* `constraint` only a fallback for other drivers).
*/
describe('isUniqueViolation', () => {
it('is true for a 23505 error', () => {
expect(isUniqueViolation({ code: '23505' })).toBe(true);
});
it('is false for any other code', () => {
expect(isUniqueViolation({ code: '08006' })).toBe(false);
});
it('is false when there is no code / not an object', () => {
expect(isUniqueViolation({})).toBe(false);
expect(isUniqueViolation(null)).toBe(false);
expect(isUniqueViolation(undefined)).toBe(false);
expect(isUniqueViolation(new Error('boom'))).toBe(false);
});
});
describe('violatedConstraint', () => {
it('reads the postgres@3.x `constraint_name` field', () => {
expect(
violatedConstraint({ code: '23505', constraint_name: 'idx_a' }),
).toBe('idx_a');
});
it('falls back to `constraint` when `constraint_name` is absent', () => {
expect(violatedConstraint({ code: '23505', constraint: 'idx_b' })).toBe(
'idx_b',
);
});
it('prefers `constraint_name` over `constraint` when both are present', () => {
expect(
violatedConstraint({ constraint_name: 'idx_a', constraint: 'idx_b' }),
).toBe('idx_a');
});
it('is undefined when neither field is present', () => {
expect(violatedConstraint({ code: '23505' })).toBeUndefined();
expect(violatedConstraint(null)).toBeUndefined();
expect(violatedConstraint(undefined)).toBeUndefined();
});
});
+29
View File
@@ -33,6 +33,35 @@ export function dbOrTx(
}
}
/** Postgres `unique_violation` SQLSTATE — raised when a write hits a UNIQUE index. */
const PG_UNIQUE_VIOLATION = '23505';
/**
* Whether `err` is a Postgres unique-violation (SQLSTATE `23505`). THE single
* check so repos/services stop re-hardcoding the magic code.
*
* NOTE (#222): `core/ai-chat/roles/ai-agent-roles.service.ts` still carries its
* own inline `23505` check on a separate, unmerged branch; it should adopt this
* helper (and {@link violatedConstraint}) after #227 lands.
*/
export function isUniqueViolation(err: unknown): boolean {
return (err as { code?: unknown } | null | undefined)?.code === PG_UNIQUE_VIOLATION;
}
/**
* The name of the UNIQUE index/constraint a `23505` error violated, or
* undefined. The `kysely-postgres-js` / `postgres@3.x` driver surfaces it as
* `err.constraint_name` (NOT `.constraint`); `.constraint` is kept only as a
* defensive fallback for other drivers.
*/
export function violatedConstraint(err: unknown): string | undefined {
const e = err as
| { constraint_name?: string; constraint?: string }
| null
| undefined;
return e?.constraint_name ?? e?.constraint;
}
/**
* Bind a JS array/object as a `jsonb` column value, working around a postgres
* driver double-encoding quirk. THE single implementation — repos that persist