Merge pull request 'fix(share): custom address edit renames in place instead of duplicating (#226)' (#227) from fix/share-alias-rename into develop

Reviewed-on: #227
This commit was merged in pull request #227.
This commit is contained in:
2026-06-27 03:53:31 +03:00
14 changed files with 1239 additions and 70 deletions

View File

@@ -0,0 +1,48 @@
import { type Kysely, sql } from 'kysely';
/**
* Enforce "a page has EXACTLY ONE custom address" at the DB level. The original
* `share_aliases` table only had a unique index on `(workspace_id, alias)`, so a
* page could accumulate several alias rows (every slug edit used to INSERT a new
* one), leaving orphan `/l/<old>` links live forever and making the share
* modal's `findByPageId` lookup nondeterministic.
*
* We first dedup any pre-existing rows (keeping the NEWEST per page — the same
* "current" choice the read path now makes), then add a PARTIAL unique index on
* `(workspace_id, page_id)`. It is partial (`WHERE page_id IS NOT NULL`) so that
* multiple DANGLING aliases (target page deleted -> `page_id` SET NULL) can
* still coexist without colliding.
*
* ⚠️ IRREVERSIBLE DATA LOSS (intended): the dedup DELETE below permanently drops
* every alias row but the newest per page. Those duplicates were live `/l/<old>`
* pointers (resolved by name via `findByAliasAndWorkspace`, not by page), so
* after this upgrade any such OLD vanity link starts returning the SPA 404. This
* is the point — it kills the orphan rows the pre-invariant bug accumulated —
* but `down()` only drops the unique index; it CANNOT restore the deleted rows.
*/
export async function up(db: Kysely<any>): Promise<void> {
// Reap legacy duplicates: for each (workspace_id, page_id) keep only the row
// with the greatest (created_at, id) — matches ShareAliasRepo.findByPageId.
await sql`
DELETE FROM share_aliases sa
USING share_aliases keep
WHERE sa.page_id IS NOT NULL
AND sa.workspace_id = keep.workspace_id
AND sa.page_id = keep.page_id
AND (keep.created_at, keep.id) > (sa.created_at, sa.id)
`.execute(db);
await db.schema
.createIndex('share_aliases_workspace_id_page_id_unique')
.on('share_aliases')
.columns(['workspace_id', 'page_id'])
.unique()
.where('page_id', 'is not', null)
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema
.dropIndex('share_aliases_workspace_id_page_id_unique')
.execute();
}

View File

@@ -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;
}
}

View File

