@agent-native/core 0.70.0 → 0.70.2

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 (172) hide show
  1. package/README.md +15 -15
  2. package/corpus/README.md +2 -2
  3. package/corpus/core/CHANGELOG.md +67 -0
  4. package/corpus/core/docs/design/durable-agent-runs.md +217 -0
  5. package/corpus/core/package.json +1 -1
  6. package/corpus/core/src/action.ts +89 -0
  7. package/corpus/core/src/agent/model-config.ts +0 -2
  8. package/corpus/core/src/agent/run-loop-with-resume.ts +48 -0
  9. package/corpus/core/src/cli/skills.ts +2 -2
  10. package/corpus/core/src/cli/sync-builder-starter-manifest.ts +275 -0
  11. package/corpus/core/src/client/AgentPanel.tsx +5 -3
  12. package/corpus/core/src/client/AssistantChat.tsx +8 -0
  13. package/corpus/core/src/client/client-surface.ts +41 -0
  14. package/corpus/core/src/client/composer/TiptapComposer.tsx +123 -12
  15. package/corpus/core/src/client/feedback-context.ts +4 -0
  16. package/corpus/core/src/client/index.ts +1 -0
  17. package/corpus/core/src/client/sse-event-processor.ts +7 -1
  18. package/corpus/core/src/mcp/build-server.ts +57 -1
  19. package/corpus/core/src/mcp/embed-app.ts +86 -2
  20. package/corpus/core/src/mcp/oauth-route.ts +109 -13
  21. package/corpus/core/src/server/core-routes-plugin.ts +78 -0
  22. package/corpus/core/src/server/onboarding-html.ts +35 -0
  23. package/corpus/core/src/templates/workspace-core/.agents/skills/reliable-mutations/SKILL.md +72 -0
  24. package/corpus/templates/analytics/.agents/skills/dashboard-management/SKILL.md +22 -0
  25. package/corpus/templates/analytics/AGENTS.md +8 -1
  26. package/corpus/templates/analytics/actions/install-dashboard-template.ts +97 -2
  27. package/corpus/templates/analytics/actions/update-dashboard.ts +36 -7
  28. package/corpus/templates/analytics/app/components/dashboard/SqlChart.tsx +30 -3
  29. package/corpus/templates/analytics/app/components/layout/Header.tsx +0 -1
  30. package/corpus/templates/analytics/app/components/layout/Sidebar.tsx +2 -4
  31. package/corpus/templates/analytics/app/hooks/use-navigation-state.ts +0 -3
  32. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/SqlChartCard.tsx +32 -2
  33. package/corpus/templates/analytics/changelog/2026-06-23-long-dashboard-and-analysis-names-in-the-sidebar-now-truncat.md +6 -0
  34. package/corpus/templates/analytics/changelog/2026-06-23-open-any-dashboard-chart-full-screen-in-a-modal-from-the-pan.md +6 -0
  35. package/corpus/templates/assets/actions/generate-image.ts +24 -0
  36. package/corpus/templates/assets/app/components/layout/Sidebar.tsx +2 -2
  37. package/corpus/templates/assets/app/global.css +17 -1
  38. package/corpus/templates/assets/app/routes/_index.tsx +94 -14
  39. package/corpus/templates/assets/changelog/2026-06-23-chat-dates-in-the-sidebar-no-longer-wrap-onto-two-lines.md +6 -0
  40. package/corpus/templates/assets/changelog/2026-06-23-clicking-image-video-or-refine-on-the-start-screen-now-drops.md +6 -0
  41. package/corpus/templates/assets/changelog/2026-06-23-pick-your-image-generation-model-right-from-the-chat-model-m.md +6 -0
  42. package/corpus/templates/clips/app/components/library/library-grid.tsx +1 -20
  43. package/corpus/templates/clips/app/components/recorder/countdown-overlay.tsx +72 -48
  44. package/corpus/templates/clips/app/lib/countdown-audio-cue.ts +35 -17
  45. package/corpus/templates/clips/changelog/2026-06-23-removed-select-button-from-library-hover-a-clip-to-select-it.md +6 -0
  46. package/corpus/templates/clips/changelog/2026-06-23-shared-video-links-now-play-for-anyone-without-signing-in.md +6 -0
  47. package/corpus/templates/clips/changelog/2026-06-23-skip-or-cancel-the-recording-countdown-with-buttons-beside-t.md +6 -0
  48. package/corpus/templates/clips/changelog/2026-06-23-the-desktop-camera-bubble-now-looks-sharp-the-instant-it-ope.md +6 -0
  49. package/corpus/templates/clips/changelog/2026-06-23-the-desktop-recording-widget-now-grows-downward-only-when-yo.md +6 -0
  50. package/corpus/templates/clips/changelog/2026-06-23-the-recording-start-sound-is-now-a-softer-more-modern-chime.md +6 -0
  51. package/corpus/templates/clips/chrome-extension/public/manifest.json +17 -8
  52. package/corpus/templates/clips/chrome-extension/src/background.ts +769 -180
  53. package/corpus/templates/clips/chrome-extension/src/content-script.ts +177 -0
  54. package/corpus/templates/clips/chrome-extension/src/offscreen.ts +466 -329
  55. package/corpus/templates/clips/chrome-extension/src/overlay.css +377 -0
  56. package/corpus/templates/clips/chrome-extension/src/overlay.html +12 -0
  57. package/corpus/templates/clips/chrome-extension/src/overlay.ts +330 -0
  58. package/corpus/templates/clips/chrome-extension/src/permission.html +227 -0
  59. package/corpus/templates/clips/chrome-extension/src/permission.ts +105 -0
  60. package/corpus/templates/clips/chrome-extension/src/popup.html +3 -0
  61. package/corpus/templates/clips/chrome-extension/src/popup.ts +50 -2
  62. package/corpus/templates/clips/chrome-extension/src/styles.css +50 -0
  63. package/corpus/templates/clips/chrome-extension/vite.config.ts +3 -0
  64. package/corpus/templates/clips/desktop/src/lib/audio-cue.ts +41 -24
  65. package/corpus/templates/clips/desktop/src/lib/bubble-webrtc.ts +119 -2
  66. package/corpus/templates/clips/desktop/src/overlays/countdown.tsx +21 -2
  67. package/corpus/templates/clips/desktop/src/styles.css +70 -2
  68. package/corpus/templates/clips/desktop/src-tauri/src/clips/mod.rs +73 -0
  69. package/corpus/templates/clips/server/routes/api/video/[recordingId].get.ts +34 -5
  70. package/corpus/templates/content/AGENTS.md +20 -3
  71. package/corpus/templates/content/actions/_database-utils.ts +15 -0
  72. package/corpus/templates/content/actions/_property-utils.ts +371 -4
  73. package/corpus/templates/content/actions/configure-document-property.ts +35 -1
  74. package/corpus/templates/content/actions/create-content-database.ts +5 -1
  75. package/corpus/templates/content/actions/delete-document-property.ts +52 -2
  76. package/corpus/templates/content/actions/duplicate-document-property.ts +34 -17
  77. package/corpus/templates/content/actions/reorder-document-property.ts +79 -0
  78. package/corpus/templates/content/actions/set-document-property.ts +39 -0
  79. package/corpus/templates/content/app/components/editor/DocumentBlockFields.tsx +806 -0
  80. package/corpus/templates/content/app/components/editor/DocumentDatabase.tsx +226 -68
  81. package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +59 -30
  82. package/corpus/templates/content/app/components/editor/DocumentProperties.tsx +25 -1
  83. package/corpus/templates/content/app/components/editor/blockFieldSaveController.ts +180 -0
  84. package/corpus/templates/content/app/components/editor/blockFieldSaveRegistry.ts +179 -0
  85. package/corpus/templates/content/app/components/editor/previewDocumentSaveController.ts +244 -0
  86. package/corpus/templates/content/app/components/editor/previewDocumentSaveRegistry.ts +132 -0
  87. package/corpus/templates/content/app/global.css +9 -0
  88. package/corpus/templates/content/app/hooks/use-document-properties.ts +21 -0
  89. package/corpus/templates/content/server/db/schema.ts +30 -0
  90. package/corpus/templates/content/server/plugins/db.ts +87 -0
  91. package/corpus/templates/content/shared/api.ts +7 -0
  92. package/corpus/templates/content/shared/properties.ts +109 -0
  93. package/corpus/templates/forms/.agents/skills/form-responses/SKILL.md +10 -0
  94. package/corpus/templates/forms/actions/export-responses.ts +6 -0
  95. package/corpus/templates/forms/actions/list-responses.ts +2 -0
  96. package/corpus/templates/forms/actions/response-insights.ts +2 -0
  97. package/corpus/templates/forms/app/pages/ResponsesPage.tsx +138 -1
  98. package/corpus/templates/forms/changelog/2026-06-23-response-tables-now-show-the-page-each-submission-came-from-.md +6 -0
  99. package/corpus/templates/forms/changelog/2026-06-23-response-tables-now-show-whether-feedback-came-from-the-web-.md +6 -0
  100. package/corpus/templates/forms/server/db/schema.ts +7 -0
  101. package/corpus/templates/forms/server/handlers/submissions.ts +19 -2
  102. package/corpus/templates/forms/server/lib/integrations.ts +76 -2
  103. package/corpus/templates/forms/server/plugins/db.ts +20 -0
  104. package/corpus/templates/forms/shared/types.ts +12 -0
  105. package/corpus/templates/plan/app/components/plan/wireframe/html-artboard.css +3 -1
  106. package/corpus/templates/plan/changelog/2026-06-23-large-diagrams-in-a-plan-now-scroll-within-their-block-inste.md +6 -0
  107. package/dist/action.js +87 -0
  108. package/dist/action.js.map +1 -1
  109. package/dist/agent/engine/builder-engine.d.ts +1 -1
  110. package/dist/agent/engine/builder-engine.d.ts.map +1 -1
  111. package/dist/agent/model-config.d.ts +2 -2
  112. package/dist/agent/model-config.d.ts.map +1 -1
  113. package/dist/agent/model-config.js +0 -2
  114. package/dist/agent/model-config.js.map +1 -1
  115. package/dist/agent/run-loop-with-resume.d.ts +13 -0
  116. package/dist/agent/run-loop-with-resume.d.ts.map +1 -1
  117. package/dist/agent/run-loop-with-resume.js +44 -0
  118. package/dist/agent/run-loop-with-resume.js.map +1 -1
  119. package/dist/cli/skills.js +2 -2
  120. package/dist/cli/skills.js.map +1 -1
  121. package/dist/cli/sync-builder-starter-manifest.d.ts +32 -0
  122. package/dist/cli/sync-builder-starter-manifest.d.ts.map +1 -0
  123. package/dist/cli/sync-builder-starter-manifest.js +179 -0
  124. package/dist/cli/sync-builder-starter-manifest.js.map +1 -0
  125. package/dist/client/AgentPanel.d.ts.map +1 -1
  126. package/dist/client/AgentPanel.js +5 -3
  127. package/dist/client/AgentPanel.js.map +1 -1
  128. package/dist/client/AssistantChat.d.ts +6 -1
  129. package/dist/client/AssistantChat.d.ts.map +1 -1
  130. package/dist/client/AssistantChat.js +2 -2
  131. package/dist/client/AssistantChat.js.map +1 -1
  132. package/dist/client/client-surface.d.ts +17 -0
  133. package/dist/client/client-surface.d.ts.map +1 -0
  134. package/dist/client/client-surface.js +24 -0
  135. package/dist/client/client-surface.js.map +1 -0
  136. package/dist/client/composer/TiptapComposer.d.ts +26 -1
  137. package/dist/client/composer/TiptapComposer.d.ts.map +1 -1
  138. package/dist/client/composer/TiptapComposer.js +27 -5
  139. package/dist/client/composer/TiptapComposer.js.map +1 -1
  140. package/dist/client/feedback-context.d.ts +3 -0
  141. package/dist/client/feedback-context.d.ts.map +1 -1
  142. package/dist/client/feedback-context.js +2 -0
  143. package/dist/client/feedback-context.js.map +1 -1
  144. package/dist/client/index.d.ts +1 -0
  145. package/dist/client/index.d.ts.map +1 -1
  146. package/dist/client/index.js +1 -0
  147. package/dist/client/index.js.map +1 -1
  148. package/dist/client/sse-event-processor.d.ts.map +1 -1
  149. package/dist/client/sse-event-processor.js +7 -1
  150. package/dist/client/sse-event-processor.js.map +1 -1
  151. package/dist/mcp/build-server.d.ts +21 -0
  152. package/dist/mcp/build-server.d.ts.map +1 -1
  153. package/dist/mcp/build-server.js +43 -1
  154. package/dist/mcp/build-server.js.map +1 -1
  155. package/dist/mcp/embed-app.d.ts.map +1 -1
  156. package/dist/mcp/embed-app.js +86 -2
  157. package/dist/mcp/embed-app.js.map +1 -1
  158. package/dist/mcp/oauth-route.d.ts.map +1 -1
  159. package/dist/mcp/oauth-route.js +95 -12
  160. package/dist/mcp/oauth-route.js.map +1 -1
  161. package/dist/server/core-routes-plugin.d.ts.map +1 -1
  162. package/dist/server/core-routes-plugin.js +63 -0
  163. package/dist/server/core-routes-plugin.js.map +1 -1
  164. package/dist/server/onboarding-html.d.ts.map +1 -1
  165. package/dist/server/onboarding-html.js +35 -0
  166. package/dist/server/onboarding-html.js.map +1 -1
  167. package/dist/templates/workspace-core/.agents/skills/reliable-mutations/SKILL.md +72 -0
  168. package/docs/design/durable-agent-runs.md +217 -0
  169. package/package.json +1 -1
  170. package/src/templates/workspace-core/.agents/skills/reliable-mutations/SKILL.md +72 -0
  171. package/corpus/templates/analytics/app/pages/About.tsx +0 -108
  172. package/corpus/templates/analytics/app/routes/about.tsx +0 -9
