Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .changeset/entry-edit-lock.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
"emdash": minor
"@emdash-cms/admin": minor
---

Adds an edit lock per content entry, so two people no longer discover a collision only after both have done the work.

Opening an entry in the admin takes a lock on it. A second editor is told who has it and chooses between opening the entry read-only, where nothing they type can be lost to a refused save, and taking it over. After a take-over, the previous holder is told within two minutes that the entry moved on, their next save is refused, and a banner names who holds it now.

The lock lasts seven minutes. The admin renews it every two minutes while the entry is open, so a pause in typing does not lose it, and every save on the entry extends it too. Leaving the editor or closing the tab releases it; a tab that loses power or network lets it lapse.

#### Who is newly refused

Scripts, API tokens and the CLI that update, delete, publish, unpublish, schedule or discard an entry while an editor has it open in the admin now receive `409 ENTRY_LOCKED` where the write used to succeed. This applies to every collection once the migration has run. The response's `error.message` names the holder and `error.details` carries their `userId`, `userName`, `acquiredAt` and `expiresAt`. Pass `"overrideLock": true` in the request body to write anyway, or `?overrideLock=true` on `DELETE`, which has no body. The CLI takes `--override-lock` on `content update`, `content delete`, `content publish`, `content unpublish` and `content schedule`. The MCP content tools do not honour the lock yet.

Locks are per entry and per locale, so two translations of the same entry can be edited at once.

Take or read a lock directly through `GET`, `POST` and `DELETE` on `/_emdash/api/content/{collection}/{id}/lock`.

#### Turning it off

Locking is on for every collection. Switch it off under **Content Types** → your collection → **Edit locking**, with `editLocking: false` in a seed file, or through `schema_update_collection`:

```json
{ "slug": "posts", "editLocking": false }
```

#### Upgrading

Includes database migration `075_entry_edit_locks`. Projects on the default `auto` runtime migration mode need no action. Projects that migrate as a deployment step: run `emdash migrate` before deploying this version.
19 changes: 19 additions & 0 deletions docs/src/content/docs/guides/working-with-content.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,25 @@ EmDash tracks changes to content. Access revision history from the editor sideba
Restoring a revision creates a new revision with the restored content. The original revision
history is preserved.

### Entry Open Elsewhere

Opening an entry takes an edit lock on it. If a colleague already has it open, EmDash says who
and offers two choices:

- **Open read-only** shows the entry without accepting edits, so you cannot type work that will
be thrown away.
- **Take over** gives you the lock. The other person keeps what they typed, but within two
minutes their editor tells them you hold the entry now, and their next save is refused.

The lock is renewed while the entry is open and released when you leave the editor or close the
tab. If the tab loses power or network instead, the lock expires seven minutes later.

