Skip to content
Merged
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
19 changes: 19 additions & 0 deletions .changeset/11773-first-save-pinned.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@object-ui/app-shell': patch
'@object-ui/data-objectstack': minor
---

Studio's first draft save after opening an item no longer replaces a draft that was saved elsewhere in the meantime (objectui#11773).

Until now an editor's draft saves were pinned only from its second save on: the `/meta` draft read served no version, so two editors who each opened an item and saved it once overwrote each other's draft in silence. The `@objectstack/*` 18 draft read serves the version, and each editor's load now pins its first save to what it read:

- an item with a pending draft is saved with `If-Match` and that draft's version;
- an item with no pending draft is saved with `If-None-Match: *`, so a draft another editor created in the meantime is refused, not overwritten.

The editors read the draft at the package their saves write to (`?package=`), since a read's version describes the row a save to the same address compares against. This covers the metadata editor, the permission matrix's package door, the Studio pillars (objects, flows, interfaces and app navigation), the list-view panel and the hooks panel.

A create sends `If-None-Match: *`, so creating an item under a name that already holds a draft is refused instead of writing the new skeleton over it.

In the conflict dialog, **Overwrite** now re-sends the draft pinned to the version the refusal names (`currentVersion`), so a save that lands while the dialog is open is refused and asked about again rather than overwritten. A server whose refusal names no `currentVersion` is overwritten unpinned, as before.

`@object-ui/data-objectstack`: `MetadataClientSaveOptions` gains `ifNoneMatch?: '*'`, sent as `If-None-Match: *`. It never travels with `ifMatch`: `MetadataClient.save` throws before sending a request that sets both. The `ifMatch` docblock now names the token's real sources (the item read's `version`, a save receipt's `version`, a conflict's `currentVersion`) instead of "the `checksum` returned by the last read".
328 changes: 263 additions & 65 deletions packages/app-shell/src/views/metadata-admin/DraftConflictDialog.test.tsx

Large diffs are not rendered by default.

238 changes: 172 additions & 66 deletions packages/app-shell/src/views/metadata-admin/DraftConflictDialog.tsx

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#11773 — the permission matrix's PACKAGE door pins its first draft
* save to what its load read.
*
* The load reads the set's draft at the package the save writes
* (`getDraft(type, name, { packageId })`), and its answer pins the first
* autosave: `If-Match` with the version a pending draft's read served, or
* `If-None-Match: *` when no draft was pending. The ENVIRONMENT door writes live
* config, reads no draft and pins nothing, as before (the control).
*
* The client double answers as the published `@objectstack/*` 18.0.0-next.0
* door was measured to (readings on the pull request): the draft read serves
* `version`, and no draft at all is `null`.
*/

import '@testing-library/jest-dom/vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';

const PUBLISHED = {
name: 'sales_perms',
label: 'Sales',
objects: { a_account: { allowRead: true, allowCreate: true } },
fields: {},
};

const LAYERED = { effective: PUBLISHED, code: null, overlay: null, overlayScope: null };

interface Server {
draft: { body: Record<string, unknown>; version: string } | null;
draftReads: unknown[][];
savedOpts: Array<Record<string, unknown> | undefined>;
}

type FakeClient = Record<string, (...args: never[]) => Promise<unknown>>;

function makeClient(server: Server): FakeClient {
return {
layered: async () => LAYERED,
getDraft: async (...args: unknown[]) => {
server.draftReads.push(args);
return server.draft ? { type: 'permission', name: 'sales_perms', item: server.draft.body, version: server.draft.version } : null;
},
list: async (type: string) => (type === 'object' ? [{ item: { name: 'a_account' } }] : type === 'permission' ? [PUBLISHED] : []),
get: async () => null,
save: async (_type: string, _name: string, payload: Record<string, unknown>, saveOpts?: Record<string, unknown>) => {
server.savedOpts.push(saveOpts);
return { success: true, version: 'hmac-sha256:receipt', state: 'draft', payload };
},
};
}

let clientImpl: FakeClient;

vi.mock('./useMetadata', () => ({
useMetadataClient: () => clientImpl,
useMetadataTypes: () => ({
loading: false,
error: null,
entries: [{ type: 'permission', label: 'Permission', allowEnvironmentOverlay: true }],
}),
}));

vi.mock('./AssignedUsersSection', () => ({ AssignedUsersSection: () => null }));

import { PermissionMatrixEditPage } from './PermissionMatrixEditor';

afterEach(cleanup);

const AUTOSAVE = { timeout: 4000 };

function renderDoor(packageId: string | undefined) {
return render(
<MemoryRouter>
<PermissionMatrixEditPage type="permission" name="sales_perms" packageId={packageId} />
</MemoryRouter>,
);
}

const accountRow = () => screen.getByText('a_account').closest('tr')!;

