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:
@@ -0,0 +1,41 @@
|
||||
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.
|
||||
*/
|
||||
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();
|
||||
}
|
||||
@@ -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 () => {
|
||||
@@ -99,6 +108,56 @@ 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),
|
||||
executeTakeFirst: 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 = {
|
||||
|
||||
@@ -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,6 +88,45 @@ export class ShareAliasRepo {
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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). */
|
||||
async updatePageId(
|
||||
id: string,
|
||||
|
||||
Reference in New Issue
Block a user