Locks are per entry and per locale, so two translations of the same entry can be edited at the
same time. Scripts and API clients that write to a locked entry are refused; see
[Entry Edit Lock](/reference/rest-api/#entry-edit-lock) in the REST API reference.

Turn locking off for a collection under **Content Types** → your collection → **Edit locking**.

## Bulk Operations

Perform actions on multiple entries at once:
Expand Down
16 changes: 11 additions & 5 deletions docs/src/content/docs/reference/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -256,14 +256,20 @@ npx emdash content update posts 01ABC123 \
--data '{"title": "Updated"}'
```

| Option | Description |
| -------- | -------------------------------------- |
| `--rev` | Revision token from `get` (required) |
| `--data` | JSON string with content data |
| `--file` | Read data from a JSON file |
| Option | Description |
| ----------------- | -------------------------------------------------------- |
| `--rev` | Revision token from `get` (required) |
| `--data` | JSON string with content data |
| `--file` | Read data from a JSON file |
| `--override-lock` | Write even though another editor has the entry open |

If the item has changed since your `get`, the server returns 409 Conflict — re-read and try again.

If someone has the entry open in the admin, the server returns 409 with code
`ENTRY_LOCKED` and a message that names the holder. Wait for them to finish, or
pass `--override-lock`. The same flag is available on `content delete`,
`content publish`, `content unpublish` and `content schedule`.

#### `content delete <collection> <id>`

```bash
Expand Down
4 changes: 4 additions & 0 deletions docs/src/content/docs/reference/mcp-server.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ The server exposes tools across eight domains: content, schema, media, search, t

### Content Tools

The write tools do not honour an entry's [edit lock](/reference/rest-api/#entry-edit-lock). An editor who has the entry open in the admin is only protected by `_rev`.

#### `content_list`

List content items in a collection with optional filtering and pagination.
Expand Down Expand Up @@ -322,6 +324,7 @@ Create a new content collection. This creates a database table and schema defini
| `description` | `string` | No | Description of this collection |
| `icon` | `string` | No | Icon name for the admin UI |
| `supports` | `string[]` | No | Features: `drafts`, `revisions`, `preview`, `scheduling`, `search`, `seo` (default: `['drafts', 'revisions']`) |
| `editLocking` | `boolean` | No | Take an edit lock when an entry is opened (default: `true`) |

**Scope:** `schema:write` | **Minimum role:** Admin

Expand All @@ -343,6 +346,7 @@ Update an existing collection without deleting its table, fields, or content. On
| `commentsModeration` | `string` | No | Moderation policy: `all`, `first_time`, or `none` |
| `commentsClosedAfterDays` | `integer` | No | Close comments after this many days; `0` keeps them open |
| `commentsAutoApproveUsers` | `boolean` | No | Automatically approve comments from authenticated users |
| `editLocking` | `boolean` | No | Whether opening an entry takes an edit lock that refuses other editors' writes |

**Scope:** `schema:write` | **Minimum role:** Admin

Expand Down
45 changes: 45 additions & 0 deletions docs/src/content/docs/reference/rest-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,51 @@ Content-Type: application/json
}
```

### Entry Edit Lock

```http
GET /_emdash/api/content/:collection/:id/lock
POST /_emdash/api/content/:collection/:id/lock
DELETE /_emdash/api/content/:collection/:id/lock
```

`POST` takes the lock for the caller, or reports who is holding it. `GET` reports
the same without touching the lease.

```json
{
"success": true,
"data": {
"enabled": true,
"heldByCaller": false,
"holder": {
"userId": "01JB...",
"userName": "Ada",
"acquiredAt": "2026-05-01T09:12:04.117Z",
"expiresAt": "2026-05-01T09:19:04.117Z"
}
}
}
```

`enabled` is `false` when the collection has edit locking switched off, and no
lock is taken. The lease lasts seven minutes; repeating the `POST` and every save
on the entry extend it. Send `{ "takeover": true }` to claim the lock from
whoever holds it; their next `POST` or save reports the new holder.

Send `{ "token": "<opaque string>" }` on `POST` to identify the editing session,
and `?token=` on `DELETE`. With a token, only the session that last claimed the
lock releases it, so a second tab of the same account keeps the lock when the
first tab closes. `DELETE` releases the lock only when the caller holds it, and
reports `{ "released": false }` otherwise.

While another user holds a live lock, `PUT`, `DELETE`, `/publish`, `/unpublish`,
`/schedule` and `/discard-draft` on that entry return `409 ENTRY_LOCKED`. The
`error.message` names the holder and `error.details` carries their `userId`,
`userName`, `acquiredAt` and `expiresAt`. Send `"overrideLock": true` in the
request body to write anyway, or `?overrideLock=true` on the `DELETE` routes,
which have no body.

### Delete Content

```http
Expand Down
1 change: 1 addition & 0 deletions docs/src/content/docs/themes/seed-files.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ Each collection definition creates a content type in the database:
| `supports` | `array` | No | Features: `"drafts"`, `"revisions"` |
| `hidden` | `boolean`| No | Omit the collection's admin sidebar entry |
| `sortOrder` | `number` | No | Position in the admin sidebar (ascending) |
| `editLocking` | `boolean`| No | Take an edit lock when an entry is opened (default `true`) |
| `fields` | `array` | Yes | Field definitions |

<Aside type="tip" title="Hiding a collection from the sidebar">
Expand Down
139 changes: 139 additions & 0 deletions e2e/tests/entry-lock.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
/**
* Entry edit lock E2E tests.
*
* Covers the lease mechanics the admin depends on: opening an entry takes the
* lock, typing extends it rather than replacing it, and leaving the editor
* hands it back.
*/

import { test, expect } from "../fixtures";

