@drawnagency/primitives 0.1.76 → 0.1.77
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/dist/chunk-2G2S36SY.js +13 -0
- package/dist/{chunk-7T2WQSWF.js → chunk-63BQQNZO.js} +9 -9
- package/dist/chunk-REM2NH7K.js +44 -0
- package/dist/{chunk-SWEVWC5K.js → chunk-RO4E5UJI.js} +21 -21
- package/dist/{chunk-NKVZGPR6.js → chunk-YP7IHMC3.js} +1 -1
- package/dist/collab/provider-registry.d.ts +4 -0
- package/dist/collab/provider-registry.d.ts.map +1 -0
- package/dist/collab/provider-registry.js +9 -0
- package/dist/collab/rebase.d.ts +37 -0
- package/dist/collab/rebase.d.ts.map +1 -0
- package/dist/collab/rebase.js +64 -0
- package/dist/collab/recovery.d.ts +89 -0
- package/dist/collab/recovery.d.ts.map +1 -0
- package/dist/collab/types.d.ts +93 -0
- package/dist/collab/types.d.ts.map +1 -0
- package/dist/collab/types.js +19 -0
- package/dist/components/editor/AudienceIndicator.d.ts +3 -1
- package/dist/components/editor/AudienceIndicator.d.ts.map +1 -1
- package/dist/components/editor/ConflictDialog.d.ts +14 -0
- package/dist/components/editor/ConflictDialog.d.ts.map +1 -0
- package/dist/components/editor/IndicatorPill.d.ts +4 -1
- package/dist/components/editor/IndicatorPill.d.ts.map +1 -1
- package/dist/components/editor/LockBadge.d.ts +7 -0
- package/dist/components/editor/LockBadge.d.ts.map +1 -0
- package/dist/components/editor/PresenceAvatars.d.ts +9 -0
- package/dist/components/editor/PresenceAvatars.d.ts.map +1 -0
- package/dist/components/editor/SectionWrapper.d.ts +1 -1
- package/dist/components/editor/SectionWrapper.d.ts.map +1 -1
- package/dist/components/editor/StatusIndicator.d.ts +3 -1
- package/dist/components/editor/StatusIndicator.d.ts.map +1 -1
- package/dist/components/editor/index.js +92 -44
- package/dist/components/primitives/index.js +3 -3
- package/dist/components/sections/brand-guide.js +3 -3
- package/dist/components/sections/register-schemas.js +9 -9
- package/dist/components/sections/register.js +9 -9
- package/dist/components/shell/EditorShell.d.ts +3 -1
- package/dist/components/shell/EditorShell.d.ts.map +1 -1
- package/dist/hooks/useCollabSession.d.ts +23 -0
- package/dist/hooks/useCollabSession.d.ts.map +1 -0
- package/dist/hooks/useEditorPersistence.d.ts +21 -1
- package/dist/hooks/useEditorPersistence.d.ts.map +1 -1
- package/dist/hooks/useEditorPublish.d.ts +39 -2
- package/dist/hooks/useEditorPublish.d.ts.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +44 -22
- package/dist/lib/dexie.d.ts +12 -0
- package/dist/lib/dexie.d.ts.map +1 -1
- package/dist/lib/dexie.js +27 -0
- package/dist/lib/index.js +12 -12
- package/dist/lib/registry.d.ts +10 -0
- package/dist/lib/registry.d.ts.map +1 -1
- package/dist/schemas/index.js +1 -1
- package/dist/types/database.d.ts +70 -0
- package/dist/types/database.d.ts.map +1 -1
- package/package.json +13 -1
- package/src/collab/provider-registry.ts +14 -0
- package/src/collab/rebase.ts +140 -0
- package/src/collab/recovery.ts +149 -0
- package/src/collab/types.ts +96 -0
- package/src/components/editor/AudienceIndicator.tsx +4 -1
- package/src/components/editor/ConflictDialog.tsx +61 -0
- package/src/components/editor/IndicatorPill.tsx +12 -6
- package/src/components/editor/LockBadge.tsx +31 -0
- package/src/components/editor/PresenceAvatars.tsx +53 -0
- package/src/components/editor/SectionWrapper.tsx +60 -29
- package/src/components/editor/StatusIndicator.tsx +4 -0
- package/src/components/shell/EditorShell.tsx +561 -18
- package/src/hooks/useCollabSession.ts +220 -0
- package/src/hooks/useEditorPersistence.ts +104 -1
- package/src/hooks/useEditorPublish.ts +205 -63
- package/src/index.ts +2 -0
- package/src/lib/dexie.ts +44 -0
- package/src/lib/registry.ts +8 -0
- package/src/types/database.ts +55 -0
- package/dist/{chunk-Q3T23HVW.js → chunk-B2DLHZKC.js} +4 -4
- package/dist/{chunk-T7MP7ANP.js → chunk-UUCOJL7O.js} +0 -0
- package/dist/{chunk-ZRWGV5O6.js → chunk-XFDEUK37.js} +3 -3
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { useState, useCallback, useRef, useEffect } from "react";
|
|
2
2
|
import type { SiteIndex, SiteConfig } from "../schemas/site-config";
|
|
3
|
+
import type { SectionContent } from "../schemas/sections";
|
|
3
4
|
import type { LoadedSection } from "../lib/loader";
|
|
4
5
|
import type { MediaManifest, MediaItem } from "../media/types";
|
|
5
|
-
import { getDirtySectionRows, hasLocalChanges, discardSavedChanges, cacheContent, clearContentCache, getPendingMediaBlobs, clearPendingMediaByIds } from "../lib/dexie";
|
|
6
|
+
import { getDirtySectionRows, hasLocalChanges, discardSavedChanges, cacheContent, clearContentCache, getCachedContent, getPendingMediaBlobs, clearPendingMediaByIds } from "../lib/dexie";
|
|
6
7
|
import { packUploads, type MediaUploadPayload } from "../lib/save-chunks";
|
|
8
|
+
import { buildCommittedBaseline } from "../collab/recovery";
|
|
7
9
|
|
|
8
10
|
function blobToBase64(blob: Blob): Promise<string> {
|
|
9
11
|
return new Promise((resolve, reject) => {
|
|
@@ -58,7 +60,14 @@ interface PublishDeps {
|
|
|
58
60
|
siteConfig: SiteConfig | null;
|
|
59
61
|
sections: LoadedSection[];
|
|
60
62
|
deletedSectionIds?: string[];
|
|
61
|
-
|
|
63
|
+
/** Receives the ids that were actually committed this save (so the caller can
|
|
64
|
+
* release exactly those sections' locks), plus:
|
|
65
|
+
* - editToken: the edit-generation captured at save BEGIN. The caller's
|
|
66
|
+
* post-save clears must be guarded on it — any edit with a newer generation
|
|
67
|
+
* happened while this save was in flight and was NOT carried by it.
|
|
68
|
+
* - sentDeletedIds: the deletion ids this save actually sent, so the caller
|
|
69
|
+
* clears only those (a deletion scheduled mid-save must stay scheduled). */
|
|
70
|
+
onSuccess: (savedSectionIds: string[], meta: { editToken: number; sentDeletedIds: string[] }) => void;
|
|
62
71
|
mediaManifest: MediaManifest;
|
|
63
72
|
manifestDirty: boolean;
|
|
64
73
|
clearManifestDirty: () => void;
|
|
@@ -71,8 +80,30 @@ interface PublishDeps {
|
|
|
71
80
|
// no draft exists yet). Sent with saves so the server can 409 on a concurrent
|
|
72
81
|
// edit instead of silently overwriting it. Read at save time (latest value).
|
|
73
82
|
getBaseVersion: () => string | null;
|
|
83
|
+
/** Injected by EditorShell. On a 409 (concurrent edit) the save calls this to
|
|
84
|
+
* re-fetch the fresh draft, rebase local unsaved work on top, and refresh the
|
|
85
|
+
* optimistic-concurrency base (savedShaRef). Returns true when recovery
|
|
86
|
+
* succeeded and the save should be retried once; false (or undefined) means
|
|
87
|
+
* fall through to the honest conflict toast. */
|
|
88
|
+
recoverConflict?: () => Promise<boolean>;
|
|
89
|
+
/** Injected by EditorShell. After every successful plain save, receives the
|
|
90
|
+
* COMMITTED snapshot (sent content substituted into the previous clean
|
|
91
|
+
* baseline, plus the sent index) so the shell can re-prime the content-cache
|
|
92
|
+
* baseline that recoverConflict diffs against. Without this, the baseline
|
|
93
|
+
* dies at the first save (discardSavedChanges clears it) and later 409
|
|
94
|
+
* recoveries would silently merge local-wins. */
|
|
95
|
+
onBaselineCommitted?: (args: { sha: string; sections: LoadedSection[]; index: SiteIndex; savedIds: string[] }) => Promise<void>;
|
|
74
96
|
/** Test hook: overrides the per-request payload budget (default 4MB). */
|
|
75
97
|
saveChunkBudget?: number;
|
|
98
|
+
/** Injected by EditorShell. Mirrors this hook's in-flight-save state out to the
|
|
99
|
+
* shell so background collaboration handlers (e.g. the remote saved-event
|
|
100
|
+
* rebase) can defer while our own save's response path owns the state. */
|
|
101
|
+
savingRef?: React.MutableRefObject<boolean>;
|
|
102
|
+
/** Injected by EditorShell (persistence.currentGeneration). Read at save begin
|
|
103
|
+
* to mint the editToken passed back through onSuccess. Absent (tests without
|
|
104
|
+
* collab wiring): the token degrades to MAX_SAFE_INTEGER, i.e. "nothing was
|
|
105
|
+
* edited after capture" — the pre-generation wholesale-clear behavior. */
|
|
106
|
+
getEditGeneration?: () => number;
|
|
76
107
|
}
|
|
77
108
|
|
|
78
109
|
interface GatheredMedia {
|
|
@@ -102,7 +133,11 @@ export function useEditorPublish({
|
|
|
102
133
|
onShasUpdated,
|
|
103
134
|
onPublishComplete,
|
|
104
135
|
getBaseVersion,
|
|
136
|
+
recoverConflict,
|
|
137
|
+
onBaselineCommitted,
|
|
105
138
|
saveChunkBudget,
|
|
139
|
+
savingRef,
|
|
140
|
+
getEditGeneration,
|
|
106
141
|
}: PublishDeps) {
|
|
107
142
|
const [publishAction, setPublishAction] = useState<PublishAction>("idle");
|
|
108
143
|
const [publishFeedback, setPublishFeedback] = useState<string | null>(null);
|
|
@@ -163,6 +198,11 @@ export function useEditorPublish({
|
|
|
163
198
|
|
|
164
199
|
let updatedManifest: MediaManifest | undefined;
|
|
165
200
|
if (hasMediaChanges) {
|
|
201
|
+
// KNOWN LIMITATION (deferred): the manifest is rebuilt from THIS editor's
|
|
202
|
+
// view of manifest.images, so concurrent media changes by another editor
|
|
203
|
+
// are last-writer-wins — a manifest entry they added since our load is
|
|
204
|
+
// dropped from the write (their files remain; the entry orphans). Fixing
|
|
205
|
+
// this needs a merge against the remote manifest at save time.
|
|
166
206
|
const images = { ...manifest.images };
|
|
167
207
|
for (const upload of mediaUploads) {
|
|
168
208
|
images[upload.item.id] = upload.item;
|
|
@@ -186,7 +226,16 @@ export function useEditorPublish({
|
|
|
186
226
|
mediaManifest: MediaManifest;
|
|
187
227
|
manifestDirty: boolean;
|
|
188
228
|
saveChunkBudget?: number;
|
|
189
|
-
}): Promise<{
|
|
229
|
+
}): Promise<{
|
|
230
|
+
sha: string;
|
|
231
|
+
// sectionId+updatedAt for compare-and-clear cleanup, plus the SENT content —
|
|
232
|
+
// the committed-baseline re-prime needs exactly what went to the server (not
|
|
233
|
+
// the current view state, which may carry mid-flight edits).
|
|
234
|
+
savedSections: { sectionId: string; updatedAt: string; content: SectionContent }[];
|
|
235
|
+
uploadedItems: MediaItem[];
|
|
236
|
+
// The index actually sent (deletions applied) — the committed index state.
|
|
237
|
+
sentIndex: SiteIndex;
|
|
238
|
+
}> {
|
|
190
239
|
const dirty = await getDirtySectionRows();
|
|
191
240
|
const gathered = await gatherMediaPayload(
|
|
192
241
|
args.pendingMediaItems,
|
|
@@ -233,56 +282,69 @@ export function useEditorPublish({
|
|
|
233
282
|
const committedImages: Record<string, MediaItem> = {};
|
|
234
283
|
let sent = 0;
|
|
235
284
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
285
|
+
try {
|
|
286
|
+
for (const chunk of leadChunks) {
|
|
287
|
+
setSaveProgress(`Saving images ${sent + chunk.length}/${totalUploads}…`);
|
|
288
|
+
for (const upload of chunk) committedImages[upload.item.id] = upload.item;
|
|
289
|
+
const manifest: MediaManifest = { images: { ...args.mediaManifest.images, ...committedImages } };
|
|
290
|
+
const data = await postSave({
|
|
291
|
+
baseVersion,
|
|
292
|
+
media: { uploads: chunk, deletions: [], manifest },
|
|
293
|
+
});
|
|
294
|
+
baseVersion = data.sha;
|
|
295
|
+
sent += chunk.length;
|
|
296
|
+
const chunkItems = chunk.map((u) => u.item);
|
|
297
|
+
uploadedItems.push(...chunkItems);
|
|
298
|
+
// Promote committed items immediately so a later failure can't re-send
|
|
299
|
+
// them. A failure HERE is tolerable: re-sending identical blobs on the
|
|
300
|
+
// next save is harmless (same content, same paths).
|
|
301
|
+
try {
|
|
302
|
+
await clearPendingMediaByIds(chunkItems.map((i) => i.id), []);
|
|
303
|
+
onMediaPublished(chunkItems, []);
|
|
304
|
+
} catch (promoteError) {
|
|
305
|
+
console.error("Failed to promote committed media chunk locally:", promoteError);
|
|
306
|
+
}
|
|
256
307
|
}
|
|
257
|
-
}
|
|
258
308
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
309
|
+
if (leadChunks.length > 0) {
|
|
310
|
+
setSaveProgress(finalUploads.length > 0 ? `Saving images ${totalUploads}/${totalUploads}…` : null);
|
|
311
|
+
}
|
|
262
312
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
313
|
+
const responseData = await postSave({
|
|
314
|
+
sections: dirty.map(({ sectionId, content }) => ({ id: sectionId, content })),
|
|
315
|
+
siteIndex,
|
|
316
|
+
baseVersion,
|
|
317
|
+
...(args.deletedSectionIds?.length ? { deletedSectionIds: args.deletedSectionIds } : {}),
|
|
318
|
+
...(args.isConfigDirty() ? { siteConfig: args.siteConfig } : {}),
|
|
319
|
+
...(gathered.hasMediaChanges ? {
|
|
320
|
+
media: {
|
|
321
|
+
uploads: finalUploads,
|
|
322
|
+
deletions: args.pendingMediaDeletions.map((id) => ({
|
|
323
|
+
id,
|
|
324
|
+
folder: args.mediaManifest.images[id]?.folder,
|
|
325
|
+
})).filter((d) => d.folder),
|
|
326
|
+
manifest: gathered.updatedManifest,
|
|
327
|
+
},
|
|
328
|
+
} : {}),
|
|
329
|
+
});
|
|
280
330
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
331
|
+
return {
|
|
332
|
+
sha: responseData.sha,
|
|
333
|
+
savedSections: dirty.map(({ sectionId, updatedAt, content }) => ({ sectionId, updatedAt, content })),
|
|
334
|
+
uploadedItems: [...uploadedItems, ...finalUploads.map((u) => u.item)],
|
|
335
|
+
sentIndex: siteIndex,
|
|
336
|
+
};
|
|
337
|
+
} catch (error) {
|
|
338
|
+
// I2: once a lead chunk has committed (advancing the draft head), a 409
|
|
339
|
+
// must NOT trigger auto-recovery — the retry would rebuild the manifest
|
|
340
|
+
// from this invocation's stale closure, dropping the just-promoted items
|
|
341
|
+
// and orphaning their committed files. Mark the error so the recovery
|
|
342
|
+
// wrapper falls through to the honest toast instead.
|
|
343
|
+
if (sent > 0 && error && typeof error === "object") {
|
|
344
|
+
(error as { leadChunksCommitted?: boolean }).leadChunksCommitted = true;
|
|
345
|
+
}
|
|
346
|
+
throw error;
|
|
347
|
+
}
|
|
286
348
|
}
|
|
287
349
|
|
|
288
350
|
// --- Shared save orchestration ---
|
|
@@ -290,9 +352,24 @@ export function useEditorPublish({
|
|
|
290
352
|
// change-detection, the /api/save request, and the post-save cleanup stay in
|
|
291
353
|
// one place and can't drift between the two paths.
|
|
292
354
|
|
|
355
|
+
// Whether THIS save invocation's recoverConflict actually rebased the view —
|
|
356
|
+
// gates the 409 toast copy: "your view has been updated" must only be claimed
|
|
357
|
+
// when it is true. Reset at the start of every save.
|
|
358
|
+
const recoveredRef = useRef(false);
|
|
359
|
+
|
|
360
|
+
// Edit-generation captured at save begin. Edits with a newer generation
|
|
361
|
+
// happened mid-save (after the payload snapshot) — onSuccess receives this
|
|
362
|
+
// token so the shell's post-save clears can spare them. Captured BEFORE the
|
|
363
|
+
// flush: an edit landing during the flush itself then reads as "after the
|
|
364
|
+
// token" and is kept — erring toward a stale dirty badge, never lost work.
|
|
365
|
+
const editTokenRef = useRef(Number.MAX_SAFE_INTEGER);
|
|
366
|
+
|
|
293
367
|
function tryBeginSave(): boolean {
|
|
294
368
|
if (!siteConfig || inFlightRef.current) return false;
|
|
295
369
|
inFlightRef.current = true;
|
|
370
|
+
if (savingRef) savingRef.current = true;
|
|
371
|
+
recoveredRef.current = false;
|
|
372
|
+
editTokenRef.current = getEditGeneration?.() ?? Number.MAX_SAFE_INTEGER;
|
|
296
373
|
setPublishAction("saving");
|
|
297
374
|
setPublishFeedback(null);
|
|
298
375
|
return true;
|
|
@@ -315,6 +392,30 @@ export function useEditorPublish({
|
|
|
315
392
|
});
|
|
316
393
|
}
|
|
317
394
|
|
|
395
|
+
// Wraps a save attempt with single-shot 409 auto-recovery: when the server
|
|
396
|
+
// rejects the save because another editor moved the draft head, ask
|
|
397
|
+
// recoverConflict() to re-fetch + rebase local work and refresh savedShaRef,
|
|
398
|
+
// then retry exactly once. Any other error (or a declined/absent recovery)
|
|
399
|
+
// propagates to reportSaveError. At most one retry per save invocation — never
|
|
400
|
+
// a loop, since the retry runs runPerformSave() directly, not this wrapper.
|
|
401
|
+
async function saveWithConflictRecovery(): ReturnType<typeof runPerformSave> {
|
|
402
|
+
try {
|
|
403
|
+
return await runPerformSave();
|
|
404
|
+
} catch (error) {
|
|
405
|
+
const status = (error as { status?: number })?.status;
|
|
406
|
+
// I2: no recovery once lead media chunks committed this invocation — see
|
|
407
|
+
// the flag's source in performSave.
|
|
408
|
+
const leadCommitted = (error as { leadChunksCommitted?: boolean })?.leadChunksCommitted === true;
|
|
409
|
+
if (status === 409 && !leadCommitted && recoverConflict && (await recoverConflict())) {
|
|
410
|
+
// recoverConflict rebased local state and refreshed savedShaRef —
|
|
411
|
+
// getBaseVersion() now returns the fresh draft head. One retry only.
|
|
412
|
+
recoveredRef.current = true;
|
|
413
|
+
return await runPerformSave();
|
|
414
|
+
}
|
|
415
|
+
throw error;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
318
419
|
// Clear the Dexie rows + pending media for what was actually committed. In the
|
|
319
420
|
// publish path this MUST run before cacheContent: discardSavedChanges clears
|
|
320
421
|
// only the rows we sent (preserving edits typed during the request) AND
|
|
@@ -330,17 +431,27 @@ export function useEditorPublish({
|
|
|
330
431
|
await clearPendingMediaByIds(uploadedItems.map((i) => i.id), pendingMediaDeletions);
|
|
331
432
|
}
|
|
332
433
|
|
|
333
|
-
function finishSave(uploadedItems: MediaItem[]): void {
|
|
434
|
+
function finishSave(uploadedItems: MediaItem[], savedSectionIds: string[]): void {
|
|
334
435
|
clearConfigDirty();
|
|
335
436
|
clearManifestDirty();
|
|
336
|
-
onSuccess(
|
|
437
|
+
onSuccess(savedSectionIds, {
|
|
438
|
+
editToken: editTokenRef.current,
|
|
439
|
+
sentDeletedIds: deletedSectionIds ?? [],
|
|
440
|
+
});
|
|
337
441
|
onMediaPublished(uploadedItems, pendingMediaDeletions);
|
|
338
442
|
}
|
|
339
443
|
|
|
340
444
|
function reportSaveError(error: unknown, failLabel: string): void {
|
|
341
445
|
console.error(`${failLabel} failed:`, error);
|
|
342
|
-
const message = error instanceof Error && error.message ? error.message : `${failLabel} failed`;
|
|
343
446
|
const isConflict = (error as { status?: number })?.status === 409;
|
|
447
|
+
// M1 honesty: only claim the view was updated when recovery actually
|
|
448
|
+
// rebased it this invocation (i.e. the RETRY 409'd). A failed/absent/
|
|
449
|
+
// skipped recovery left the view untouched — say so.
|
|
450
|
+
const message = isConflict
|
|
451
|
+
? recoveredRef.current
|
|
452
|
+
? "Someone else saved while you were editing — your view has been updated; try saving again."
|
|
453
|
+
: "This site was changed elsewhere — reload to get the latest, then re-apply your edits."
|
|
454
|
+
: error instanceof Error && error.message ? error.message : `${failLabel} failed`;
|
|
344
455
|
showFeedback(message, isConflict ? 8000 : 6000);
|
|
345
456
|
}
|
|
346
457
|
|
|
@@ -356,12 +467,31 @@ export function useEditorPublish({
|
|
|
356
467
|
return;
|
|
357
468
|
}
|
|
358
469
|
|
|
359
|
-
const { sha, savedSections, uploadedItems } = await
|
|
470
|
+
const { sha, savedSections, uploadedItems, sentIndex } = await saveWithConflictRecovery();
|
|
360
471
|
onShasUpdated(sha, null);
|
|
361
472
|
|
|
362
473
|
try {
|
|
474
|
+
// C1: read the previous clean baseline BEFORE commitSavedRows clears the
|
|
475
|
+
// content cache, then re-prime it with the COMMITTED snapshot keyed at
|
|
476
|
+
// the new sha. Post-recovery the cache holds the fetched remote snapshot,
|
|
477
|
+
// so this correctly layers only the retried dirty rows on top of it.
|
|
478
|
+
const prevBaseline = onBaselineCommitted ? await getCachedContent() : null;
|
|
363
479
|
await commitSavedRows(savedSections, uploadedItems);
|
|
364
|
-
|
|
480
|
+
if (onBaselineCommitted) {
|
|
481
|
+
const baselineSections = buildCommittedBaseline({
|
|
482
|
+
baselineSections: prevBaseline?.sections ?? null,
|
|
483
|
+
fallbackSections: sections,
|
|
484
|
+
savedRows: savedSections,
|
|
485
|
+
sentIndex,
|
|
486
|
+
});
|
|
487
|
+
await onBaselineCommitted({
|
|
488
|
+
sha,
|
|
489
|
+
sections: baselineSections,
|
|
490
|
+
index: sentIndex,
|
|
491
|
+
savedIds: savedSections.map((s) => s.sectionId),
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
finishSave(uploadedItems, savedSections.map((s) => s.sectionId));
|
|
365
495
|
} catch (cleanupError) {
|
|
366
496
|
console.error("Save succeeded but local cleanup failed:", cleanupError);
|
|
367
497
|
showFeedback("Saved, but the local cache couldn't be updated — reload the editor.", 8000);
|
|
@@ -373,10 +503,11 @@ export function useEditorPublish({
|
|
|
373
503
|
reportSaveError(error, "Save");
|
|
374
504
|
} finally {
|
|
375
505
|
inFlightRef.current = false;
|
|
506
|
+
if (savingRef) savingRef.current = false;
|
|
376
507
|
setPublishAction("idle");
|
|
377
508
|
setSaveProgress(null);
|
|
378
509
|
}
|
|
379
|
-
}, [flushNow, cancelPendingFlush, isConfigDirty, clearConfigDirty, siteIndexRef, siteConfig, deletedSectionIds, onSuccess, mediaManifest, manifestDirty, clearManifestDirty, pendingMediaItems, pendingMediaDeletions, onMediaPublished, onShasUpdated, showFeedback, saveChunkBudget]);
|
|
510
|
+
}, [flushNow, cancelPendingFlush, isConfigDirty, clearConfigDirty, siteIndexRef, siteConfig, sections, deletedSectionIds, onSuccess, mediaManifest, manifestDirty, clearManifestDirty, pendingMediaItems, pendingMediaDeletions, onMediaPublished, onShasUpdated, showFeedback, recoverConflict, onBaselineCommitted, saveChunkBudget, getEditGeneration]);
|
|
380
511
|
|
|
381
512
|
const handlePublish = useCallback(async () => {
|
|
382
513
|
setPublishAction("publishing");
|
|
@@ -422,10 +553,11 @@ export function useEditorPublish({
|
|
|
422
553
|
|
|
423
554
|
const hasLocalEdits = await hasPendingEdits();
|
|
424
555
|
|
|
425
|
-
let savedSections: { sectionId: string; updatedAt: string }[] = [];
|
|
556
|
+
let savedSections: { sectionId: string; updatedAt: string; content: SectionContent }[] = [];
|
|
426
557
|
let uploadedItems: MediaItem[] = [];
|
|
558
|
+
let sentIndex: SiteIndex | null = null;
|
|
427
559
|
if (hasLocalEdits) {
|
|
428
|
-
({ savedSections, uploadedItems } = await
|
|
560
|
+
({ savedSections, uploadedItems, sentIndex } = await saveWithConflictRecovery());
|
|
429
561
|
}
|
|
430
562
|
|
|
431
563
|
setPublishAction("publishing");
|
|
@@ -448,12 +580,21 @@ export function useEditorPublish({
|
|
|
448
580
|
|
|
449
581
|
if (hasLocalEdits) {
|
|
450
582
|
try {
|
|
583
|
+
// C1 parity with handleSave: read the previous clean baseline before
|
|
584
|
+
// the invalidation, then re-cache the COMMITTED snapshot (sent content
|
|
585
|
+
// + sent index — never the live view state, which may carry mid-flight
|
|
586
|
+
// edits) against the PUBLISHED sha; the draft branch is gone after
|
|
587
|
+
// publish, so this is the snapshot a later cache hit restores.
|
|
588
|
+
const prevBaseline = await getCachedContent();
|
|
451
589
|
await commitSavedRows(savedSections, uploadedItems);
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
590
|
+
const baselineSections = buildCommittedBaseline({
|
|
591
|
+
baselineSections: prevBaseline?.sections ?? null,
|
|
592
|
+
fallbackSections: sections,
|
|
593
|
+
savedRows: savedSections,
|
|
594
|
+
sentIndex: sentIndex!,
|
|
595
|
+
});
|
|
596
|
+
await cacheContent(sha, baselineSections, sentIndex!, siteConfig!);
|
|
597
|
+
finishSave(uploadedItems, savedSections.map((s) => s.sectionId));
|
|
457
598
|
} catch (cleanupError) {
|
|
458
599
|
console.error("Publish succeeded but local cleanup failed:", cleanupError);
|
|
459
600
|
onShasUpdated(null, sha);
|
|
@@ -469,10 +610,11 @@ export function useEditorPublish({
|
|
|
469
610
|
reportSaveError(error, "Publish");
|
|
470
611
|
} finally {
|
|
471
612
|
inFlightRef.current = false;
|
|
613
|
+
if (savingRef) savingRef.current = false;
|
|
472
614
|
setPublishAction("idle");
|
|
473
615
|
setSaveProgress(null);
|
|
474
616
|
}
|
|
475
|
-
}, [flushNow, cancelPendingFlush, isConfigDirty, clearConfigDirty, siteIndexRef, siteConfig, sections, deletedSectionIds, onSuccess, mediaManifest, manifestDirty, clearManifestDirty, pendingMediaItems, pendingMediaDeletions, onMediaPublished, onShasUpdated, showFeedback, onPublishComplete, saveChunkBudget]);
|
|
617
|
+
}, [flushNow, cancelPendingFlush, isConfigDirty, clearConfigDirty, siteIndexRef, siteConfig, sections, deletedSectionIds, onSuccess, mediaManifest, manifestDirty, clearManifestDirty, pendingMediaItems, pendingMediaDeletions, onMediaPublished, onShasUpdated, showFeedback, onPublishComplete, recoverConflict, saveChunkBudget, getEditGeneration]);
|
|
476
618
|
|
|
477
619
|
return { publishAction, publishFeedback, saveProgress, handleSave, handlePublish, handleSaveAndPublish };
|
|
478
620
|
}
|
package/src/index.ts
CHANGED
package/src/lib/dexie.ts
CHANGED
|
@@ -67,6 +67,14 @@ interface PendingMediaDeletionRow {
|
|
|
67
67
|
deletedAt: string;
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
export interface PreservedEditRow {
|
|
71
|
+
id: string;
|
|
72
|
+
sectionId: string;
|
|
73
|
+
content: SectionContent;
|
|
74
|
+
reason: "takeover" | "conflict";
|
|
75
|
+
preservedAt: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
70
78
|
class EditorDatabase extends Dexie {
|
|
71
79
|
sections!: Dexie.Table<SectionRow, string>;
|
|
72
80
|
siteIndex!: Dexie.Table<SiteIndexRow, string>;
|
|
@@ -76,6 +84,7 @@ class EditorDatabase extends Dexie {
|
|
|
76
84
|
mediaManifest!: Dexie.Table<MediaManifestRow, string>;
|
|
77
85
|
pendingMedia!: Dexie.Table<PendingMediaRow, string>;
|
|
78
86
|
pendingMediaDeletions!: Dexie.Table<PendingMediaDeletionRow, string>;
|
|
87
|
+
preservedEdits!: Dexie.Table<PreservedEditRow, string>;
|
|
79
88
|
|
|
80
89
|
constructor(siteId: string) {
|
|
81
90
|
super(`brand-portal-${siteId}`);
|
|
@@ -164,6 +173,20 @@ class EditorDatabase extends Dexie {
|
|
|
164
173
|
await tx.table("siteIndex").clear();
|
|
165
174
|
await tx.table("contentCache").clear();
|
|
166
175
|
});
|
|
176
|
+
// v9: preservedEdits store — snapshots of local edits about to be
|
|
177
|
+
// superseded by a takeover or conflict resolution, so nothing is silently
|
|
178
|
+
// destroyed.
|
|
179
|
+
this.version(9).stores({
|
|
180
|
+
sections: "sectionId",
|
|
181
|
+
siteIndex: "key",
|
|
182
|
+
meta: "key",
|
|
183
|
+
siteConfig: "key",
|
|
184
|
+
contentCache: "key",
|
|
185
|
+
mediaManifest: "key",
|
|
186
|
+
pendingMedia: "id",
|
|
187
|
+
pendingMediaDeletions: "id",
|
|
188
|
+
preservedEdits: "id, sectionId",
|
|
189
|
+
});
|
|
167
190
|
}
|
|
168
191
|
}
|
|
169
192
|
|
|
@@ -539,3 +562,24 @@ export async function clearPendingMediaByIds(
|
|
|
539
562
|
if (deletionIds.length > 0) await database.pendingMediaDeletions.bulkDelete(deletionIds);
|
|
540
563
|
});
|
|
541
564
|
}
|
|
565
|
+
|
|
566
|
+
/** Snapshot a local edit that is about to be superseded (takeover or conflict
|
|
567
|
+
* resolution) so it is recoverable rather than silently destroyed. */
|
|
568
|
+
export async function preserveEdit(
|
|
569
|
+
sectionId: string,
|
|
570
|
+
content: SectionContent,
|
|
571
|
+
reason: "takeover" | "conflict",
|
|
572
|
+
): Promise<string> {
|
|
573
|
+
const preservedAt = new Date().toISOString();
|
|
574
|
+
const id = `${sectionId}:${preservedAt}`;
|
|
575
|
+
await getDb().preservedEdits.put({ id, sectionId, content, reason, preservedAt });
|
|
576
|
+
return id;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
export async function listPreservedEdits(): Promise<PreservedEditRow[]> {
|
|
580
|
+
return getDb().preservedEdits.toArray();
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
export async function removePreservedEdit(id: string): Promise<void> {
|
|
584
|
+
await getDb().preservedEdits.delete(id);
|
|
585
|
+
}
|
package/src/lib/registry.ts
CHANGED
|
@@ -109,6 +109,14 @@ export interface WrapperProps {
|
|
|
109
109
|
mainStatus?: string | null;
|
|
110
110
|
contentDiffersFromMain?: boolean;
|
|
111
111
|
isLocalOnly?: boolean;
|
|
112
|
+
/** Collaboration: when another editor holds this section's lock, their identity.
|
|
113
|
+
* Renders a lock badge, makes content inert, and suppresses mutation chrome. */
|
|
114
|
+
lockedBy?: { email: string } | null;
|
|
115
|
+
/** Collaboration: request the lock be stolen from the current holder. */
|
|
116
|
+
onTakeover?: () => void;
|
|
117
|
+
/** Collaboration: fired on focus-in to request the lock. Resolves false when
|
|
118
|
+
* denied (caller keeps the section read-only via `lockedBy`). */
|
|
119
|
+
onEditIntent?: () => Promise<boolean>;
|
|
112
120
|
children: React.ReactNode;
|
|
113
121
|
}
|
|
114
122
|
|
package/src/types/database.ts
CHANGED
|
@@ -223,6 +223,41 @@ export type Database = {
|
|
|
223
223
|
},
|
|
224
224
|
]
|
|
225
225
|
}
|
|
226
|
+
editor_locks: {
|
|
227
|
+
Row: {
|
|
228
|
+
acquired_at: string
|
|
229
|
+
email: string
|
|
230
|
+
heartbeat_at: string
|
|
231
|
+
section_id: string
|
|
232
|
+
site_id: string
|
|
233
|
+
user_id: string
|
|
234
|
+
}
|
|
235
|
+
Insert: {
|
|
236
|
+
acquired_at?: string
|
|
237
|
+
email: string
|
|
238
|
+
heartbeat_at?: string
|
|
239
|
+
section_id: string
|
|
240
|
+
site_id: string
|
|
241
|
+
user_id: string
|
|
242
|
+
}
|
|
243
|
+
Update: {
|
|
244
|
+
acquired_at?: string
|
|
245
|
+
email?: string
|
|
246
|
+
heartbeat_at?: string
|
|
247
|
+
section_id?: string
|
|
248
|
+
site_id?: string
|
|
249
|
+
user_id?: string
|
|
250
|
+
}
|
|
251
|
+
Relationships: [
|
|
252
|
+
{
|
|
253
|
+
foreignKeyName: "editor_locks_site_id_fkey"
|
|
254
|
+
columns: ["site_id"]
|
|
255
|
+
isOneToOne: false
|
|
256
|
+
referencedRelation: "sites"
|
|
257
|
+
referencedColumns: ["id"]
|
|
258
|
+
},
|
|
259
|
+
]
|
|
260
|
+
}
|
|
226
261
|
github_installations: {
|
|
227
262
|
Row: {
|
|
228
263
|
created_at: string | null
|
|
@@ -680,6 +715,17 @@ export type Database = {
|
|
|
680
715
|
[_ in never]: never
|
|
681
716
|
}
|
|
682
717
|
Functions: {
|
|
718
|
+
acquire_section_lock: {
|
|
719
|
+
Args: { p_section_id: string; p_site_id: string; p_steal?: boolean }
|
|
720
|
+
Returns: {
|
|
721
|
+
acquired_at: string
|
|
722
|
+
email: string
|
|
723
|
+
heartbeat_at: string
|
|
724
|
+
section_id: string
|
|
725
|
+
site_id: string
|
|
726
|
+
user_id: string
|
|
727
|
+
}[]
|
|
728
|
+
}
|
|
683
729
|
chat_usage_hit: {
|
|
684
730
|
Args: {
|
|
685
731
|
p_day: string
|
|
@@ -690,7 +736,12 @@ export type Database = {
|
|
|
690
736
|
}
|
|
691
737
|
Returns: undefined
|
|
692
738
|
}
|
|
739
|
+
collab_site_member: { Args: { p_site_id: string }; Returns: boolean }
|
|
693
740
|
get_user_id_by_email: { Args: { lookup_email: string }; Returns: string }
|
|
741
|
+
heartbeat_section_locks: {
|
|
742
|
+
Args: { p_section_ids: string[]; p_site_id: string }
|
|
743
|
+
Returns: undefined
|
|
744
|
+
}
|
|
694
745
|
is_site_member: { Args: { p_site_id: string }; Returns: boolean }
|
|
695
746
|
is_site_owner: { Args: { p_site_id: string }; Returns: boolean }
|
|
696
747
|
oauth_cleanup: { Args: { p_max_age_ms: number }; Returns: undefined }
|
|
@@ -716,6 +767,10 @@ export type Database = {
|
|
|
716
767
|
retry_after_ms: number
|
|
717
768
|
}[]
|
|
718
769
|
}
|
|
770
|
+
release_section_locks: {
|
|
771
|
+
Args: { p_section_ids: string[]; p_site_id: string }
|
|
772
|
+
Returns: undefined
|
|
773
|
+
}
|
|
719
774
|
revoke_site_user: {
|
|
720
775
|
Args: { p_site_id: string; p_user_id: string }
|
|
721
776
|
Returns: undefined
|
|
File without changes
|
|
@@ -7,12 +7,12 @@ import {
|
|
|
7
7
|
import {
|
|
8
8
|
getSectionSchema
|
|
9
9
|
} from "./chunk-TZQPOR5A.js";
|
|
10
|
-
import {
|
|
11
|
-
getAllSchemas
|
|
12
|
-
} from "./chunk-VKAGMEKE.js";
|
|
13
10
|
import {
|
|
14
11
|
IndexSchema
|
|
15
12
|
} from "./chunk-SWIHUIFT.js";
|
|
13
|
+
import {
|
|
14
|
+
getAllSchemas
|
|
15
|
+
} from "./chunk-VKAGMEKE.js";
|
|
16
16
|
import {
|
|
17
17
|
MAX_BLOCK_DEPTH,
|
|
18
18
|
blockDepth
|