@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.
Files changed (78) hide show
  1. package/dist/chunk-2G2S36SY.js +13 -0
  2. package/dist/{chunk-7T2WQSWF.js → chunk-63BQQNZO.js} +9 -9
  3. package/dist/chunk-REM2NH7K.js +44 -0
  4. package/dist/{chunk-SWEVWC5K.js → chunk-RO4E5UJI.js} +21 -21
  5. package/dist/{chunk-NKVZGPR6.js → chunk-YP7IHMC3.js} +1 -1
  6. package/dist/collab/provider-registry.d.ts +4 -0
  7. package/dist/collab/provider-registry.d.ts.map +1 -0
  8. package/dist/collab/provider-registry.js +9 -0
  9. package/dist/collab/rebase.d.ts +37 -0
  10. package/dist/collab/rebase.d.ts.map +1 -0
  11. package/dist/collab/rebase.js +64 -0
  12. package/dist/collab/recovery.d.ts +89 -0
  13. package/dist/collab/recovery.d.ts.map +1 -0
  14. package/dist/collab/types.d.ts +93 -0
  15. package/dist/collab/types.d.ts.map +1 -0
  16. package/dist/collab/types.js +19 -0
  17. package/dist/components/editor/AudienceIndicator.d.ts +3 -1
  18. package/dist/components/editor/AudienceIndicator.d.ts.map +1 -1
  19. package/dist/components/editor/ConflictDialog.d.ts +14 -0
  20. package/dist/components/editor/ConflictDialog.d.ts.map +1 -0
  21. package/dist/components/editor/IndicatorPill.d.ts +4 -1
  22. package/dist/components/editor/IndicatorPill.d.ts.map +1 -1
  23. package/dist/components/editor/LockBadge.d.ts +7 -0
  24. package/dist/components/editor/LockBadge.d.ts.map +1 -0
  25. package/dist/components/editor/PresenceAvatars.d.ts +9 -0
  26. package/dist/components/editor/PresenceAvatars.d.ts.map +1 -0
  27. package/dist/components/editor/SectionWrapper.d.ts +1 -1
  28. package/dist/components/editor/SectionWrapper.d.ts.map +1 -1
  29. package/dist/components/editor/StatusIndicator.d.ts +3 -1
  30. package/dist/components/editor/StatusIndicator.d.ts.map +1 -1
  31. package/dist/components/editor/index.js +92 -44
  32. package/dist/components/primitives/index.js +3 -3
  33. package/dist/components/sections/brand-guide.js +3 -3
  34. package/dist/components/sections/register-schemas.js +9 -9
  35. package/dist/components/sections/register.js +9 -9
  36. package/dist/components/shell/EditorShell.d.ts +3 -1
  37. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  38. package/dist/hooks/useCollabSession.d.ts +23 -0
  39. package/dist/hooks/useCollabSession.d.ts.map +1 -0
  40. package/dist/hooks/useEditorPersistence.d.ts +21 -1
  41. package/dist/hooks/useEditorPersistence.d.ts.map +1 -1
  42. package/dist/hooks/useEditorPublish.d.ts +39 -2
  43. package/dist/hooks/useEditorPublish.d.ts.map +1 -1
  44. package/dist/index.d.ts +2 -0
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +44 -22
  47. package/dist/lib/dexie.d.ts +12 -0
  48. package/dist/lib/dexie.d.ts.map +1 -1
  49. package/dist/lib/dexie.js +27 -0
  50. package/dist/lib/index.js +12 -12
  51. package/dist/lib/registry.d.ts +10 -0
  52. package/dist/lib/registry.d.ts.map +1 -1
  53. package/dist/schemas/index.js +1 -1
  54. package/dist/types/database.d.ts +70 -0
  55. package/dist/types/database.d.ts.map +1 -1
  56. package/package.json +13 -1
  57. package/src/collab/provider-registry.ts +14 -0
  58. package/src/collab/rebase.ts +140 -0
  59. package/src/collab/recovery.ts +149 -0
  60. package/src/collab/types.ts +96 -0
  61. package/src/components/editor/AudienceIndicator.tsx +4 -1
  62. package/src/components/editor/ConflictDialog.tsx +61 -0
  63. package/src/components/editor/IndicatorPill.tsx +12 -6
  64. package/src/components/editor/LockBadge.tsx +31 -0
  65. package/src/components/editor/PresenceAvatars.tsx +53 -0
  66. package/src/components/editor/SectionWrapper.tsx +60 -29
  67. package/src/components/editor/StatusIndicator.tsx +4 -0
  68. package/src/components/shell/EditorShell.tsx +561 -18
  69. package/src/hooks/useCollabSession.ts +220 -0
  70. package/src/hooks/useEditorPersistence.ts +104 -1
  71. package/src/hooks/useEditorPublish.ts +205 -63
  72. package/src/index.ts +2 -0
  73. package/src/lib/dexie.ts +44 -0
  74. package/src/lib/registry.ts +8 -0
  75. package/src/types/database.ts +55 -0
  76. package/dist/{chunk-Q3T23HVW.js → chunk-B2DLHZKC.js} +4 -4
  77. package/dist/{chunk-T7MP7ANP.js → chunk-UUCOJL7O.js} +0 -0
  78. 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