interface LockStatus {
enabled: boolean;
heldByCaller: boolean;
holder: { userId: string; userName: string | null; acquiredAt: string; expiresAt: string } | null;
}

test.describe("Entry edit lock", () => {
let collectionSlug: string;
let postId: string;
let headers: Record<string, string>;
let baseUrl: string;

async function readLock(): Promise<LockStatus> {
const response = await fetch(
`${baseUrl}/_emdash/api/content/${collectionSlug}/${postId}/lock`,
{ headers },
);
const body = (await response.json()) as { data: LockStatus };
return body.data;
}

test.beforeEach(async ({ admin, serverInfo }) => {
await admin.devBypassAuth();

baseUrl = serverInfo.baseUrl;
headers = {
"Content-Type": "application/json",
Authorization: `Bearer ${serverInfo.token}`,
"X-EmDash-Request": "1",
Origin: baseUrl,
};

collectionSlug = `lock_${Date.now()}`;
await fetch(`${baseUrl}/_emdash/api/schema/collections`, {
method: "POST",
headers,
body: JSON.stringify({
slug: collectionSlug,
label: "Lock Test",
labelSingular: "Lock Test",
supports: ["revisions", "drafts"],
}),
});
await fetch(`${baseUrl}/_emdash/api/schema/collections/${collectionSlug}/fields`, {
method: "POST",
headers,
body: JSON.stringify({ slug: "title", type: "string", label: "Title", required: true }),
});

const createRes = await fetch(`${baseUrl}/_emdash/api/content/${collectionSlug}`, {
method: "POST",
headers,
body: JSON.stringify({ data: { title: "Original" }, slug: "lock-test" }),
});
const created = (await createRes.json()) as { data?: { item?: { id: string } } };
postId = created.data!.item!.id;
});

test.afterEach(async () => {
await fetch(`${baseUrl}/_emdash/api/content/${collectionSlug}/${postId}`, {
method: "DELETE",
headers,
}).catch(() => {});
await fetch(`${baseUrl}/_emdash/api/schema/collections/${collectionSlug}`, {
method: "DELETE",
headers,
}).catch(() => {});
});

test("opening an entry takes the lock and leaving hands it back", async ({ admin }) => {
expect(await readLock()).toMatchObject({ enabled: true, holder: null });

await admin.goToEditContent(collectionSlug, postId);
await admin.waitForLoading();
await expect(admin.page.locator("#field-title")).toHaveValue("Original");

await expect
.poll(async () => (await readLock()).holder?.userName, { timeout: 10000 })
.toBe("Dev Admin");

// Leave through the editor's own back link: a full page load tears the
// document down without unmounting, and the lease has to expire instead.
await admin.page.getByRole("link", { name: /Back to .* list/ }).click();
await admin.waitForLoading();

await expect.poll(async () => (await readLock()).holder, { timeout: 10000 }).toBeNull();
});

test("autosaving extends the lease instead of dropping it", async ({ admin }) => {
const contentUrl = `/_emdash/api/content/${collectionSlug}/${postId}`;

await admin.goToEditContent(collectionSlug, postId);
await admin.waitForLoading();
const titleInput = admin.page.locator("#field-title");
await expect(titleInput).toHaveValue("Original");

await expect
.poll(async () => (await readLock()).holder !== null, { timeout: 10000 })
.toBe(true);
const before = (await readLock()).holder!.expiresAt;

const autosave = admin.page.waitForResponse(
(res) => res.url().includes(contentUrl) && res.request().method() === "PUT",
{ timeout: 10000 },
);
await titleInput.fill("Edited while holding the lock");
await autosave;

const after = await readLock();
expect(after.holder).not.toBeNull();
expect(after.holder!.expiresAt > before).toBe(true);
});

test("takes no lock on a collection with locking switched off", async ({ admin }) => {
await fetch(`${baseUrl}/_emdash/api/schema/collections/${collectionSlug}`, {
method: "PUT",
headers,
body: JSON.stringify({ editLocking: false }),
});

await admin.goToEditContent(collectionSlug, postId);
await admin.waitForLoading();
await expect(admin.page.locator("#field-title")).toHaveValue("Original");

const status = await readLock();
expect(status.enabled).toBe(false);
expect(status.holder).toBeNull();
});
});
Loading
Loading