smoodly 0.0.25 → 0.0.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -0
- package/dist/admin/editor/EditorView.js +81 -17
- package/dist/admin/editor/autosave.d.ts +4 -1
- package/dist/admin/editor/autosave.js +24 -1
- package/dist/admin/editor/global-edit.d.ts +5 -1
- package/dist/admin/editor/global-edit.js +15 -5
- package/dist/admin/editor/useGlobalEdit.d.ts +1 -1
- package/dist/admin/editor/useGlobalEdit.js +1 -1
- package/dist/admin/next/create-admin.d.ts +4 -0
- package/dist/admin/next/create-admin.js +3 -1
- package/dist/admin/next/index.d.ts +1 -0
- package/dist/admin/ops-impl.js +70 -24
- package/dist/admin/ops.d.ts +22 -12
- package/dist/admin/shell/AdminApp.d.ts +3 -1
- package/dist/admin/shell/AdminApp.js +20 -2
- package/dist/admin/shell/EntriesList.js +5 -1
- package/dist/admin/shell/EntryForm.js +44 -7
- package/dist/admin/shell/GlobalForm.js +46 -7
- package/dist/admin/shell/GlobalList.js +6 -2
- package/dist/admin/shell/PagesList.js +5 -1
- package/dist/admin/shell/presence-context.d.ts +9 -0
- package/dist/admin/shell/presence-context.js +35 -0
- package/dist/admin/shell/presence-realtime.d.ts +3 -0
- package/dist/admin/shell/presence-realtime.js +22 -0
- package/dist/admin/shell/presence.d.ts +51 -0
- package/dist/admin/shell/presence.js +90 -0
- package/dist/admin/shell/stale.d.ts +28 -0
- package/dist/admin/shell/stale.js +17 -0
- package/dist/admin/ui/LockIcon.d.ts +4 -0
- package/dist/admin/ui/LockIcon.js +12 -0
- package/dist/admin/ui/PresenceLine.d.ts +6 -0
- package/dist/admin/ui/PresenceLine.js +11 -0
- package/dist/admin/ui/StaleBanner.d.ts +5 -0
- package/dist/admin/ui/StaleBanner.js +9 -0
- package/dist/admin/ui/primitives.js +1 -0
- package/dist/admin/ui/theme.js +4 -0
- package/dist/draft-token.d.ts +31 -0
- package/dist/draft-token.js +41 -0
- package/dist/entry-store.d.ts +11 -8
- package/dist/entry-store.js +24 -10
- package/dist/env.d.ts +9 -0
- package/dist/env.js +19 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +2 -1
- package/dist/schema-version.d.ts +1 -1
- package/dist/schema-version.js +1 -1
- package/dist/sql-space.js +11 -0
- package/dist/sql.js +11 -0
- package/dist/store.d.ts +11 -8
- package/dist/store.js +26 -8
- package/dist/supabase-entry-store.d.ts +15 -5
- package/dist/supabase-entry-store.js +87 -22
- package/dist/supabase-store.d.ts +15 -5
- package/dist/supabase-store.js +60 -20
- package/migrations/20260926130000_smoodly_v2.sql +26 -0
- package/package.json +1 -1
|
@@ -31,6 +31,12 @@ import { GlobalFields } from "./GlobalFields.js";
|
|
|
31
31
|
import { localeFromUrl, writeLocaleToUrl } from "../editor/locale-url.js";
|
|
32
32
|
import { useLeaveGuard } from "./router.js";
|
|
33
33
|
import { confirm, DISCARD_CONFIRM } from "../ui/confirm.js";
|
|
34
|
+
import { draftToken } from "../../draft-token.js";
|
|
35
|
+
import { isStale, staleInfoOf } from "./stale.js";
|
|
36
|
+
import { StaleBanner } from "../ui/StaleBanner.js";
|
|
37
|
+
import { usePresence, usePresenceState, useSavedElsewhere, useTrackPresence } from "./presence-context.js";
|
|
38
|
+
import { editorsOf } from "./presence.js";
|
|
39
|
+
import { PresenceLine } from "../ui/PresenceLine.js";
|
|
34
40
|
export function GlobalForm({ ops, registry, name }) {
|
|
35
41
|
const adminHref = useAdminHref();
|
|
36
42
|
const item = registry.globals.find((s) => s.name === name);
|
|
@@ -48,15 +54,29 @@ export function GlobalForm({ ops, registry, name }) {
|
|
|
48
54
|
const [reloadKey, setReloadKey] = useState(0);
|
|
49
55
|
const [history, setHistory] = useState(null);
|
|
50
56
|
const [snapshot, setSnapshot] = useState(null);
|
|
57
|
+
const [stale, setStale] = useState(null);
|
|
51
58
|
const formRef = useRef(null);
|
|
52
59
|
const fieldsRef = useRef(fields);
|
|
53
60
|
fieldsRef.current = fields;
|
|
54
|
-
const
|
|
61
|
+
const recordRef = useRef(record);
|
|
62
|
+
recordRef.current = record;
|
|
63
|
+
/** The token of the row on screen — what every guarded op sends (spec 2026-09-26 concurrency §1). */
|
|
64
|
+
const token = () => { const row = recordRef.current?.locales[locale]; return row ? draftToken(row) : null; };
|
|
65
|
+
// Presence (spec 2026-09-26 concurrency §5): this tab on this item, the others on it, every ack announced.
|
|
66
|
+
const target = { kind: "global", id: name, locale };
|
|
67
|
+
useTrackPresence(target);
|
|
68
|
+
const { others } = usePresenceState();
|
|
69
|
+
const presence = usePresence();
|
|
70
|
+
const { saveState, mark, flush, reset } = useDraftAutosave({
|
|
55
71
|
read: () => fieldsRef.current,
|
|
56
72
|
persist: async (value) => {
|
|
57
|
-
const r = await ops.globals.save(name, locale, value);
|
|
58
|
-
if (r.ok)
|
|
73
|
+
const r = await ops.globals.save(name, locale, value, token());
|
|
74
|
+
if (r.ok) {
|
|
59
75
|
setRecord(r.data);
|
|
76
|
+
presence?.announce(target, r.data.locales[locale]?.draftSavedAt ?? null);
|
|
77
|
+
}
|
|
78
|
+
else if (isStale(r))
|
|
79
|
+
setStale(staleInfoOf(r));
|
|
60
80
|
return r;
|
|
61
81
|
},
|
|
62
82
|
onSaved: () => setDirty(false),
|
|
@@ -77,6 +97,7 @@ export function GlobalForm({ ops, registry, name }) {
|
|
|
77
97
|
setFields(null);
|
|
78
98
|
setFieldErrors({});
|
|
79
99
|
setDirty(false);
|
|
100
|
+
setStale(null);
|
|
80
101
|
setTouched(false);
|
|
81
102
|
ops.globals.get(name, locale).then((r) => {
|
|
82
103
|
if (cancelled)
|
|
@@ -97,6 +118,10 @@ export function GlobalForm({ ops, registry, name }) {
|
|
|
97
118
|
const row = record?.locales[locale];
|
|
98
119
|
const status = row ? localeStatusOf(row) : null;
|
|
99
120
|
const fail = (r) => {
|
|
121
|
+
if (isStale(r)) {
|
|
122
|
+
setStale(staleInfoOf(r));
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
100
125
|
if (r.code === "validation" && r.fields && r.fields.length > 0) {
|
|
101
126
|
setFieldErrors(Object.fromEntries(r.fields.map((f) => [f.field, f.message])));
|
|
102
127
|
setNotice({ tone: "warn", text: r.message, fields: r.fields });
|
|
@@ -131,7 +156,7 @@ export function GlobalForm({ ops, registry, name }) {
|
|
|
131
156
|
}
|
|
132
157
|
let r;
|
|
133
158
|
try {
|
|
134
|
-
r = await ops.globals.setStatus(name, locale, "published");
|
|
159
|
+
r = await ops.globals.setStatus(name, locale, "published", token());
|
|
135
160
|
}
|
|
136
161
|
catch {
|
|
137
162
|
r = { ok: false, code: "internal", message: "Couldn't reach the server." };
|
|
@@ -142,6 +167,7 @@ export function GlobalForm({ ops, registry, name }) {
|
|
|
142
167
|
return;
|
|
143
168
|
}
|
|
144
169
|
setRecord(r.data);
|
|
170
|
+
presence?.announce(target, r.data.locales[locale]?.draftSavedAt ?? null);
|
|
145
171
|
setFieldErrors({});
|
|
146
172
|
setPublished(true);
|
|
147
173
|
setTimeout(() => setPublished(false), 1600);
|
|
@@ -174,7 +200,7 @@ export function GlobalForm({ ops, registry, name }) {
|
|
|
174
200
|
}
|
|
175
201
|
let r;
|
|
176
202
|
try {
|
|
177
|
-
r = await ops.globals.discardDraft(name, locale);
|
|
203
|
+
r = await ops.globals.discardDraft(name, locale, token());
|
|
178
204
|
}
|
|
179
205
|
catch {
|
|
180
206
|
r = { ok: false, code: "internal", message: "Couldn't reach the server." };
|
|
@@ -185,6 +211,7 @@ export function GlobalForm({ ops, registry, name }) {
|
|
|
185
211
|
return;
|
|
186
212
|
}
|
|
187
213
|
setRecord(r.data);
|
|
214
|
+
presence?.announce(target, r.data.locales[locale]?.draftSavedAt ?? null);
|
|
188
215
|
setReloadKey((k) => k + 1);
|
|
189
216
|
setNotice({ tone: "ok", text: "Changes discarded." });
|
|
190
217
|
};
|
|
@@ -210,13 +237,25 @@ export function GlobalForm({ ops, registry, name }) {
|
|
|
210
237
|
else
|
|
211
238
|
fail(r);
|
|
212
239
|
};
|
|
240
|
+
/** Load their version (spec 2026-09-26 concurrency §4): drop the frozen state and re-get through the ops seam. */
|
|
241
|
+
const loadTheirs = () => { setStale(null); reset(); setReloadKey((k) => k + 1); };
|
|
242
|
+
// Someone else saved this item (§5): clean → their version silently; unsaved edits → the freeze at once.
|
|
243
|
+
useSavedElsewhere(target, (event) => {
|
|
244
|
+
if (stale)
|
|
245
|
+
return;
|
|
246
|
+
if (dirty || saveState !== "saved")
|
|
247
|
+
setStale({ by: event.email, at: Date.now() });
|
|
248
|
+
else
|
|
249
|
+
setReloadKey((k) => k + 1);
|
|
250
|
+
});
|
|
213
251
|
const restore = async (versionId) => {
|
|
214
|
-
const r = await ops.globals.restore(name, locale, versionId);
|
|
252
|
+
const r = await ops.globals.restore(name, locale, versionId, token());
|
|
215
253
|
if (!r.ok) {
|
|
216
254
|
fail(r);
|
|
217
255
|
return false;
|
|
218
256
|
}
|
|
219
257
|
setRecord(r.data);
|
|
258
|
+
presence?.announce(target, r.data.locales[locale]?.draftSavedAt ?? null);
|
|
220
259
|
setReloadKey((k) => k + 1);
|
|
221
260
|
setNotice({ tone: "ok", text: "Version restored." });
|
|
222
261
|
return true;
|
|
@@ -259,6 +298,6 @@ export function GlobalForm({ ops, registry, name }) {
|
|
|
259
298
|
trailing: multilingual && (_jsx(LocaleSwitcher, { registry: registry, present: registry.locales.supported, parentLocales: [], value: locale, adding: false, onSwitch: (l) => { void switchLocale(l); }, onAdd: () => undefined })),
|
|
260
299
|
},
|
|
261
300
|
{ label: item.title },
|
|
262
|
-
] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error") && _jsx(SaveIndicator, { state: saveState }), _jsx(PublishControl, { status: status, pending: dirty || saveState !== "saved", busy: busy, flash: published, disabled: !row, onPublish: () => { void publish(); }, onUnpublish: () => { void unpublish(); }, onDiscard: () => { void discard(); }, onHistory: () => { void openHistory(); } })] }) }), history && row && (_jsx(HistoryModal, { rows: history, draftId: row.draftVersionId, onSelect: (v) => { void loadSnapshot(v); }, onClose: () => setHistory(null), onRestore: restore, render: (versionId) => snapshot?.id === versionId ? (_jsxs("fieldset", { disabled: true, className: "sm-history__form", children: [_jsx(GlobalFields, { item: item, groups: contentGroups, fields: snapshot.fields, refOptions: refOptions, multilingual: multilingual, onChange: () => { } }), settingGroups.length > 0 && (_jsxs(_Fragment, { children: [_jsx(SectionLabel, { children: "Settings" }), _jsx(GlobalFields, { item: item, groups: settingGroups, fields: snapshot.fields, refOptions: refOptions, multilingual: multilingual, onChange: () => { } })] }))] })) : _jsx("p", { className: "sm-hint", style: { padding: 16 }, children: "Loading\u2026" }) })), _jsxs("div", { className: "sm-editor", children: [_jsxs("div", { className: "sm-form", ref: formRef, children: [notice && (_jsxs(Banner, { tone: notice.tone, onDismiss: () => setNotice(null), children: [_jsx("p", { style: { margin: 0 }, children: notice.text }), _jsx(FieldErrorList, { fields: notice.fields ?? [], onReveal: (path) => { if (formRef.current)
|
|
301
|
+
] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error") && _jsx(SaveIndicator, { state: saveState }), _jsx(PublishControl, { status: status, pending: dirty || saveState !== "saved", busy: busy, flash: published, disabled: !row, onPublish: () => { void publish(); }, onUnpublish: () => { void unpublish(); }, onDiscard: () => { void discard(); }, onHistory: () => { void openHistory(); } })] }) }), history && row && (_jsx(HistoryModal, { rows: history, draftId: row.draftVersionId, onSelect: (v) => { void loadSnapshot(v); }, onClose: () => setHistory(null), onRestore: restore, render: (versionId) => snapshot?.id === versionId ? (_jsxs("fieldset", { disabled: true, className: "sm-history__form", children: [_jsx(GlobalFields, { item: item, groups: contentGroups, fields: snapshot.fields, refOptions: refOptions, multilingual: multilingual, onChange: () => { } }), settingGroups.length > 0 && (_jsxs(_Fragment, { children: [_jsx(SectionLabel, { children: "Settings" }), _jsx(GlobalFields, { item: item, groups: settingGroups, fields: snapshot.fields, refOptions: refOptions, multilingual: multilingual, onChange: () => { } })] }))] })) : _jsx("p", { className: "sm-hint", style: { padding: 16 }, children: "Loading\u2026" }) })), _jsx(PresenceLine, { names: editorsOf(others, target), noun: "item" }), stale && _jsx(StaleBanner, { info: stale, onReload: loadTheirs }), _jsxs("div", { className: "sm-editor", inert: stale ? true : undefined, children: [_jsxs("div", { className: "sm-form", ref: formRef, children: [notice && (_jsxs(Banner, { tone: notice.tone, onDismiss: () => setNotice(null), children: [_jsx("p", { style: { margin: 0 }, children: notice.text }), _jsx(FieldErrorList, { fields: notice.fields ?? [], onReveal: (path) => { if (formRef.current)
|
|
263
302
|
revealField(formRef.current, path); } })] })), record && (_jsxs("p", { className: "sm-hint", children: [pages.size === 0 ? "Not placed on any page yet." : `Used on ${pages.size} page${pages.size === 1 ? "" : "s"}: `, [...pages].map((id, i) => (_jsxs("span", { children: [i > 0 && ", ", _jsx("a", { href: adminHref(`/pages/${encodeURIComponent(id)}?locale=${encodeURIComponent(locale)}`), children: id })] }, id)))] })), fields !== null ? (_jsxs(_Fragment, { children: [_jsx(GlobalFields, { item: item, groups: contentGroups, fields: fields, errors: fieldErrors, refOptions: refOptions, multilingual: multilingual, onChange: onChange }), settingGroups.length > 0 && (_jsxs(_Fragment, { children: [_jsx(SectionLabel, { children: "Settings" }), _jsx(GlobalFields, { item: item, groups: settingGroups, fields: fields, errors: fieldErrors, refOptions: refOptions, multilingual: multilingual, onChange: onChange })] }))] })) : !notice && _jsx("p", { style: { color: "var(--text-muted)" }, children: "Loading\u2026" })] }), _jsx(ResizeHandle, {}), _jsx("aside", { className: "sm-inspector", children: record && row && (_jsxs("div", { className: "sm-card", children: [_jsx(SectionLabel, { children: "Document info" }), _jsx(InfoRow, { label: "Kind", value: item.section !== undefined ? `Visual · ${item.section}` : "Fields" }), _jsx(InfoRow, { label: "Updated", value: updatedLine(row.updatedAt, row.updatedBy) }), _jsx(InfoRow, { label: "ID", value: record.id, mono: true })] })) })] })] }));
|
|
264
303
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
// The Globals list (global content, spec 2026-09-07 §6; renamed in the
|
|
4
4
|
// UI 2026-09-09, the route is /globals): every registered item is
|
|
5
5
|
// a row, edited on its own page. Nothing here is created or deleted by
|
|
@@ -15,11 +15,15 @@ import { relativeTime } from "../ui/format.js";
|
|
|
15
15
|
import { useAdminHref } from "./base.js";
|
|
16
16
|
import { ErrorPane } from "./ErrorPane.js";
|
|
17
17
|
import { rememberLocale, rememberedLocale } from "./locale-memory.js";
|
|
18
|
+
import { usePresenceState } from "./presence-context.js";
|
|
19
|
+
import { editorsOf } from "./presence.js";
|
|
20
|
+
import { LockIcon } from "../ui/LockIcon.js";
|
|
18
21
|
export function GlobalList({ ops, registry }) {
|
|
19
22
|
const adminHref = useAdminHref();
|
|
20
23
|
const [items, setItems] = useState(null);
|
|
21
24
|
const [error, setError] = useState(null);
|
|
22
25
|
const [locale, setLocale] = useState(() => rememberedLocale(registry.locales));
|
|
26
|
+
const { others } = usePresenceState();
|
|
23
27
|
const multilingual = registry.locales.supported.length > 1;
|
|
24
28
|
const pickLocale = (next) => {
|
|
25
29
|
setLocale(next);
|
|
@@ -43,7 +47,7 @@ export function GlobalList({ ops, registry }) {
|
|
|
43
47
|
key: row.name,
|
|
44
48
|
href: adminHref(`/globals/${encodeURIComponent(row.name)}?locale=${encodeURIComponent(locale)}`),
|
|
45
49
|
cells: [
|
|
46
|
-
|
|
50
|
+
_jsxs("span", { className: "sm-cell sm-cell--title", children: [row.title, _jsx(LockIcon, { names: editorsOf(others, { kind: "global", id: row.name, locale }) })] }, "t"),
|
|
47
51
|
_jsx("span", { className: "sm-cell sm-cell--muted", children: row.flavour }, "k"),
|
|
48
52
|
_jsx("span", { className: "sm-cell sm-cell--muted", children: relativeTime(row.updatedAt) }, "u"),
|
|
49
53
|
_jsx(StatusBadge, { status: statusLabel(row.status) }, "s"),
|
|
@@ -21,7 +21,11 @@ import { rememberLocale, rememberedLocale } from "./locale-memory.js";
|
|
|
21
21
|
import { confirmLiveUrl } from "./live-url.js";
|
|
22
22
|
import { confirm } from "../ui/confirm.js";
|
|
23
23
|
import { PageFormDialog } from "./PageDialogs.js";
|
|
24
|
+
import { usePresenceState } from "./presence-context.js";
|
|
25
|
+
import { editorsOf } from "./presence.js";
|
|
26
|
+
import { LockIcon } from "../ui/LockIcon.js";
|
|
24
27
|
export function PagesList({ ops, registry }) {
|
|
28
|
+
const { others } = usePresenceState();
|
|
25
29
|
const adminHref = useAdminHref();
|
|
26
30
|
const [pages, setPages] = useState(null);
|
|
27
31
|
const [error, setError] = useState(null);
|
|
@@ -243,7 +247,7 @@ export function PagesList({ ops, registry }) {
|
|
|
243
247
|
key: row.id,
|
|
244
248
|
onClick: () => onRow(row),
|
|
245
249
|
cells: [
|
|
246
|
-
_jsxs("span", { className: row.hasChildren ? "sm-cell sm-cell--tree sm-cell--parent" : "sm-cell sm-cell--tree", style: { paddingLeft: depth * 20, ...dim }, children: [_jsx("button", { type: "button", className: "sm-chevron", "aria-label": row.expanded ? "Collapse" : "Expand", "aria-expanded": row.hasChildren ? row.expanded : undefined, style: { visibility: row.hasChildren ? "visible" : "hidden" }, onClick: (e) => { e.stopPropagation(); toggle(row.id); }, onKeyDown: (e) => e.stopPropagation(), children: row.expanded ? "▾" : "▸" }), _jsx("span", { style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: row.title })] }, "t"),
|
|
250
|
+
_jsxs("span", { className: row.hasChildren ? "sm-cell sm-cell--tree sm-cell--parent" : "sm-cell sm-cell--tree", style: { paddingLeft: depth * 20, ...dim }, children: [_jsx("button", { type: "button", className: "sm-chevron", "aria-label": row.expanded ? "Collapse" : "Expand", "aria-expanded": row.hasChildren ? row.expanded : undefined, style: { visibility: row.hasChildren ? "visible" : "hidden" }, onClick: (e) => { e.stopPropagation(); toggle(row.id); }, onKeyDown: (e) => e.stopPropagation(), children: row.expanded ? "▾" : "▸" }), _jsx("span", { style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: row.title }), _jsx(LockIcon, { names: editorsOf(others, { kind: "page", id: row.id, locale }) })] }, "t"),
|
|
247
251
|
row.present
|
|
248
252
|
? _jsx("span", { className: "sm-cell sm-cell--mono", children: row.path }, "p")
|
|
249
253
|
: _jsxs("span", { className: "sm-cell sm-cell--muted", children: ["not in ", locale, " \u2014 click to add"] }, "p"),
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Presence, PresenceState, PresenceTarget, SavedEvent } from "./presence.ts";
|
|
2
|
+
export declare const PresenceProvider: import("react").Provider<Presence | null>;
|
|
3
|
+
export declare const usePresence: () => Presence | null;
|
|
4
|
+
export declare function usePresenceState(): PresenceState;
|
|
5
|
+
/** Track `target` while mounted; null tracks nothing. Keys on the target's three fields. */
|
|
6
|
+
export declare function useTrackPresence(target: PresenceTarget | null): void;
|
|
7
|
+
/** Hear another person's save of exactly `target`. The handler is read at call time
|
|
8
|
+
* (re-subscribed every render on purpose: one Set entry, always fresh state). */
|
|
9
|
+
export declare function useSavedElsewhere(target: PresenceTarget | null, handler: (event: SavedEvent) => void): void;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
// The React side of presence: one controller per signed-in session from AdminApp,
|
|
3
|
+
// the hooks the lists and the three views use.
|
|
4
|
+
import { createContext, useContext, useEffect, useSyncExternalStore } from "react";
|
|
5
|
+
const OFF = { status: "off", others: [] };
|
|
6
|
+
const PresenceContext = createContext(null);
|
|
7
|
+
export const PresenceProvider = PresenceContext.Provider;
|
|
8
|
+
export const usePresence = () => useContext(PresenceContext);
|
|
9
|
+
export function usePresenceState() {
|
|
10
|
+
const p = usePresence();
|
|
11
|
+
return useSyncExternalStore(p ? p.subscribe : () => () => { }, p ? p.getState : () => OFF, () => OFF);
|
|
12
|
+
}
|
|
13
|
+
/** Track `target` while mounted; null tracks nothing. Keys on the target's three fields. */
|
|
14
|
+
export function useTrackPresence(target) {
|
|
15
|
+
const p = usePresence();
|
|
16
|
+
const kind = target?.kind, id = target?.id, locale = target?.locale;
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
if (!p || !kind || !id || !locale)
|
|
19
|
+
return;
|
|
20
|
+
p.track({ kind, id, locale });
|
|
21
|
+
return () => p.track(null);
|
|
22
|
+
}, [p, kind, id, locale]);
|
|
23
|
+
}
|
|
24
|
+
/** Hear another person's save of exactly `target`. The handler is read at call time
|
|
25
|
+
* (re-subscribed every render on purpose: one Set entry, always fresh state). */
|
|
26
|
+
export function useSavedElsewhere(target, handler) {
|
|
27
|
+
const p = usePresence();
|
|
28
|
+
const kind = target?.kind, id = target?.id, locale = target?.locale;
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (!p || !kind || !id || !locale)
|
|
31
|
+
return;
|
|
32
|
+
return p.onSaved((event) => { if (event.kind === kind && event.id === id && event.locale === locale)
|
|
33
|
+
handler(event); });
|
|
34
|
+
});
|
|
35
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
// The real channel behind `createPresence` (spec 2026-09-26 concurrency §5–§6): one
|
|
3
|
+
// supabase-js client on the CONTENT project with its public key (the anon key in
|
|
4
|
+
// self-host, the read key in the cloud), a private channel named after the space,
|
|
5
|
+
// presence keyed by the email. A key that is a JWT is also the realtime token.
|
|
6
|
+
import { createClient } from "@supabase/supabase-js";
|
|
7
|
+
export function connectRealtime(config, email) {
|
|
8
|
+
const client = createClient(config.url, config.key, { auth: { persistSession: false, autoRefreshToken: false } });
|
|
9
|
+
if (config.key.split(".").length === 3)
|
|
10
|
+
void client.realtime.setAuth(config.key);
|
|
11
|
+
const channel = client.channel(`space:${config.spaceId}`, { config: { private: true, presence: { key: email } } });
|
|
12
|
+
return {
|
|
13
|
+
subscribe: (onStatus) => { channel.subscribe((status) => onStatus(status)); },
|
|
14
|
+
track: (state) => channel.track(state),
|
|
15
|
+
untrack: () => channel.untrack(),
|
|
16
|
+
send: (message) => channel.send(message),
|
|
17
|
+
onSync: (listener) => { channel.on("presence", { event: "sync" }, listener); },
|
|
18
|
+
presenceState: () => channel.presenceState(),
|
|
19
|
+
onBroadcast: (event, listener) => { channel.on("broadcast", { event }, (m) => listener(m.payload)); },
|
|
20
|
+
unsubscribe: async () => { await channel.unsubscribe(); await client.removeAllChannels(); },
|
|
21
|
+
};
|
|
22
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export type PresenceTarget = {
|
|
2
|
+
kind: "page" | "entry" | "global";
|
|
3
|
+
id: string;
|
|
4
|
+
locale: string;
|
|
5
|
+
};
|
|
6
|
+
export type PresenceEntry = PresenceTarget & {
|
|
7
|
+
email: string;
|
|
8
|
+
};
|
|
9
|
+
export type SavedEvent = PresenceTarget & {
|
|
10
|
+
email: string;
|
|
11
|
+
savedAt: string | null;
|
|
12
|
+
};
|
|
13
|
+
export type PresenceStatus = "off" | "joining" | "joined" | "failed";
|
|
14
|
+
export type PresenceState = {
|
|
15
|
+
status: PresenceStatus;
|
|
16
|
+
others: PresenceEntry[];
|
|
17
|
+
};
|
|
18
|
+
/** The slice of a Realtime channel presence uses. */
|
|
19
|
+
export type PresenceChannel = {
|
|
20
|
+
subscribe(onStatus: (status: string) => void): void;
|
|
21
|
+
track(state: Record<string, unknown>): Promise<unknown>;
|
|
22
|
+
untrack(): Promise<unknown>;
|
|
23
|
+
send(message: {
|
|
24
|
+
type: "broadcast";
|
|
25
|
+
event: string;
|
|
26
|
+
payload: Record<string, unknown>;
|
|
27
|
+
}): Promise<unknown>;
|
|
28
|
+
onSync(listener: () => void): void;
|
|
29
|
+
/** key (the email) → that person's tabs' states. */
|
|
30
|
+
presenceState(): Record<string, Record<string, unknown>[]>;
|
|
31
|
+
onBroadcast(event: string, listener: (payload: Record<string, unknown>) => void): void;
|
|
32
|
+
unsubscribe(): Promise<unknown>;
|
|
33
|
+
};
|
|
34
|
+
export type Presence = {
|
|
35
|
+
join(): void;
|
|
36
|
+
/** What this tab edits now; null on leaving a view. Re-sent on rejoin. */
|
|
37
|
+
track(target: PresenceTarget | null): void;
|
|
38
|
+
/** After a guarded op acked: tell the others. */
|
|
39
|
+
announce(target: PresenceTarget, savedAt: string | null): void;
|
|
40
|
+
onSaved(listener: (event: SavedEvent) => void): () => void;
|
|
41
|
+
getState(): PresenceState;
|
|
42
|
+
subscribe(listener: () => void): () => void;
|
|
43
|
+
dispose(): void;
|
|
44
|
+
};
|
|
45
|
+
export declare const shortName: (email: string) => string;
|
|
46
|
+
/** The short names editing `target`, other than me, sorted and deduped. */
|
|
47
|
+
export declare function editorsOf(others: PresenceEntry[], target: PresenceTarget): string[];
|
|
48
|
+
export declare function createPresence(opts: {
|
|
49
|
+
email: string;
|
|
50
|
+
connect: () => PresenceChannel;
|
|
51
|
+
}): Presence;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Editing presence (spec 2026-09-26 concurrency §5): who has what open, and a
|
|
2
|
+
// "saved" announcement, over ONE channel per space. Framework-free: the channel
|
|
3
|
+
// is injected (presence-realtime.ts builds the supabase-js one; tests fake it),
|
|
4
|
+
// the React glue is presence-context.tsx. Never awaited by anything — a failed
|
|
5
|
+
// join leaves the admin exactly as without presence.
|
|
6
|
+
const sameTarget = (a, b) => a.kind === b.kind && a.id === b.id && a.locale === b.locale;
|
|
7
|
+
export const shortName = (email) => email.split("@")[0];
|
|
8
|
+
/** The short names editing `target`, other than me, sorted and deduped. */
|
|
9
|
+
export function editorsOf(others, target) {
|
|
10
|
+
return [...new Set(others.filter((o) => sameTarget(o, target)).map((o) => shortName(o.email)))].sort();
|
|
11
|
+
}
|
|
12
|
+
const isTarget = (v) => typeof v === "object" && v !== null
|
|
13
|
+
&& ["page", "entry", "global"].includes(v.kind)
|
|
14
|
+
&& typeof v.id === "string" && typeof v.locale === "string";
|
|
15
|
+
export function createPresence(opts) {
|
|
16
|
+
let state = { status: "off", others: [] };
|
|
17
|
+
let channel = null;
|
|
18
|
+
let current = null;
|
|
19
|
+
const listeners = new Set();
|
|
20
|
+
const savedListeners = new Set();
|
|
21
|
+
const set = (next) => { state = { ...state, ...next }; for (const l of listeners)
|
|
22
|
+
l(); };
|
|
23
|
+
const readOthers = () => {
|
|
24
|
+
if (!channel)
|
|
25
|
+
return [];
|
|
26
|
+
const out = [];
|
|
27
|
+
for (const [email, metas] of Object.entries(channel.presenceState())) {
|
|
28
|
+
if (email === opts.email)
|
|
29
|
+
continue;
|
|
30
|
+
for (const meta of metas)
|
|
31
|
+
if (isTarget(meta))
|
|
32
|
+
out.push({ email, kind: meta.kind, id: meta.id, locale: meta.locale });
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
};
|
|
36
|
+
return {
|
|
37
|
+
join() {
|
|
38
|
+
if (channel)
|
|
39
|
+
return;
|
|
40
|
+
try {
|
|
41
|
+
channel = opts.connect();
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
set({ status: "failed" });
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
set({ status: "joining" });
|
|
48
|
+
channel.onSync(() => set({ others: readOthers() }));
|
|
49
|
+
channel.onBroadcast("saved", (payload) => {
|
|
50
|
+
const { email, savedAt } = payload;
|
|
51
|
+
if (!isTarget(payload) || typeof email !== "string" || email === opts.email)
|
|
52
|
+
return;
|
|
53
|
+
const event = { kind: payload.kind, id: payload.id, locale: payload.locale, email, savedAt: typeof savedAt === "string" ? savedAt : null };
|
|
54
|
+
for (const l of savedListeners)
|
|
55
|
+
l(event);
|
|
56
|
+
});
|
|
57
|
+
channel.subscribe((status) => {
|
|
58
|
+
if (status === "SUBSCRIBED") {
|
|
59
|
+
set({ status: "joined" });
|
|
60
|
+
if (current && channel)
|
|
61
|
+
void channel.track(current).catch(() => { });
|
|
62
|
+
}
|
|
63
|
+
else if (status === "CHANNEL_ERROR" || status === "TIMED_OUT" || status === "CLOSED") {
|
|
64
|
+
set({ status: "failed", others: [] });
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
},
|
|
68
|
+
track(target) {
|
|
69
|
+
current = target;
|
|
70
|
+
if (state.status !== "joined" || !channel)
|
|
71
|
+
return;
|
|
72
|
+
void (target ? channel.track(target) : channel.untrack()).catch(() => { });
|
|
73
|
+
},
|
|
74
|
+
announce(target, savedAt) {
|
|
75
|
+
if (state.status !== "joined" || !channel)
|
|
76
|
+
return;
|
|
77
|
+
void channel.send({ type: "broadcast", event: "saved", payload: { ...target, email: opts.email, savedAt } }).catch(() => { });
|
|
78
|
+
},
|
|
79
|
+
onSaved(listener) { savedListeners.add(listener); return () => { savedListeners.delete(listener); }; },
|
|
80
|
+
getState: () => state,
|
|
81
|
+
subscribe(listener) { listeners.add(listener); return () => { listeners.delete(listener); }; },
|
|
82
|
+
dispose() {
|
|
83
|
+
const c = channel;
|
|
84
|
+
channel = null;
|
|
85
|
+
set({ status: "off", others: [] });
|
|
86
|
+
if (c)
|
|
87
|
+
void c.unsubscribe().catch(() => { });
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { OpError, OpResult } from "../ops.ts";
|
|
2
|
+
export type StaleInfo = {
|
|
3
|
+
by: string | null;
|
|
4
|
+
at: number;
|
|
5
|
+
};
|
|
6
|
+
/** The views' `fail` helpers take a loose failure shape; the guard's refusal is recognised in either. */
|
|
7
|
+
type FailureLike = {
|
|
8
|
+
ok: false;
|
|
9
|
+
code: string;
|
|
10
|
+
message: string;
|
|
11
|
+
stale?: true;
|
|
12
|
+
by?: string | null;
|
|
13
|
+
at?: number;
|
|
14
|
+
};
|
|
15
|
+
export declare const isStale: (r: OpResult<unknown> | FailureLike) => r is OpError & {
|
|
16
|
+
stale: true;
|
|
17
|
+
};
|
|
18
|
+
/** The part before the @ — how the members list and history shorten an actor. */
|
|
19
|
+
export declare const shortName: (email: string | null) => string;
|
|
20
|
+
export declare function staleText(info: StaleInfo, now?: number): string;
|
|
21
|
+
export declare const STALE_DETAIL = "Your unsaved changes here will be discarded.";
|
|
22
|
+
export declare const LOAD_THEIRS = "Load their version";
|
|
23
|
+
/** An op result's stale facts, for the views' `fail` paths. */
|
|
24
|
+
export declare const staleInfoOf: (r: {
|
|
25
|
+
by?: string | null;
|
|
26
|
+
at?: number;
|
|
27
|
+
}) => StaleInfo;
|
|
28
|
+
export {};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { versionTime } from "../ui/format.js";
|
|
2
|
+
export const isStale = (r) => !r.ok && r.code === "conflict" && r.stale === true;
|
|
3
|
+
/** The part before the @ — how the members list and history shorten an actor. */
|
|
4
|
+
export const shortName = (email) => (email ? email.split("@")[0] : "someone");
|
|
5
|
+
export function staleText(info, now = Date.now()) {
|
|
6
|
+
const at = new Date(info.at);
|
|
7
|
+
const today = new Date(now);
|
|
8
|
+
const sameDay = at.getFullYear() === today.getFullYear() && at.getMonth() === today.getMonth() && at.getDate() === today.getDate();
|
|
9
|
+
const when = sameDay
|
|
10
|
+
? `${String(at.getHours()).padStart(2, "0")}:${String(at.getMinutes()).padStart(2, "0")}`
|
|
11
|
+
: versionTime(info.at);
|
|
12
|
+
return `${shortName(info.by)} saved a newer version at ${when}.`;
|
|
13
|
+
}
|
|
14
|
+
export const STALE_DETAIL = "Your unsaved changes here will be discarded.";
|
|
15
|
+
export const LOAD_THEIRS = "Load their version";
|
|
16
|
+
/** An op result's stale facts, for the views' `fail` paths. */
|
|
17
|
+
export const staleInfoOf = (r) => ({ by: r.by ?? null, at: r.at ?? Date.now() });
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
// The lock after a list row's title (spec 2026-09-26 concurrency §5): someone else
|
|
4
|
+
// has this target open in this locale. A tooltip names them.
|
|
5
|
+
import { Lock } from "lucide-react";
|
|
6
|
+
export const lockLabel = (names) => names.length === 1 ? `${names[0]} is editing` : `${names.join(", ")} are editing`;
|
|
7
|
+
export function LockIcon({ names }) {
|
|
8
|
+
if (names.length === 0)
|
|
9
|
+
return null;
|
|
10
|
+
const label = lockLabel(names);
|
|
11
|
+
return (_jsx("span", { className: "sm-lock", role: "img", "aria-label": label, title: label, children: _jsx(Lock, { size: 14, strokeWidth: 1.5, "aria-hidden": "true" }) }));
|
|
12
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export type PresenceNoun = "page" | "entry" | "item";
|
|
2
|
+
export declare function presenceLineText(names: string[], noun: PresenceNoun): string;
|
|
3
|
+
export declare function PresenceLine({ names, noun }: {
|
|
4
|
+
names: string[];
|
|
5
|
+
noun: PresenceNoun;
|
|
6
|
+
}): import("react").JSX.Element | null;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
export function presenceLineText(names, noun) {
|
|
4
|
+
const who = names.length === 1 ? names[0] : `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
|
|
5
|
+
return `${who} ${names.length === 1 ? "is" : "are"} also editing this ${noun}.`;
|
|
6
|
+
}
|
|
7
|
+
export function PresenceLine({ names, noun }) {
|
|
8
|
+
if (names.length === 0)
|
|
9
|
+
return null;
|
|
10
|
+
return _jsx("p", { className: "sm-presence", role: "status", children: presenceLineText(names, noun) });
|
|
11
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
// The freeze notice under the top bar (spec 2026-09-26 concurrency §4): the fact,
|
|
4
|
+
// what a reload costs, and the one button. The view puts everything below it inert.
|
|
5
|
+
import { LOAD_THEIRS, STALE_DETAIL, staleText } from "../shell/stale.js";
|
|
6
|
+
import { Banner, Button } from "./primitives.js";
|
|
7
|
+
export function StaleBanner({ info, onReload }) {
|
|
8
|
+
return (_jsx("div", { className: "sm-stale", "data-testid": "stale-banner", children: _jsxs(Banner, { tone: "error", children: [_jsx("p", { style: { margin: 0 }, children: staleText(info) }), _jsx("p", { style: { margin: "4px 0 10px", color: "inherit", opacity: 0.85 }, children: STALE_DETAIL }), _jsx(Button, { onClick: onReload, children: LOAD_THEIRS })] }) }));
|
|
9
|
+
}
|
|
@@ -129,6 +129,7 @@ export function SaveIndicator({ state }) {
|
|
|
129
129
|
saved: { label: "Saved", dot: "var(--status-saved-dot)", color: "var(--text-muted)" },
|
|
130
130
|
saving: { label: "Saving", dot: "var(--warn)", color: "var(--text-muted)" },
|
|
131
131
|
error: { label: "Save failed — retrying", dot: "var(--danger)", color: "var(--danger)" },
|
|
132
|
+
stale: { label: "Not saved — newer version exists", dot: "var(--danger)", color: "var(--danger)" },
|
|
132
133
|
};
|
|
133
134
|
const { label, dot, color } = map[state];
|
|
134
135
|
return (_jsxs("span", { className: "sm-savestate", "data-testid": "save-status", style: { color }, title: label, children: [_jsx("span", { className: "sm-dot", style: { background: dot } }), label] }));
|
package/dist/admin/ui/theme.js
CHANGED
|
@@ -794,6 +794,9 @@ body:has(.sm-admin){margin:0}
|
|
|
794
794
|
.sm-overline,.sm-sectionlabel--caps>.sm-label{
|
|
795
795
|
font-family:var(--font-sans);font-size:var(--text-xs);font-weight:600;letter-spacing:var(--tracking-tag);text-transform:uppercase;color:var(--text-muted);
|
|
796
796
|
}
|
|
797
|
+
.sm-stale{padding:8px 20px 0}
|
|
798
|
+
.sm-lock{display:inline-flex;align-items:center;margin-left:6px;color:var(--text-muted);vertical-align:middle}
|
|
799
|
+
.sm-presence{margin:0;padding:6px 20px 0;font-size:13px;color:var(--text-muted)}
|
|
797
800
|
.sm-banner{
|
|
798
801
|
display:flex;gap:14px;align-items:flex-start;padding:12px 16px;border-radius:var(--radius-md);
|
|
799
802
|
font-size:var(--text-md);
|
|
@@ -841,6 +844,7 @@ body:has(.sm-admin){margin:0}
|
|
|
841
844
|
handle and the canvas's 440px minimum — so a window that shrinks after a
|
|
842
845
|
drag never leaves the canvas squeezed. */
|
|
843
846
|
.sm-editor{flex:1;min-height:0;display:grid;grid-template-columns:1fr max(var(--gutter),8px) min(var(--inspector-w),calc(100% - max(var(--gutter),8px) - 440px))}
|
|
847
|
+
.sm-editor[inert],.sm-form[inert]{opacity:.6}
|
|
844
848
|
/* The drag-to-resize handle (spec 2026-09-10 §3): fills its column, draws a
|
|
845
849
|
2px line down its centre on hover and while dragging — a shade off the
|
|
846
850
|
ground, since the col-resize cursor already says it drags (2026-09-10);
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export type DraftToken = {
|
|
2
|
+
versionId: string;
|
|
3
|
+
savedAt: string;
|
|
4
|
+
};
|
|
5
|
+
/** What a locale row carries for the token (both stores' row types satisfy it). */
|
|
6
|
+
export type DraftPointerRow = {
|
|
7
|
+
draftVersionId: string | null;
|
|
8
|
+
draftSavedAt: string | null;
|
|
9
|
+
};
|
|
10
|
+
/** The row's token, or null when it has no draft pointer (a folder, a page created without a tree). */
|
|
11
|
+
export declare function draftToken(row: DraftPointerRow): DraftToken | null;
|
|
12
|
+
export declare function sameToken(a: DraftToken | null, b: DraftToken | null): boolean;
|
|
13
|
+
/** An untrusted value is a token when it has the two strings and nothing is missing. */
|
|
14
|
+
export declare function isDraftToken(v: unknown): v is DraftToken;
|
|
15
|
+
export declare class StaleDraftError extends Error {
|
|
16
|
+
/** The row's token now — what a reload would get. */
|
|
17
|
+
readonly current: DraftToken | null;
|
|
18
|
+
/** The audit pair of the row: who wrote last, when. */
|
|
19
|
+
readonly by: string | null;
|
|
20
|
+
readonly at: number;
|
|
21
|
+
constructor(
|
|
22
|
+
/** The row's token now — what a reload would get. */
|
|
23
|
+
current: DraftToken | null,
|
|
24
|
+
/** The audit pair of the row: who wrote last, when. */
|
|
25
|
+
by: string | null, at: number);
|
|
26
|
+
}
|
|
27
|
+
/** The read-side check every guarded write runs first. `undefined` = no check. */
|
|
28
|
+
export declare function assertFresh(row: DraftPointerRow & {
|
|
29
|
+
updatedBy: string | null;
|
|
30
|
+
updatedAt: number;
|
|
31
|
+
}, expected: DraftToken | null | undefined): void;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// The draft token (spec 2026-09-26 concurrency §1): the locale row's
|
|
2
|
+
// draft pointer with that version's saved time. Every state change moves
|
|
3
|
+
// it — a save rewrites saved_at, a discard points at the published
|
|
4
|
+
// version, a restore creates a new draft. Pure and framework-free; both
|
|
5
|
+
// stores throw StaleDraftError, the ops map it, the admin freezes on it.
|
|
6
|
+
/** The row's token, or null when it has no draft pointer (a folder, a page created without a tree). */
|
|
7
|
+
export function draftToken(row) {
|
|
8
|
+
return row.draftVersionId === null ? null : { versionId: row.draftVersionId, savedAt: row.draftSavedAt ?? "" };
|
|
9
|
+
}
|
|
10
|
+
export function sameToken(a, b) {
|
|
11
|
+
if (a === null || b === null)
|
|
12
|
+
return a === b;
|
|
13
|
+
return a.versionId === b.versionId && a.savedAt === b.savedAt;
|
|
14
|
+
}
|
|
15
|
+
/** An untrusted value is a token when it has the two strings and nothing is missing. */
|
|
16
|
+
export function isDraftToken(v) {
|
|
17
|
+
return typeof v === "object" && v !== null && !Array.isArray(v)
|
|
18
|
+
&& typeof v.versionId === "string"
|
|
19
|
+
&& typeof v.savedAt === "string";
|
|
20
|
+
}
|
|
21
|
+
export class StaleDraftError extends Error {
|
|
22
|
+
constructor(
|
|
23
|
+
/** The row's token now — what a reload would get. */
|
|
24
|
+
current,
|
|
25
|
+
/** The audit pair of the row: who wrote last, when. */
|
|
26
|
+
by, at) {
|
|
27
|
+
super("smoodly: the draft changed since it was loaded — reload before saving.");
|
|
28
|
+
this.current = current;
|
|
29
|
+
this.by = by;
|
|
30
|
+
this.at = at;
|
|
31
|
+
this.name = "StaleDraftError";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** The read-side check every guarded write runs first. `undefined` = no check. */
|
|
35
|
+
export function assertFresh(row, expected) {
|
|
36
|
+
if (expected === undefined)
|
|
37
|
+
return;
|
|
38
|
+
const current = draftToken(row);
|
|
39
|
+
if (!sameToken(current, expected))
|
|
40
|
+
throw new StaleDraftError(current, row.updatedBy, row.updatedAt);
|
|
41
|
+
}
|