@@ -10,16 +10,21 @@ import type { KyselyDB } from '../../types/kysely.types';
describe('ShareAliasRepo', () => {
function makeSelectRepo(result: unknown) {
const where = jest.fn();
const orderBy = jest.fn();
const builder: any = {
select: jest.fn(() => builder),
where: jest.fn((...args: unknown[]) => {
where(...args);
return builder;
}),
orderBy: jest.fn((...args: unknown[]) => {
orderBy(...args);
return builder;
}),
executeTakeFirst: jest.fn().mockResolvedValue(result),
};
const db = { selectFrom: jest.fn(() => builder) } as unknown as KyselyDB;
return { repo: new ShareAliasRepo(db), db, where, builder };
return { repo: new ShareAliasRepo(db), db, where, orderBy, builder };
}
it('findByAliasAndWorkspace scopes by alias AND workspace', async () => {
@@ -34,11 +39,15 @@ describe('ShareAliasRepo', () => {
expect(where).toHaveBeenCalledWith('workspaceId', '=', 'ws-1');
});
it('findByPageId scopes by page AND workspace', async () => {
const { repo, where } = makeSelectRepo(undefined);
it('findByPageId scopes by page AND workspace, deterministically ordered', async () => {
const { repo, where, orderBy } = makeSelectRepo(undefined);
await repo.findByPageId('p-1', 'ws-1');
expect(where).toHaveBeenCalledWith('pageId', '=', 'p-1');
expect(where).toHaveBeenCalledWith('workspaceId', '=', 'ws-1');
// Explicit ORDER BY removes the nondeterministic heap order for any legacy
// duplicate rows (newest createdAt wins, id as a stable tiebreak).
expect(orderBy).toHaveBeenCalledWith('createdAt', 'desc');
expect(orderBy).toHaveBeenCalledWith('id', 'desc');
});
it('insert writes the provided columns and returns the row', async () => {
@@ -85,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);
@@ -99,6 +110,60 @@ describe('ShareAliasRepo', () => {
expect(where).toHaveBeenCalledWith('workspaceId', '=', 'ws-1');
});
it('updateAlias renames a single row scoped by id + workspace', async () => {
const set = jest.fn();
const where = jest.fn();
const builder: any = {
set: jest.fn((s: unknown) => {
set(s);
return builder;
}),
where: jest.fn((...args: unknown[]) => {
where(...args);
return builder;
}),
returning: jest.fn(() => builder),
// 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);
const res = await repo.updateAlias('a-1', 'ted', 'ws-1');
expect(db.updateTable).toHaveBeenCalledWith('shareAliases');
expect(set.mock.calls[0][0].alias).toBe('ted');
expect(set.mock.calls[0][0].updatedAt).toBeInstanceOf(Date);
// a rename must NOT touch page_id (the page's pointer is preserved)
expect(set.mock.calls[0][0]).not.toHaveProperty('pageId');
expect(where).toHaveBeenCalledWith('id', '=', 'a-1');
expect(where).toHaveBeenCalledWith('workspaceId', '=', 'ws-1');
expect(res).toMatchObject({ alias: 'ted' });
});
it('deleteOthersForPage reaps every row for the page except keepId', async () => {
const where = jest.fn();
const builder: any = {
where: jest.fn((...args: unknown[]) => {
where(...args);
return builder;
}),
execute: jest.fn().mockResolvedValue(undefined),
};
const db = { deleteFrom: jest.fn(() => builder) } as unknown as KyselyDB;
const repo = new ShareAliasRepo(db);
await repo.deleteOthersForPage('p-1', 'a-keep', 'ws-1');
expect(db.deleteFrom).toHaveBeenCalledWith('shareAliases');
expect(where).toHaveBeenCalledWith('pageId', '=', 'p-1');
expect(where).toHaveBeenCalledWith('workspaceId', '=', 'ws-1');
expect(where).toHaveBeenCalledWith('id', '!=', 'a-keep');
});
it('delete scopes by id + workspace', async () => {
const where = jest.fn();
const builder: any = {

View File

@@ -41,7 +41,14 @@ export class ShareAliasRepo {
.executeTakeFirst();
}
/** The alias currently pointing at a page (for the share modal). */
/**
* The alias currently pointing at a page (for the share modal). The service
* enforces a single alias row per page, but legacy rows (pre-invariant) may
* still exist until self-healed; the explicit ORDER BY makes the "current"
* choice DETERMINISTIC (newest wins — i.e. the most recently created address,
* which is the one the user last asked for) instead of an arbitrary Postgres
* heap order.
*/
async findByPageId(
pageId: string,
workspaceId: string,
@@ -52,6 +59,8 @@ export class ShareAliasRepo {
.select(this.baseFields)
.where('pageId', '=', pageId)
.where('workspaceId', '=', workspaceId)
.orderBy('createdAt', 'desc')
.orderBy('id', 'desc')
.executeTakeFirst();
}
@@ -79,7 +88,60 @@ export class ShareAliasRepo {
.executeTakeFirst();
}
/** Retarget an existing alias to a new page (the "swap" operation). */
/**
* 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,
alias: string,
workspaceId: string,
trx?: KyselyTransaction,
): Promise<ShareAlias> {
return dbOrTx(this.db, trx)
.updateTable('shareAliases')
.set({ alias, updatedAt: new Date() })
.where('id', '=', id)
.where('workspaceId', '=', workspaceId)
.returning(this.baseFields)
.executeTakeFirstOrThrow();
}
/**
* Self-heal helper: drop every OTHER alias row still pointing at a page,
* keeping only `keepId`. Enforces the "exactly one custom address per page"
* invariant after a rename/retarget and reaps any legacy duplicates.
*/
async deleteOthersForPage(
pageId: string,
keepId: string,
workspaceId: string,
trx?: KyselyTransaction,
): Promise<void> {
await dbOrTx(this.db, trx)
.deleteFrom('shareAliases')
.where('pageId', '=', pageId)
.where('workspaceId', '=', workspaceId)
.where('id', '!=', keepId)
.execute();
}
/**
* 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,
@@ -92,7 +154,7 @@ export class ShareAliasRepo {
.where('id', '=', id)
.where('workspaceId', '=', workspaceId)
.returning(this.baseFields)
.executeTakeFirst();
.executeTakeFirstOrThrow();
}
async delete(

View File

@@ -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();
});
});

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