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