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
14 changes: 14 additions & 0 deletions .changeset/25934-reader-record-lock.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@object-ui/app-shell': patch
---

fix(app-shell): a record locked by an approval is shown locked to a reader outside that approval

Clause-②: no
Metadata-protocol: none

A member who can read a record but is not on its approval (not an approver, not the submitter, no override) was offered inline edit while a `lockRecord: true` approval held the record, and the save was then refused with `409 RECORD_LOCKED`. The approvals list serves a request only to its participants, so the record page read that member's empty list as "no approval is running".

When the list finds no pending request, the record page now also reads the record's own lock, `client.approvals.getRecordLock(object, recordId)`, which the server answers to every caller who can read the record with `{ locked }` and nothing else. A locked answer shows the record locked: Edit is disabled, inline edit is not offered, and the "Locked for approval" band is drawn without a recall button, because a reader who cannot see the request did not submit it. The reader still sees nothing of the request itself (approvers, comments, attachments).

Unchanged: a pending request the viewer can see still decides with its own node's `lockRecord`, and a composition without the approvals service (the lock read answers `501`) still opens an answered empty record editable.
66 changes: 61 additions & 5 deletions ui/packages/app-shell/src/hooks/useRecordApprovals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,18 @@
* approvals service still mounts the list route and answers it
* `200 { data: [] }`: that is an answered read with no request, not an absent
* route, and the result's `answered` says which one happened (objectui#12220).
*
* That list serves a request only to its participants (slate, submitter,
* override actor), so for anyone else an answered empty read cannot tell "no
* approval is running" from "one is running that you may not see". When the
* list finds no pending request, the hook also asks the server whether the
* record is held by a locking approval (`client.approvals.getRecordLock`,
* answered to every caller who can read the record — objectstack#25934 ruling
* A) and hands the answer back as `recordLock`.
*/

import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
import { useAdapter } from '../providers/AdapterProvider.js';
import { bearerAuthHeaders } from '../utils/authToken.js';
import type { DecisionOutputDef } from '../utils/decisionOutputParams.js';

Expand Down Expand Up @@ -278,6 +287,18 @@ interface UseRecordApprovalsResult {
*/
pendingRequest: ApprovalRequestLite | null;
latestRequest: ApprovalRequestLite | null;
/**
* The server's record lock (`GET /approvals/records/:object/:recordId/lock`,
* objectstack#25934), read only when the list found no pending request:
* `{ locked: true }` means a pending approval whose node locks the record is
* running, though this viewer cannot see its request. `null` when there is
* no answer to give: a pending request was found (it decides, with its own
* `lock_record`), no adapter is mounted, or the read has never answered — a
* composition without the approvals service answers it 501, so there the
* record is not locked by this signal. A later failed read keeps the last
* answer, as the list read does.
*/
recordLock: { locked: boolean } | null;
refresh: () => Promise<void>;
}

Expand Down Expand Up @@ -415,6 +436,8 @@ interface ApprovalsSnapshot {
answered: boolean;
/** Rows as the server sent them, the pending one enriched. Unsorted. */
rows: ApprovalRequestLite[];
/** The server's record lock, when the rows hold no pending request. */
recordLock: { locked: boolean } | null;
}

const EMPTY_ROWS: ApprovalRequestLite[] = [];
Expand All @@ -423,6 +446,7 @@ const INITIAL_SNAPSHOT: ApprovalsSnapshot = Object.freeze({
available: true,
answered: false,
rows: EMPTY_ROWS,
recordLock: null,
}) as ApprovalsSnapshot;

interface ApprovalsEntry {
Expand Down Expand Up @@ -467,8 +491,16 @@ function holdEntry(key: string): () => void {
};
}

/** Reads the server's record lock for the record a read is for. */
type RecordLockReader = () => Promise<{ locked: boolean }>;

/** Send the record's read and publish its answer to every consumer of the key. */
function startRead(key: string, objectName: string, recordId: string): Promise<void> {
function startRead(
key: string,
objectName: string,
recordId: string,
readLock: RecordLockReader | null,
): Promise<void> {
const seq = ++readSeq;
latestSeq.set(key, seq);
const entry = entries.get(key);
Expand All @@ -484,11 +516,26 @@ function startRead(key: string, objectName: string, recordId: string): Promise<v
// header — so exactly one follow-up read, never one per row.
const pending = rows.find((r) => r.status === 'pending');
const full = pending ? await fetchProgressEnrichment(pending) : null;
// A pending row the viewer can see decides the lock with its own node's
// `lock_record`. Without one, ask the record itself (objectstack#25934):
// the list hides a request from a non-participant, the lock does not.
let recordLock: { locked: boolean } | null = null;
if (!pending && readLock) {
recordLock = entries.get(key)?.snapshot.recordLock ?? null;
try {
const lock = await readLock();
recordLock = { locked: lock?.locked === true };
} catch {
// A read that never answered (501 without the approvals service)
// leaves none; a failure after an answer keeps it, as the rows are.
}
}
if (latestSeq.get(key) !== seq) return;
publish(key, {
rows: full ? rows.map((r) => (r === pending ? full : r)) : rows,
available: true,
answered: true,
recordLock,
});
} catch (err: any) {
if (latestSeq.get(key) !== seq) return;
Expand Down Expand Up @@ -543,6 +590,14 @@ export function useRecordApprovals(
// for the render, mirrored into a ref the mount effect and `refresh` read.
const [unavailable, setUnavailable] = useState(false);
const unavailableRef = useRef(false);
// The record-lock read goes through the SDK (`client.approvals.getRecordLock`),
// so it needs the adapter's client; with no adapter mounted it is skipped.
const adapter = useAdapter();
const lockReader = useCallback(
(object: string, id: string): RecordLockReader | null =>
adapter ? () => adapter.getClient().approvals.getRecordLock(object, id) : null,
[adapter],
);

const subscribe = useCallback((l: () => void) => subscribeKey(readKey, l), [readKey]);
const getSnapshot = useCallback(
Expand All @@ -565,19 +620,19 @@ export function useRecordApprovals(
// read only when neither exists. The entry may be new while a read is in
// flight (StrictMode's replayed mount): that read publishes into it.
if (entry && !entry.read && !inFlight.has(readKey) && !unavailableRef.current) {
void startRead(readKey, objectName, recordId);
void startRead(readKey, objectName, recordId, lockReader(objectName, recordId));
} else if (entry && inFlight.has(readKey)) {
entry.read = true;
if (!entry.snapshot.loading) publish(readKey, { loading: true });
}
return release;
}, [readKey, objectName, recordId]);
}, [readKey, objectName, recordId, lockReader]);

const refresh = useCallback(async () => {
if (!objectName || !recordId || !readKey) return;
if (unavailableRef.current) return;
await startRead(readKey, objectName, recordId);
}, [objectName, recordId, readKey]);
await startRead(readKey, objectName, recordId, lockReader(objectName, recordId));
}, [objectName, recordId, readKey, lockReader]);

const requests = readKey === null ? EMPTY_ROWS : snapshot.rows;
const loading = snapshot.loading;
Expand Down Expand Up @@ -613,6 +668,7 @@ export function useRecordApprovals(
requests: sortedRequests,
pendingRequest,
latestRequest,
recordLock: readKey === null ? null : snapshot.recordLock,
refresh,
};
}
Loading
Loading