@@ -0,0 +1,180 @@
1
+ // Debounced save controller for an ADDITIONAL Blocks field's editor.
2
+ //
3
+ // Owns the only place that decides what is "saved" vs "dirty", so the rules are
4
+ // testable without rendering the editor (which pulls in TipTap):
5
+ //
6
+ // - A value is marked clean ONLY after the save promise RESOLVES. A failed save
7
+ // leaves the value dirty so it retries on the next edit or flush — it is never
8
+ // silently recorded as saved (review finding 6).
9
+ // - flush() persists the latest dirty content immediately, used on unmount /
10
+ // collapse so a debounce that has not fired yet is not dropped (finding 3).
11
+ // - mark() adopts fresh server content as the new confirmed baseline (e.g. an
12
+ // agent edit) without scheduling a save.
13
+ //
14
+ // SINGLE-FLIGHT + TRAILING (lost-update fix): the server write (set-document-
15
+ // property → upsert) is unconditional, so the LAST request to reach the DB wins.
16
+ // A monotonic sequence only protects local bookkeeping; it cannot reorder two
17
+ // requests already in flight. So we guarantee server write order == issue order
18
+ // by never having two saves in flight at once:
19
+ //
20
+ // - At most ONE save() call is outstanding per field.
21
+ // - While a save is in flight, further edits do not start a new save; they
22
+ // coalesce into a single `pending` payload.
23
+ // - When the in-flight save settles, if `pending` still differs from the last
24
+ // confirmed value, exactly one trailing save is issued for the LATEST
25
+ // pending content. This repeats (one at a time) until quiescent.
26
+ // - flush() awaits the in-flight save (if any) and then sends the final
27
+ // pending content, so the last value committed at the DB is deterministically
28
+ // the latest content the user typed.
29
+
30
+ export interface BlockFieldSaveController {
31
+ /** Record a user edit. Schedules a debounced save when the value is dirty. */
32
+ change(content: string): void;
33
+ /**
34
+ * Persist the latest dirty content now (unmount / collapse). Resolves after
35
+ * any in-flight save AND the resulting trailing save have settled, so the
36
+ * final DB value is the latest content.
37
+ */
38
+ flush(): Promise<void>;
39
+ /** Cancel any pending debounce without flushing. */
40
+ cancel(): void;
41
+ /** Adopt `content` as the confirmed-saved baseline (no save scheduled). */
42
+ mark(content: string): void;
43
+ /** The value last CONFIRMED persisted. */
44
+ readonly lastSaved: string;
45
+ /** The latest value the user has typed (may differ from lastSaved). */
46
+ readonly pending: string;
47
+ /** Whether a debounce timer is currently armed. */
48
+ readonly hasPendingTimer: boolean;
49
+ /** Whether a save() call is currently outstanding (in flight). */
50
+ readonly isSaving: boolean;
51
+ /**
52
+ * Whether this controller has CONFIRMED at least one local save (a save()
53
+ * resolved) since it was created. Once true, `lastSaved` is content this
54
+ * controller itself originated and persisted — so a server value that still
55
+ * differs from `lastSaved` is STALE (the server query hasn't refetched the
56
+ * just-saved value yet), not a genuinely newer external edit. Used by the
57
+ * remount seed/adopt path to avoid showing pre-save content. `mark()` (adopting
58
+ * fresh server content) clears it, since after a mark the baseline IS server.
59
+ */
60
+ readonly hasSavedLocally: boolean;
61
+ }
62
+
63
+ export function createBlockFieldSaveController(args: {
64
+ initialContent: string;
65
+ save: (content: string) => Promise<unknown>;
66
+ onError?: (error: unknown) => void;
67
+ debounceMs?: number;
68
+ setTimeoutFn?: typeof setTimeout;
69
+ clearTimeoutFn?: typeof clearTimeout;
70
+ }): BlockFieldSaveController {
71
+ const debounceMs = args.debounceMs ?? 500;
72
+ const setTimeoutFn = args.setTimeoutFn ?? setTimeout;
73
+ const clearTimeoutFn = args.clearTimeoutFn ?? clearTimeout;
74
+
75
+ let lastSaved = args.initialContent;
76
+ let pending = args.initialContent;
77
+ let timer: ReturnType<typeof setTimeout> | null = null;
78
+ // Becomes true once a local save() resolves; cleared when mark() adopts a
79
+ // server value as the new baseline. See `hasSavedLocally` doc above.
80
+ let hasSavedLocally = false;
81
+
82
+ // The single in-flight save, or null when idle. Edits made while this is set
83
+ // do NOT start a new save; they update `pending` and a trailing save fires
84
+ // when this settles. This is what makes server write order == issue order.
85
+ let inFlight: Promise<void> | null = null;
86
+
87
+ function clearTimer() {
88
+ if (timer !== null) {
89
+ clearTimeoutFn(timer);
90
+ timer = null;
91
+ }
92
+ }
93
+
94
+ // Start exactly one save if one isn't already running and there is dirty
95
+ // content. When it settles SUCCESSFULLY, kick the next trailing save so the
96
+ // latest pending content always ends up as the final DB write — one at a time.
97
+ function kick() {
98
+ if (inFlight !== null) return; // single-flight: never overlap saves.
99
+ if (pending === lastSaved) return; // nothing dirty to persist.
100
+
101
+ const attempted = pending;
102
+ const promise = Promise.resolve(args.save(attempted))
103
+ .then(() => {
104
+ // Mark clean ONLY after the save actually succeeds.
105
+ lastSaved = attempted;
106
+ hasSavedLocally = true;
107
+ inFlight = null;
108
+ // A trailing edit may have landed while this save was in flight. Issue
109
+ // exactly one more save for the LATEST pending content. Bounded: stops
110
+ // once pending === lastSaved.
111
+ kick();
112
+ })
113
+ .catch((error) => {
114
+ // A failed save never records its value as clean, so the content stays
115
+ // dirty (finding 6) and retries on the NEXT change() or flush() — we do
116
+ // NOT auto-retry here, to avoid a tight retry storm against a failing
117
+ // backend.
118
+ inFlight = null;
119
+ args.onError?.(error);
120
+ });
121
+
122
+ inFlight = promise;
123
+ }
124
+
125
+ return {
126
+ change(content: string) {
127
+ pending = content;
128
+ clearTimer();
129
+ if (content === lastSaved) return;
130
+ timer = setTimeoutFn(() => {
131
+ timer = null;
132
+ kick();
133
+ }, debounceMs);
134
+ },
135
+ async flush() {
136
+ clearTimer();
137
+ // 1) Wait out any in-flight save so we never overlap with it (single-
138
+ // flight) and so its successful trailing kick has fired.
139
+ while (inFlight !== null) {
140
+ await inFlight;
141
+ }
142
+ // 2) If the latest content still isn't persisted (a trailing edit, or the
143
+ // in-flight save failed), send exactly one final save for the LATEST
144
+ // pending content and await it. This is what makes the last value at the
145
+ // DB deterministically the latest content. We do NOT loop on repeated
146
+ // failure — flush is best-effort; a failed save stays dirty for the next
147
+ // edit/flush.
148
+ if (pending !== lastSaved) {
149
+ kick();
150
+ if (inFlight !== null) await inFlight;
151
+ }
152
+ },
153
+ cancel() {
154
+ clearTimer();
155
+ },
156
+ mark(content: string) {
157
+ clearTimer();
158
+ lastSaved = content;
159
+ pending = content;
160
+ // The baseline is now server-provided content, not a local save the server
161
+ // hasn't echoed — so server props are no longer "behind" this controller.
162
+ hasSavedLocally = false;
163
+ },
164
+ get lastSaved() {
165
+ return lastSaved;
166
+ },
167
+ get pending() {
168
+ return pending;
169
+ },
170
+ get hasPendingTimer() {
171
+ return timer !== null;
172
+ },
173
+ get isSaving() {
174
+ return inFlight !== null;
175
+ },
176
+ get hasSavedLocally() {
177
+ return hasSavedLocally;
178
+ },
179
+ };
180
+ }
@@ -0,0 +1,179 @@
1
+ // Module-level registry of ONE shared save controller per Blocks-field key.
2
+ //
3
+ // PROBLEM (cross-instance write inversion — what the per-key lane could NOT fix):
4
+ // A non-primary Blocks field saves through a debounced controller, and one
5
+ // controller was created PER `AdditionalBlockEditor` mount. The editor unmounts
6
+ // on collapse and remounts on reopen under the SAME `documentId:propertyId`
7
+ // key. Across that collapse→reopen, two controller instances for the same key
8
+ // can exist briefly: the OLD instance's unmount-flush is still settling while a
9
+ // NEW instance accepts a newer edit. Each controller has its OWN pending and
10
+ // in-flight state, so they cannot see each other's recency.
11
+ //
12
+ // The per-key serialization lane only ordered saves by ENQUEUE order. With two
13
+ // independent controllers the enqueue interleaving can be
14
+ // oldA → newC → oldB (the old controller issues a trailing save AFTER the new
15
+ // controller's edit), so a STALE value lands last and the user's newest edit is
16
+ // lost. Enqueue order can't capture content-recency across two controllers.
17
+ //
18
+ // FIX (one shared controller per key, ref-counted):
19
+ // There is exactly ONE controller instance per `documentId:propertyId` key, so
20
+ // there is exactly ONE `pending` value, ONE in-flight save, and a single
21
+ // single-flight + trailing pipeline for the field — regardless of how many
22
+ // editor instances mount/unmount/collapse/reopen. "Newest-wins WITHIN one
23
+ // controller" (already guaranteed by the controller) now applies across ALL
24
+ // instances, because there is only ever the one controller. An older save can
25
+ // never overwrite a newer one for the same field.
26
+ //
27
+ // - acquire(key, factory): returns the SAME controller for a key, creating it
28
+ // once (via `factory`) and bumping a ref-count. Concurrent editor instances
29
+ // for the same key share it.
30
+ // - release(key): decrements the ref-count. When it reaches 0 we do NOT evict
31
+ // immediately: we flush-then-evict. The final flush still lands, and a quick
32
+ // reopen BEFORE the flush settles re-acquires the SAME instance (ref-count
33
+ // goes back above 0, eviction is cancelled) — so there is never a competing
34
+ // second controller for the key.
35
+ //
36
+ // This subsumes the lane for cross-instance ordering: with a single controller
37
+ // per key, the lane has nothing left to serialize. The lane is therefore removed
38
+ // (see git history) to avoid two mechanisms that could disagree.
39
+
40
+ import type { BlockFieldSaveController } from "./blockFieldSaveController";
41
+
42
+ interface Entry {
43
+ controller: BlockFieldSaveController;
44
+ refCount: number;
45
+ // Set while a flush-then-evict is pending after refCount hit 0. If a reopen
46
+ // re-acquires before the flush settles, we clear this so the entry is NOT
47
+ // evicted out from under the live instance.
48
+ evicting: boolean;
49
+ }
50
+
51
+ const registry = new Map<string, Entry>();
52
+
53
+ /**
54
+ * Acquire the shared controller for `key`, creating it once via `factory`.
55
+ * Increments the ref-count and cancels any in-progress eviction so a reopen
56
+ * reuses the live instance rather than racing a fresh one.
57
+ */
58
+ export function acquireBlockFieldSaveController(
59
+ key: string,
60
+ factory: () => BlockFieldSaveController,
61
+ ): BlockFieldSaveController {
62
+ const entry = ensureEntry(key, factory);
63
+ entry.refCount += 1;
64
+ // A reopen before a pending eviction settled: keep the instance alive.
65
+ entry.evicting = false;
66
+ return entry.controller;
67
+ }
68
+
69
+ /**
70
+ * Return the EXISTING shared controller for `key`, or undefined if none is
71
+ * registered yet. Does NOT create an entry and does NOT change the ref-count.
72
+ *
73
+ * Used during render to seed a remount's displayed content from the live
74
+ * controller's latest pending value. If no controller exists yet there is no
75
+ * pending content to recover, so the caller falls back to the server value —
76
+ * which is correct. Crucially, peeking never creates an unreferenced entry, so a
77
+ * render that is discarded before commit (concurrent/StrictMode) leaks nothing;
78
+ * the lasting entry is only created by the formal acquire in the mount effect.
79
+ */
80
+ export function peekBlockFieldSaveController(
81
+ key: string,
82
+ ): BlockFieldSaveController | undefined {
83
+ return registry.get(key)?.controller;
84
+ }
85
+
86
+ function ensureEntry(
87
+ key: string,
88
+ factory: () => BlockFieldSaveController,
89
+ ): Entry {
90
+ let entry = registry.get(key);
91
+ if (!entry) {
92
+ entry = { controller: factory(), refCount: 0, evicting: false };
93
+ registry.set(key, entry);
94
+ }
95
+ return entry;
96
+ }
97
+
98
+ function controllerIsDirty(controller: BlockFieldSaveController): boolean {
99
+ return controller.pending !== controller.lastSaved;
100
+ }
101
+
102
+ /**
103
+ * Release one reference to the controller for `key`. When the last reference is
104
+ * released we flush-then-evict: flush the latest dirty content so a debounce
105
+ * that hadn't fired is not dropped, then remove the entry ONLY if it is still
106
+ * unreferenced after the flush settles (a reopen during the flush re-acquires
107
+ * the same instance and cancels the eviction).
108
+ */
109
+ export function releaseBlockFieldSaveController(key: string): void {
110
+ const entry = registry.get(key);
111
+ if (!entry) return;
112
+ entry.refCount -= 1;
113
+ if (entry.refCount > 0) return;
114
+
115
+ // Last reference gone: flush the final pending content, then evict once it
116
+ // has fully settled — but only if nobody re-acquired in the meantime.
117
+ entry.evicting = true;
118
+ const settle = () => {
119
+ const current = registry.get(key);
120
+ // Evict only if it is the SAME entry, still unreferenced, and still marked
121
+ // for eviction (a reopen would have flipped `evicting` off / refCount up).
122
+ if (current === entry && current.refCount === 0 && current.evicting) {
123
+ if (controllerIsDirty(current.controller)) {
124
+ current.evicting = false;
125
+ return;
126
+ }
127
+ registry.delete(key);
128
+ // Drop the per-key save-impl ref alongside the controller. It is only
129
+ // cleared on test reset otherwise, so without this it accumulates one
130
+ // entry per key for the lifetime of the page. A subsequent re-acquire of
131
+ // the same key recreates a fresh impl ref via blockFieldSaveImplRef (the
132
+ // hook writes its current impl every render before the acquire effect), so
133
+ // there is no stale closure. Only delete here — never while refCount > 0
134
+ // or a reopen is pending — because the live factory closes over this ref.
135
+ saveImpls.delete(key);
136
+ }
137
+ };
138
+ // flush() resolves after any in-flight save AND the trailing save have
139
+ // settled, so the final DB value is the latest content before we drop state.
140
+ Promise.resolve(entry.controller.flush()).then(settle, settle);
141
+ }
142
+
143
+ // The shared controller for a key is created ONCE, but each editor mount carries
144
+ // its own `save` implementation (a fresh `useSetDocumentProperty(...).mutateAsync`
145
+ // closure per render). To keep one controller while still calling the freshest
146
+ // impl, the controller's `save` reads through a per-key impl ref that every mount
147
+ // updates on acquire. The save TARGET (documentId:propertyId) is fixed by the key,
148
+ // so this only swaps the function identity, never the field it writes to.
149
+ type SaveImpl = (value: string) => Promise<unknown>;
150
+ const saveImpls = new Map<string, { current: SaveImpl }>();
151
+
152
+ /**
153
+ * The mutable save-impl ref for `key`, created on first use. The shared
154
+ * controller's factory closes over this ref; each mount writes its latest impl.
155
+ */
156
+ export function blockFieldSaveImplRef(key: string): { current: SaveImpl } {
157
+ let ref = saveImpls.get(key);
158
+ if (!ref) {
159
+ ref = {
160
+ current: () =>
161
+ Promise.reject(
162
+ new Error(`No save impl registered for block field "${key}"`),
163
+ ),
164
+ };
165
+ saveImpls.set(key, ref);
166
+ }
167
+ return ref;
168
+ }
169
+
170
+ /** Test-only: how many controllers the registry currently holds. */
171
+ export function activeControllerCount(): number {
172
+ return registry.size;
173
+ }
174
+
175
+ /** Test-only: reset the registry between tests. */
176
+ export function __resetBlockFieldSaveRegistry(): void {
177
+ registry.clear();
178
+ saveImpls.clear();
179
+ }
@@ -0,0 +1,244 @@
1
+ // Debounced save controller for the row PEEK's primary "Content" body (and its
2
+ // title), which — unlike the full-page editor — does NOT use Yjs collab and so
3
+ // persists through a plain debounced `update-document` write.
4
+ //
5
+ // WHY THIS EXISTS (data-loss fix): the peek used a bare `setTimeout` whose
6
+ // pending value lived only inside the timer closure. Every lifecycle transition
7
+ // that could happen before the ~450ms debounce fired — switching to another row,
8
+ // the peek editor unmounting, or the sheet closing / "Open page" navigating —
9
+ // CLEARED that timer instead of FLUSHING it, so the latest primary-body edit was
10
+ // dropped. The additional (non-primary) Blocks fields already flush-on-release
11
+ // via blockFieldSaveController; this controller gives the primary path the SAME
12
+ // durability, modeled directly on that controller:
13
+ //
14
+ // - A payload is marked clean ONLY after its save promise RESOLVES. A failed
15
+ // save leaves it dirty so it retries on the next edit or flush — never
16
+ // silently recorded as saved.
17
+ // - flush() persists the latest dirty payload immediately (row-switch / unmount
18
+ // / close / Open-page), so a debounce that has not fired yet is not dropped.
19
+ // - mark() adopts fresh server content as the new confirmed baseline (e.g. an
20
+ // agent edit) without scheduling a save.
21
+ //
22
+ // ONE CONTROLLER PER DOCUMENT ID (race-class elimination): this controller is
23
+ // bound to a SINGLE `documentId` for its entire life and NEVER retargets. The
24
+ // peek services many rows over its lifetime by acquiring a per-doc controller
25
+ // from `previewDocumentSaveRegistry` and releasing it on row-switch — exactly
26
+ // like the additional Blocks fields, which mount/unmount per (document, field)
27
+ // and never rebase a live controller's target. Two prior bugs came from the old
28
+ // single-controller-with-rebased-target design and are now STRUCTURALLY
29
+ // impossible:
30
+ //
31
+ // 1. Lane queue-jump (the per-doc serialization lane's `running`/`tail`
32
+ // microtask gap). Gone: with a single-flight controller per doc id there is
33
+ // never more than one save in flight for the id, so there is nothing to
34
+ // serialize across — the lane is deleted entirely (no second mechanism).
35
+ // 2. Stale completion after rebase. An OLD-row in-flight save that resolved
36
+ // AFTER a row-switch `mark()` used to overwrite the SHARED controller's
37
+ // `lastSaved` with the old payload and trigger a redundant save against the
38
+ // NEW row's baseline. Gone: each controller's `lastSaved`/`pending`/in-flight
39
+ // state belongs to ITS doc only; a stale completion can only ever advance
40
+ // ITS OWN baseline (correct), never another row's, because the controller's
41
+ // doc id is fixed at creation.
42
+ //
43
+ // SINGLE-FLIGHT + TRAILING (lost-update safety): the server write is
44
+ // unconditional (last request to the DB wins). Because at most one save() per
45
+ // controller is ever outstanding, server write order == issue order for the doc.
46
+ // While a save is in flight, edits coalesce into one `pending` payload; when it
47
+ // settles, exactly one trailing save fires for the LATEST payload if it differs.
48
+ //
49
+ // SYNCHRONOUS FINAL DISPATCH (async-flush-vs-sync-teardown race fix): call sites
50
+ // invoke flush() fire-and-forget on row-switch / close / Open-page / unmount, so
51
+ // the final write must be DISPATCHED (save() invoked) before the caller tears
52
+ // down or navigates. flush() therefore issues the final save SYNCHRONOUSLY — it
53
+ // does NOT await the in-flight save first. The save is bound to this controller's
54
+ // fixed doc id, so it can never be retargeted; single-flight guarantees it does
55
+ // not overlap a prior save for the id.
56
+
57
+ export interface PreviewDocumentPayload {
58
+ title: string;
59
+ content: string;
60
+ }
61
+
62
+ export interface PreviewDocumentSaveController {
63
+ /** The document id this controller is permanently bound to. */
64
+ readonly documentId: string;
65
+ /** Record a title edit. Schedules a debounced save when dirty. */
66
+ changeTitle(title: string): void;
67
+ /** Record a content (primary body) edit. Schedules a debounced save when dirty. */
68
+ changeContent(content: string): void;
69
+ /**
70
+ * Persist the latest dirty payload now (row-switch / unmount / close /
71
+ * Open-page). The final save is DISPATCHED SYNCHRONOUSLY before this returns,
72
+ * bound to this controller's fixed document id — so a fire-and-forget caller
73
+ * can tear down / navigate immediately and the trailing edit still lands on the
74
+ * correct document. The returned promise resolves once that final save (and any
75
+ * in-flight save it waited behind) has settled.
76
+ */
77
+ flush(): Promise<void>;
78
+ /** Cancel any pending debounce without flushing. */
79
+ cancel(): void;
80
+ /** Adopt `payload` as the confirmed-saved baseline (no save scheduled). */
81
+ mark(payload: PreviewDocumentPayload): void;
82
+ /** The payload last CONFIRMED persisted. */
83
+ readonly lastSaved: PreviewDocumentPayload;
84
+ /** The latest payload the user has typed (may differ from lastSaved). */
85
+ readonly pending: PreviewDocumentPayload;
86
+ /** Whether a debounce timer is currently armed. */
87
+ readonly hasPendingTimer: boolean;
88
+ /** Whether a save() call is currently outstanding (in flight). */
89
+ readonly isSaving: boolean;
90
+ /**
91
+ * Whether this controller has confirmed at least one local save since creation.
92
+ * Until the server query echoes that payload, clean local state is newer than
93
+ * stale item/document props and must be preserved on quick preview reopens.
94
+ */
95
+ readonly hasSavedLocally: boolean;
96
+ }
97
+
98
+ function payloadsEqual(a: PreviewDocumentPayload, b: PreviewDocumentPayload) {
99
+ return a.title === b.title && a.content === b.content;
100
+ }
101
+
102
+ export function createPreviewDocumentSaveController(args: {
103
+ /**
104
+ * The document id this controller persists to, fixed for its entire life. A
105
+ * controller NEVER changes which document it targets — switching rows acquires
106
+ * a different controller (see previewDocumentSaveRegistry).
107
+ */
108
+ documentId: string;
109
+ initial: PreviewDocumentPayload;
110
+ /** Persist `payload` to this controller's document. */
111
+ save: (
112
+ documentId: string,
113
+ payload: PreviewDocumentPayload,
114
+ ) => Promise<unknown>;
115
+ onSaved?: (payload: PreviewDocumentPayload) => void;
116
+ onError?: (error: unknown) => void;
117
+ debounceMs?: number;
118
+ setTimeoutFn?: typeof setTimeout;
119
+ clearTimeoutFn?: typeof clearTimeout;
120
+ }): PreviewDocumentSaveController {
121
+ const documentId = args.documentId;
122
+ const debounceMs = args.debounceMs ?? 450;
123
+ const setTimeoutFn = args.setTimeoutFn ?? setTimeout;
124
+ const clearTimeoutFn = args.clearTimeoutFn ?? clearTimeout;
125
+
126
+ let lastSaved: PreviewDocumentPayload = { ...args.initial };
127
+ let pending: PreviewDocumentPayload = { ...args.initial };
128
+ let timer: ReturnType<typeof setTimeout> | null = null;
129
+ let hasSavedLocally = false;
130
+
131
+ // The single in-flight save, or null when idle. A debounced edit made while
132
+ // this is set does NOT start a new save; it updates `pending` and a trailing
133
+ // save fires when this settles. At most one save per controller is ever
134
+ // outstanding, so server write order == issue order for this document id.
135
+ let inFlight: Promise<void> | null = null;
136
+
137
+ function clearTimer() {
138
+ if (timer !== null) {
139
+ clearTimeoutFn(timer);
140
+ timer = null;
141
+ }
142
+ }
143
+
144
+ // Start exactly one save if one isn't already running and there is dirty
145
+ // content. On SUCCESS the baseline advances to `attempted` (only what we
146
+ // actually persisted is ever marked clean) and the next trailing save is
147
+ // kicked. A failure leaves the payload dirty for the next edit/flush — it is
148
+ // never silently recorded as saved.
149
+ function kick() {
150
+ if (inFlight !== null) return; // single-flight: never overlap saves.
151
+ if (payloadsEqual(pending, lastSaved)) return; // nothing dirty.
152
+
153
+ const attempted = { ...pending };
154
+ const promise = Promise.resolve(args.save(documentId, attempted))
155
+ .then(() => {
156
+ lastSaved = attempted;
157
+ hasSavedLocally = true;
158
+ inFlight = null;
159
+ args.onSaved?.(attempted);
160
+ // A trailing edit may have landed while this save was in flight. Issue
161
+ // exactly one more for the LATEST payload. Bounded: stops once quiescent.
162
+ kick();
163
+ })
164
+ .catch((error) => {
165
+ inFlight = null;
166
+ args.onError?.(error);
167
+ });
168
+ inFlight = promise;
169
+ }
170
+
171
+ function schedule() {
172
+ clearTimer();
173
+ if (payloadsEqual(pending, lastSaved)) return;
174
+ timer = setTimeoutFn(() => {
175
+ timer = null;
176
+ kick();
177
+ }, debounceMs);
178
+ }
179
+
180
+ return {
181
+ documentId,
182
+ changeTitle(title: string) {
183
+ pending = { ...pending, title };
184
+ schedule();
185
+ },
186
+ changeContent(content: string) {
187
+ pending = { ...pending, content };
188
+ schedule();
189
+ },
190
+ flush() {
191
+ clearTimer();
192
+ // Nothing dirty: no-op, no double-save of clean content. If a save is
193
+ // still settling, return it so the caller can await full quiescence.
194
+ if (payloadsEqual(pending, lastSaved)) {
195
+ return inFlight ?? Promise.resolve();
196
+ }
197
+ // The latest payload still isn't persisted (a trailing edit, or a debounce
198
+ // that hasn't fired). Dispatch the final save SYNCHRONOUSLY — kick() issues
199
+ // it now if the lane is idle. Bound to this controller's fixed doc id, so a
200
+ // fire-and-forget caller can tear down immediately and the write still lands
201
+ // on the correct document. If a save IS already in flight, single-flight
202
+ // skips dispatch here and its success kicks the trailing save for the latest
203
+ // payload; we return a promise that resolves once that trailing save (the
204
+ // one carrying `pending`) has settled.
205
+ kick();
206
+ return waitUntilPersisted({ ...pending });
207
+ },
208
+ cancel() {
209
+ clearTimer();
210
+ },
211
+ mark(payload: PreviewDocumentPayload) {
212
+ clearTimer();
213
+ lastSaved = { ...payload };
214
+ pending = { ...payload };
215
+ hasSavedLocally = false;
216
+ },
217
+ get lastSaved() {
218
+ return { ...lastSaved };
219
+ },
220
+ get pending() {
221
+ return { ...pending };
222
+ },
223
+ get hasPendingTimer() {
224
+ return timer !== null;
225
+ },
226
+ get isSaving() {
227
+ return inFlight !== null;
228
+ },
229
+ get hasSavedLocally() {
230
+ return hasSavedLocally;
231
+ },
232
+ };
233
+
234
+ // Resolve once `target` has been confirmed persisted (or the controller went
235
+ // quiescent because a failed save left it dirty — flush is best-effort and does
236
+ // not loop on repeated failure). Chains strictly on the in-flight save promise,
237
+ // so it never busy-waits and always tracks the real settle of the trailing
238
+ // save that carries `target`.
239
+ function waitUntilPersisted(target: PreviewDocumentPayload): Promise<void> {
240
+ if (payloadsEqual(lastSaved, target)) return Promise.resolve();
241
+ if (inFlight === null) return Promise.resolve(); // quiescent (e.g. failed).
242
+ return inFlight.then(() => waitUntilPersisted(target));
243
+ }
244
+ }