describe('the package door pins its first draft save to its load (objectui#11773)', () => {
it('no pending draft: the first autosave sends If-None-Match: *, read at the package the save writes', async () => {
const server: Server = { draft: null, draftReads: [], savedOpts: [] };
clientImpl = makeClient(server);
renderDoor('app.a');
await screen.findByText('a_account');

fireEvent.click(within(accountRow()).getByRole('button', { name: 'None' }));
await waitFor(() => expect(server.savedOpts).toHaveLength(1), AUTOSAVE);
expect(server.savedOpts[0]).toMatchObject({ mode: 'draft', packageId: 'app.a', ifNoneMatch: '*' });
expect(server.savedOpts[0]).not.toHaveProperty('ifMatch');
expect(server.draftReads).toContainEqual(['permission', 'sales_perms', { packageId: 'app.a' }]);
});

it('a pending draft: the first autosave sends If-Match with the version the read served', async () => {
const server: Server = { draft: { body: PUBLISHED, version: 'hmac-sha256:pending' }, draftReads: [], savedOpts: [] };
clientImpl = makeClient(server);
renderDoor('app.a');
await screen.findByText('a_account');

fireEvent.click(within(accountRow()).getByRole('button', { name: 'None' }));
await waitFor(() => expect(server.savedOpts).toHaveLength(1), AUTOSAVE);
expect(server.savedOpts[0]).toMatchObject({ mode: 'draft', packageId: 'app.a', ifMatch: 'hmac-sha256:pending' });
expect(server.savedOpts[0]).not.toHaveProperty('ifNoneMatch');
});

it('control: the environment door reads no draft and its live Save carries no pin, as before', async () => {
const server: Server = { draft: null, draftReads: [], savedOpts: [] };
clientImpl = makeClient(server);
renderDoor(undefined);
await screen.findByText('a_account');

fireEvent.click(within(accountRow()).getByRole('button', { name: 'None' }));
fireEvent.click(screen.getByRole('button', { name: /^Save$/ }));
await waitFor(() => expect(server.savedOpts).toHaveLength(1));
expect(server.savedOpts[0]).toEqual({ force: false });
expect(server.draftReads).toEqual([]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ import { PageShell } from './PageShell.js';
import { HistoryPanel } from './ResourceHistoryPage.js';
import { useMetadataClient, useMetadataTypes, type RichMetadataTypeEntry } from './useMetadata.js';
// objectui#11773 — the package door's draft save sends the version it was built on.
import { useDraftSaveGuard } from './DraftConflictDialog.js';
import { readDraftForSave, useDraftSaveGuard } from './DraftConflictDialog.js';
// objectui#11787 — the package door autosaves through the pillars' own autosave.
import { useDraftAutoSave, type DraftSend } from '../studio-design/useDraftAutoSave.js';
import { useDisplayLocale } from '@object-ui/i18n';
Expand Down Expand Up @@ -478,12 +478,14 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved,
// is enough: the anchor is a JSON snapshot, so a copy is anchor-identical.
setDraft({ ...next });
}, []);
// objectui#11773 — the version the package door's draft was saved at, sent as
// `If-Match` by its next draft save. A conflict's "reload" re-runs the load.
// objectui#11773 — the pin of the row the package door's draft was built on
// (the load's draft read, then each save's receipt), sent by its next draft
// save. A conflict's "reload" re-runs the load.
const [reloadNonce, setReloadNonce] = React.useState(0);
const reloadSet = React.useCallback(() => setReloadNonce((n) => n + 1), []);
const {
save: saveVersioned,
recordRead: recordSetRead,
forget: forgetSetVersion,
dialog: draftConflictDialog,
} = useDraftSaveGuard(client, reloadSet);
Expand Down Expand Up @@ -577,9 +579,12 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved,
// metadata, so surface the PENDING draft if one exists — otherwise a
// just-saved-not-yet-published edit would appear lost on reopen. Draft
// reads return the `{ type, name, item }` envelope; `null` = no draft.
const draftRead = packageId
? client.getDraft<{ item?: PermissionSetDraft } | PermissionSetDraft>(type, name, { packageId }).catch(() => null)
// objectui#11773 — read at the package door's save address, with the
// pin it serves for that address (the package door's draft save).
const draftReadForSave = packageId
? readDraftForSave<{ item?: PermissionSetDraft } | PermissionSetDraft>(client, type, name, packageId)
: Promise.resolve(null);
const draftRead = draftReadForSave.then((read) => read?.answer ?? null);
const readLayered = () => client.layered<PermissionSetDraft>(type, name).catch(() => null);
// objectui#11799 — a set "+ New" created and nobody published has a
// draft and no layer, and `GET …/layers` answers 404 for it. Under the
Expand All @@ -597,7 +602,7 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved,
packageId && type === 'permission' && publishedSets
? publishedSets.then((rows) => !(rows || []).some((r) => r?.name === name), () => false)
: Promise.resolve(false);
const [lay, objList, pendingDraft] = await Promise.all([
const [lay, objList, pendingRead] = await Promise.all([
packageId
? neverPublished.then((unlisted) =>
unlisted ? draftRead.then((d) => (d ? null : readLayered())) : readLayered(),
Expand All @@ -606,9 +611,10 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved,
// In package scope, list only the objects this package declares
// (ADR-0086 P0) — otherwise the whole environment leaks into the panel.
client.list<any>('object', packageId ? { packageId } : {}).catch(() => []),
draftRead,
draftReadForSave,
]);
if (cancelled) return;
const pendingDraft = pendingRead?.answer ?? null;
// ARTIFACT tier input (objectui#4518) — the `code` layer, and its
// `provenance` (objectui#4526), of the SAME envelope the display
// baseline comes from, so the writability verdict and the body on
Expand Down Expand Up @@ -665,8 +671,11 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved,
} else {
resetDraftBaseline(full);
}
// objectui#11773 — a read serves no version: the next save is unpinned.
forgetSetVersion();
// objectui#11773 — the package door's next draft save is pinned to what
// the draft read served. The environment door reads no draft and its
// live write is not pinned.
if (pendingRead && packageId) recordSetRead(type, name, packageId, pendingRead.pin);
else forgetSetVersion();
} catch (err: any) {
setError(err?.message ?? String(err));
} finally {
Expand All @@ -676,7 +685,7 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved,
return () => {
cancelled = true;
};
}, [client, type, name, packageId, publishNonce, resetDraftBaseline, reloadNonce, forgetSetVersion]);
}, [client, type, name, packageId, publishNonce, resetDraftBaseline, reloadNonce, recordSetRead, forgetSetVersion]);

/* ── Lazy-load fields when an object is expanded ─────────── */
async function ensureFields(objectName: string) {
Expand Down
Loading
Loading