- onSuccess: () => void;
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<{ sha: string; savedSections: { sectionId: string; updatedAt: string }[]; uploadedItems: MediaItem[] }> {
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
- for (const chunk of leadChunks) {
237
- setSaveProgress(`Saving images ${sent + chunk.length}/${totalUploads}…`);
238
- for (const upload of chunk) committedImages[upload.item.id] = upload.item;
239
- const manifest: MediaManifest = { images: { ...args.mediaManifest.images, ...committedImages } };
240
- const data = await postSave({
241
- baseVersion,
242
- media: { uploads: chunk, deletions: [], manifest },
243
- });
244
- baseVersion = data.sha;
245
- sent += chunk.length;
246
- const chunkItems = chunk.map((u) => u.item);
247
- uploadedItems.push(...chunkItems);
248
- // Promote committed items immediately so a later failure can't re-send
249
- // them. A failure HERE is tolerable: re-sending identical blobs on the
250
- // next save is harmless (same content, same paths).
251
- try {
252
- await clearPendingMediaByIds(chunkItems.map((i) => i.id), []);
253
- onMediaPublished(chunkItems, []);
254
- } catch (promoteError) {
255
- console.error("Failed to promote committed media chunk locally:", promoteError);
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
- if (leadChunks.length > 0) {
260
- setSaveProgress(finalUploads.length > 0 ? `Saving images ${totalUploads}/${totalUploads}…` : null);
261
- }
309
+ if (leadChunks.length > 0) {
310
+ setSaveProgress(finalUploads.length > 0 ? `Saving images ${totalUploads}/${totalUploads}…` : null);
311
+ }
262
312
 
263
- const responseData = await postSave({
264
- sections: dirty.map(({ sectionId, content }) => ({ id: sectionId, content })),
265
- siteIndex,
266
- baseVersion,
267
- ...(args.deletedSectionIds?.length ? { deletedSectionIds: args.deletedSectionIds } : {}),
268
- ...(args.isConfigDirty() ? { siteConfig: args.siteConfig } : {}),
269
- ...(gathered.hasMediaChanges ? {
270
- media: {
271
- uploads: finalUploads,
272
- deletions: args.pendingMediaDeletions.map((id) => ({
273
- id,
274
- folder: args.mediaManifest.images[id]?.folder,
275
- })).filter((d) => d.folder),
276
- manifest: gathered.updatedManifest,
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
- return {
282
- sha: responseData.sha,
283
- savedSections: dirty.map(({ sectionId, updatedAt }) => ({ sectionId, updatedAt })),
284
- uploadedItems: [...uploadedItems, ...finalUploads.map((u) => u.item)],
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 runPerformSave();
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
- finishSave(uploadedItems);
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 runPerformSave());
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
- // Re-cache against the PUBLISHED sha (commitSavedRows just invalidated the
453
- // cache); the draft branch is gone after publish, so this is the snapshot a
454
- // later cache hit restores.
455
- await cacheContent(sha, sections, siteIndexRef.current, siteConfig!);
456
- finishSave(uploadedItems);
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
@@ -4,3 +4,5 @@ export * from "./auth";
4
4
  export * from "./media";
5
5
  export * from "./deploy";
6
6
  export * from "./storage";
7
+ export * from "./collab/types";
8
+ export { setCollabProvider, getCollabProvider } from "./collab/provider-registry";
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
+ }
@@ -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
 
@@ -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
@@ -1,12 +1,12 @@
1
- import {
2
- Button,
3
- usePages
4
- } from "./chunk-ZSPCE6MZ.js";
5
1
  import {
6
2
  Checkbox,
7
3
  FormLabel,
8
4
  Input
9
5
  } from "./chunk-5THS4K5D.js";
6
+ import {
7
+ Button,
8
+ usePages
9
+ } from "./chunk-ZSPCE6MZ.js";
10
10
  import {
11
11
  DeleteIcon,
12
12
  DragHandle,
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