fix(share): keep exactly one custom address per page on alias edit (#226)
Editing an existing share alias (e.g. slug `te` -> `ted`) failed to update the displayed `/l/<alias>` link: `setAlias()` looked the requested slug up by name and, if free, INSERTed a brand-new row, leaving the page with multiple alias rows. The modal then read via `findByPageId().executeTakeFirst()` with no `ORDER BY`, so Postgres returned an arbitrary (in practice the oldest, stale) row. Every edit also spawned an orphan row that kept a live `/l/<old>` link forever. Regression of #205. Enforce the invariant "a page has EXACTLY ONE custom address": - `setAlias()` now resolves the page's current alias row and RENAMES it in place when the requested name is free (insert only when the page has none), keeps the same-name no-op and the cross-page 409 `ALIAS_REASSIGN_REQUIRED` + confirmed-retarget flow, and after any successful write DELETEs all other alias rows for the page (self-heal). Runs in one transaction so the page is never transiently empty or duplicated. - repo: add `updateAlias` (rename) and `deleteOthersForPage`; make `findByPageId` deterministic with `ORDER BY created_at DESC, id DESC`. - migration: dedup existing rows (keep newest per page) + a PARTIAL unique index `(workspace_id, page_id) WHERE page_id IS NOT NULL` so dangling aliases still coexist while live ones are one-per-page. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,13 +7,18 @@ import { ShareAliasService } from './share-alias.service';
|
||||
* request-time readable-target resolution (which re-runs the share boundary).
|
||||
*/
|
||||
describe('ShareAliasService', () => {
|
||||
// Sentinel handed to repo calls so tests can assert they ran inside the tx.
|
||||
const trx = { __trx: true };
|
||||
|
||||
function makeService() {
|
||||
const shareAliasRepo = {
|
||||
findByAliasAndWorkspace: jest.fn(),
|
||||
findByPageId: jest.fn(),
|
||||
findById: jest.fn(),
|
||||
insert: jest.fn(),
|
||||
updateAlias: jest.fn(),
|
||||
updatePageId: jest.fn(),
|
||||
deleteOthersForPage: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
};
|
||||
const pageRepo = { findById: jest.fn() };
|
||||
@@ -21,12 +26,19 @@ describe('ShareAliasService', () => {
|
||||
resolveReadableSharePage: jest.fn(),
|
||||
isSharingAllowed: jest.fn(),
|
||||
};
|
||||
// Fake kysely db: only .transaction().execute(cb) is used by setAlias.
|
||||
const db = {
|
||||
transaction: jest.fn(() => ({
|
||||
execute: jest.fn(async (cb: any) => cb(trx)),
|
||||
})),
|
||||
};
|
||||
const service = new ShareAliasService(
|
||||
shareAliasRepo as any,
|
||||
pageRepo as any,
|
||||
shareService as any,
|
||||
db as any,
|
||||
);
|
||||
return { service, shareAliasRepo, pageRepo, shareService };
|
||||
return { service, shareAliasRepo, pageRepo, shareService, db };
|
||||
}
|
||||
|
||||
describe('setAlias', () => {
|
||||
@@ -43,9 +55,10 @@ describe('ShareAliasService', () => {
|
||||
expect(shareAliasRepo.findByAliasAndWorkspace).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('normalizes then inserts a brand-new alias', async () => {
|
||||
it('normalizes then inserts a brand-new alias (page has none yet)', async () => {
|
||||
const { service, shareAliasRepo } = makeService();
|
||||
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue(undefined);
|
||||
shareAliasRepo.findByPageId.mockResolvedValue(undefined);
|
||||
shareAliasRepo.insert.mockResolvedValue({ id: 'a-1', alias: 'my-page' });
|
||||
|
||||
const res = await service.setAlias({
|
||||
@@ -58,17 +71,70 @@ describe('ShareAliasService', () => {
|
||||
expect(shareAliasRepo.findByAliasAndWorkspace).toHaveBeenCalledWith(
|
||||
'my-page',
|
||||
'ws-1',
|
||||
trx,
|
||||
);
|
||||
expect(shareAliasRepo.insert).toHaveBeenCalledWith(
|
||||
{
|
||||
workspaceId: 'ws-1',
|
||||
alias: 'my-page',
|
||||
pageId: 'p-1',
|
||||
creatorId: 'u-1',
|
||||
},
|
||||
trx,
|
||||
);
|
||||
expect(shareAliasRepo.updateAlias).not.toHaveBeenCalled();
|
||||
// self-heal still runs, keeping just the inserted row
|
||||
expect(shareAliasRepo.deleteOthersForPage).toHaveBeenCalledWith(
|
||||
'p-1',
|
||||
'a-1',
|
||||
'ws-1',
|
||||
trx,
|
||||
);
|
||||
expect(shareAliasRepo.insert).toHaveBeenCalledWith({
|
||||
workspaceId: 'ws-1',
|
||||
alias: 'my-page',
|
||||
pageId: 'p-1',
|
||||
creatorId: 'u-1',
|
||||
});
|
||||
expect(res).toMatchObject({ id: 'a-1' });
|
||||
});
|
||||
|
||||
it('is a no-op when the alias already points at the same page', async () => {
|
||||
it('renames the existing row in place when editing to a free name (te -> ted)', async () => {
|
||||
const { service, shareAliasRepo } = makeService();
|
||||
// The new slug is free...
|
||||
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue(undefined);
|
||||
// ...but the page already owns an alias named `te`.
|
||||
shareAliasRepo.findByPageId.mockResolvedValue({
|
||||
id: 'a-1',
|
||||
alias: 'te',
|
||||
pageId: 'p-1',
|
||||
});
|
||||
shareAliasRepo.updateAlias.mockResolvedValue({
|
||||
id: 'a-1',
|
||||
alias: 'ted',
|
||||
pageId: 'p-1',
|
||||
});
|
||||
|
||||
const res = await service.setAlias({
|
||||
workspaceId: 'ws-1',
|
||||
pageId: 'p-1',
|
||||
creatorId: 'u-1',
|
||||
alias: 'ted',
|
||||
});
|
||||
|
||||
// RENAME, not INSERT a second row.
|
||||
expect(shareAliasRepo.insert).not.toHaveBeenCalled();
|
||||
expect(shareAliasRepo.updateAlias).toHaveBeenCalledWith(
|
||||
'a-1',
|
||||
'ted',
|
||||
'ws-1',
|
||||
trx,
|
||||
);
|
||||
// ...and any other row for the page is reaped, so `te` cannot survive.
|
||||
expect(shareAliasRepo.deleteOthersForPage).toHaveBeenCalledWith(
|
||||
'p-1',
|
||||
'a-1',
|
||||
'ws-1',
|
||||
trx,
|
||||
);
|
||||
expect(res).toMatchObject({ id: 'a-1', alias: 'ted' });
|
||||
});
|
||||
|
||||
it('is a no-op when the alias already points at the same page (and self-heals)', async () => {
|
||||
const { service, shareAliasRepo } = makeService();
|
||||
const existing = { id: 'a-1', alias: 'foo', pageId: 'p-1' };
|
||||
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue(existing);
|
||||
@@ -82,7 +148,45 @@ describe('ShareAliasService', () => {
|
||||
|
||||
expect(res).toBe(existing);
|
||||
expect(shareAliasRepo.insert).not.toHaveBeenCalled();
|
||||
expect(shareAliasRepo.updateAlias).not.toHaveBeenCalled();
|
||||
expect(shareAliasRepo.updatePageId).not.toHaveBeenCalled();
|
||||
// self-heal reaps any legacy duplicate rows for the page
|
||||
expect(shareAliasRepo.deleteOthersForPage).toHaveBeenCalledWith(
|
||||
'p-1',
|
||||
'a-1',
|
||||
'ws-1',
|
||||
trx,
|
||||
);
|
||||
});
|
||||
|
||||
it('self-heals a page with pre-existing duplicate rows down to one', async () => {
|
||||
const { service, shareAliasRepo } = makeService();
|
||||
// Name free; the page already has a (legacy) alias row we rename.
|
||||
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue(undefined);
|
||||
shareAliasRepo.findByPageId.mockResolvedValue({
|
||||
id: 'a-keep',
|
||||
alias: 'old',
|
||||
pageId: 'p-1',
|
||||
});
|
||||
shareAliasRepo.updateAlias.mockResolvedValue({
|
||||
id: 'a-keep',
|
||||
alias: 'new',
|
||||
pageId: 'p-1',
|
||||
});
|
||||
|
||||
await service.setAlias({
|
||||
workspaceId: 'ws-1',
|
||||
pageId: 'p-1',
|
||||
creatorId: 'u-1',
|
||||
alias: 'new',
|
||||
});
|
||||
|
||||
expect(shareAliasRepo.deleteOthersForPage).toHaveBeenCalledWith(
|
||||
'p-1',
|
||||
'a-keep',
|
||||
'ws-1',
|
||||
trx,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws 409 with current target when name is taken and not confirmed', async () => {
|
||||
@@ -134,6 +238,14 @@ describe('ShareAliasService', () => {
|
||||
'a-1',
|
||||
'p-1',
|
||||
'ws-1',
|
||||
trx,
|
||||
);
|
||||
// the page's previous alias row(s) are reaped after the swap
|
||||
expect(shareAliasRepo.deleteOthersForPage).toHaveBeenCalledWith(
|
||||
'p-1',
|
||||
'a-1',
|
||||
'ws-1',
|
||||
trx,
|
||||
);
|
||||
expect(res).toMatchObject({ pageId: 'p-1' });
|
||||
});
|
||||
|
||||
@@ -9,6 +9,9 @@ import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
import { ShareService } from './share.service';
|
||||
import { Page, ShareAlias } from '@docmost/db/types/entity.types';
|
||||
import { isValidShareAlias, normalizeShareAlias } from './share-alias.util';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||
import { executeTx } from '@docmost/db/utils';
|
||||
|
||||
/** Postgres unique_violation; the (workspace_id, alias) constraint races here. */
|
||||
const PG_UNIQUE_VIOLATION = '23505';
|
||||
@@ -28,16 +31,26 @@ export class ShareAliasService {
|
||||
private readonly shareAliasRepo: ShareAliasRepo,
|
||||
private readonly pageRepo: PageRepo,
|
||||
private readonly shareService: ShareService,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Create or retarget a vanity alias. The alias is workspace-scoped:
|
||||
* - no row for this name -> INSERT a new pointer
|
||||
* - row already points at pageId -> no-op (idempotent)
|
||||
* - row points elsewhere -> the "swap". Without confirmReassign we
|
||||
* throw 409 carrying the current target so the client can confirm; with
|
||||
* it we UPDATE the single row's page_id (every /l/<alias> link follows the
|
||||
* 302 to the new page instantly — no stale 301 cache).
|
||||
* Create, RENAME or retarget a page's vanity alias. INVARIANT: a page has
|
||||
* EXACTLY ONE custom address. The alias name is workspace-scoped:
|
||||
* - name free, page has no alias yet -> INSERT a new pointer
|
||||
* - name free, page already has one -> RENAME that row in place (the slug
|
||||
* edit, e.g. `te` -> `ted`); we never spawn a second row, so no orphan
|
||||
* `/l/<old>` link survives
|
||||
* - name already points at pageId -> no-op (idempotent)
|
||||
* - name points at ANOTHER page -> the "swap". Without confirmReassign
|
||||
* we throw 409 carrying the current target so the client can confirm;
|
||||
* with it we UPDATE the single row's page_id (every /l/<alias> link
|
||||
* follows the 302 to the new page instantly — no stale cache).
|
||||
*
|
||||
* After ANY successful write we DELETE every other alias row still pointing
|
||||
* at this page (the previous name after a rename/retarget, plus any legacy
|
||||
* duplicates) so the invariant self-heals. The whole thing runs in one
|
||||
* transaction so the page never transiently has zero or duplicate rows.
|
||||
*
|
||||
* Caller is responsible for authorizing the page (edit rights + public
|
||||
* readability); this method owns only the alias-name semantics.
|
||||
@@ -57,48 +70,91 @@ export class ShareAliasService {
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await this.shareAliasRepo.findByAliasAndWorkspace(
|
||||
alias,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
if (!existing) {
|
||||
try {
|
||||
return await this.shareAliasRepo.insert({
|
||||
workspaceId,
|
||||
try {
|
||||
return await executeTx(this.db, async (trx) => {
|
||||
const byName = await this.shareAliasRepo.findByAliasAndWorkspace(
|
||||
alias,
|
||||
pageId,
|
||||
creatorId,
|
||||
});
|
||||
} catch (err: any) {
|
||||
// Lost a uniqueness race: another request claimed the name first.
|
||||
if (err?.code === PG_UNIQUE_VIOLATION) {
|
||||
throw new ConflictException({ message: 'Alias already taken' });
|
||||
workspaceId,
|
||||
trx,
|
||||
);
|
||||
|
||||
// The name is occupied by a DIFFERENT (or dangling) target page.
|
||||
if (byName && byName.pageId !== pageId) {
|
||||
if (!confirmReassign) {
|
||||
const currentPage = byName.pageId
|
||||
? await this.pageRepo.findById(byName.pageId)
|
||||
: null;
|
||||
throw new ConflictException({
|
||||
message: 'Alias already in use',
|
||||
code: 'ALIAS_REASSIGN_REQUIRED',
|
||||
currentPageId: byName.pageId,
|
||||
currentPageTitle: currentPage?.title ?? null,
|
||||
});
|
||||
}
|
||||
// Confirmed: claim the existing name row for this page, then drop the
|
||||
// page's previous alias row(s) so it ends with exactly this one.
|
||||
const retargeted = await this.shareAliasRepo.updatePageId(
|
||||
byName.id,
|
||||
pageId,
|
||||
workspaceId,
|
||||
trx,
|
||||
);
|
||||
await this.shareAliasRepo.deleteOthersForPage(
|
||||
pageId,
|
||||
retargeted.id,
|
||||
workspaceId,
|
||||
trx,
|
||||
);
|
||||
return retargeted;
|
||||
}
|
||||
this.logger.error(err);
|
||||
throw new BadRequestException('Failed to set alias');
|
||||
}
|
||||
}
|
||||
|
||||
// Already points at this page -> nothing to do.
|
||||
if (existing.pageId === pageId) {
|
||||
return existing;
|
||||
}
|
||||
// The name is FREE, or already points at THIS page. Ensure the page has
|
||||
// a single row carrying this name: rename its current one, or insert.
|
||||
const current =
|
||||
byName ??
|
||||
(await this.shareAliasRepo.findByPageId(pageId, workspaceId, trx));
|
||||
|
||||
// Name occupied by a different (or dangling) target: require confirmation.
|
||||
if (!confirmReassign) {
|
||||
const currentPage = existing.pageId
|
||||
? await this.pageRepo.findById(existing.pageId)
|
||||
: null;
|
||||
throw new ConflictException({
|
||||
message: 'Alias already in use',
|
||||
code: 'ALIAS_REASSIGN_REQUIRED',
|
||||
currentPageId: existing.pageId,
|
||||
currentPageTitle: currentPage?.title ?? null,
|
||||
let row: ShareAlias;
|
||||
if (current) {
|
||||
row =
|
||||
current.alias === alias
|
||||
? current // same-name no-op
|
||||
: await this.shareAliasRepo.updateAlias(
|
||||
current.id,
|
||||
alias,
|
||||
workspaceId,
|
||||
trx,
|
||||
);
|
||||
} else {
|
||||
row = await this.shareAliasRepo.insert(
|
||||
{ workspaceId, alias, pageId, creatorId },
|
||||
trx,
|
||||
);
|
||||
}
|
||||
|
||||
// Self-heal: a page keeps EXACTLY ONE custom address.
|
||||
await this.shareAliasRepo.deleteOthersForPage(
|
||||
pageId,
|
||||
row.id,
|
||||
workspaceId,
|
||||
trx,
|
||||
);
|
||||
return row;
|
||||
});
|
||||
} catch (err: any) {
|
||||
if (
|
||||
err instanceof ConflictException ||
|
||||
err instanceof BadRequestException
|
||||
) {
|
||||
throw err;
|
||||
}
|
||||
// Lost a uniqueness race: another request claimed the name first.
|
||||
if (err?.code === PG_UNIQUE_VIOLATION) {
|
||||
throw new ConflictException({ message: 'Alias already taken' });
|
||||
}
|
||||
this.logger.error(err);
|
||||
throw new BadRequestException('Failed to set alias');
|
||||
}
|
||||
|
||||
return this.shareAliasRepo.updatePageId(existing.id, pageId, workspaceId);
|
||||
}
|
||||
|
||||
/** Free a vanity name (no history kept). */
|
||||
|
||||
Reference in New Issue
Block a user