@agent-native/core 0.70.0 → 0.70.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -15
- package/corpus/README.md +2 -2
- package/corpus/core/CHANGELOG.md +61 -0
- package/corpus/core/docs/design/durable-agent-runs.md +217 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/action.ts +89 -0
- package/corpus/core/src/agent/model-config.ts +0 -2
- package/corpus/core/src/agent/run-loop-with-resume.ts +48 -0
- package/corpus/core/src/cli/skills.ts +2 -2
- package/corpus/core/src/client/AgentPanel.tsx +5 -3
- package/corpus/core/src/client/AssistantChat.tsx +8 -0
- package/corpus/core/src/client/client-surface.ts +41 -0
- package/corpus/core/src/client/composer/TiptapComposer.tsx +123 -12
- package/corpus/core/src/client/feedback-context.ts +4 -0
- package/corpus/core/src/client/index.ts +1 -0
- package/corpus/core/src/client/sse-event-processor.ts +7 -1
- package/corpus/core/src/mcp/build-server.ts +57 -1
- package/corpus/core/src/mcp/embed-app.ts +86 -2
- package/corpus/core/src/mcp/oauth-route.ts +109 -13
- package/corpus/core/src/server/core-routes-plugin.ts +78 -0
- package/corpus/core/src/server/onboarding-html.ts +35 -0
- package/corpus/core/src/templates/workspace-core/.agents/skills/reliable-mutations/SKILL.md +72 -0
- package/corpus/templates/analytics/.agents/skills/dashboard-management/SKILL.md +22 -0
- package/corpus/templates/analytics/AGENTS.md +8 -1
- package/corpus/templates/analytics/actions/install-dashboard-template.ts +97 -2
- package/corpus/templates/analytics/actions/update-dashboard.ts +36 -7
- package/corpus/templates/analytics/app/components/dashboard/SqlChart.tsx +30 -3
- package/corpus/templates/analytics/app/components/layout/Header.tsx +0 -1
- package/corpus/templates/analytics/app/components/layout/Sidebar.tsx +2 -4
- package/corpus/templates/analytics/app/hooks/use-navigation-state.ts +0 -3
- package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/SqlChartCard.tsx +32 -2
- package/corpus/templates/analytics/changelog/2026-06-23-long-dashboard-and-analysis-names-in-the-sidebar-now-truncat.md +6 -0
- package/corpus/templates/analytics/changelog/2026-06-23-open-any-dashboard-chart-full-screen-in-a-modal-from-the-pan.md +6 -0
- package/corpus/templates/assets/actions/generate-image.ts +24 -0
- package/corpus/templates/assets/app/components/layout/Sidebar.tsx +2 -2
- package/corpus/templates/assets/app/global.css +17 -1
- package/corpus/templates/assets/app/routes/_index.tsx +94 -14
- package/corpus/templates/assets/changelog/2026-06-23-chat-dates-in-the-sidebar-no-longer-wrap-onto-two-lines.md +6 -0
- package/corpus/templates/assets/changelog/2026-06-23-clicking-image-video-or-refine-on-the-start-screen-now-drops.md +6 -0
- package/corpus/templates/assets/changelog/2026-06-23-pick-your-image-generation-model-right-from-the-chat-model-m.md +6 -0
- package/corpus/templates/clips/app/components/library/library-grid.tsx +1 -20
- package/corpus/templates/clips/app/components/recorder/countdown-overlay.tsx +72 -48
- package/corpus/templates/clips/app/lib/countdown-audio-cue.ts +35 -17
- package/corpus/templates/clips/changelog/2026-06-23-removed-select-button-from-library-hover-a-clip-to-select-it.md +6 -0
- package/corpus/templates/clips/changelog/2026-06-23-shared-video-links-now-play-for-anyone-without-signing-in.md +6 -0
- package/corpus/templates/clips/changelog/2026-06-23-skip-or-cancel-the-recording-countdown-with-buttons-beside-t.md +6 -0
- package/corpus/templates/clips/changelog/2026-06-23-the-desktop-camera-bubble-now-looks-sharp-the-instant-it-ope.md +6 -0
- package/corpus/templates/clips/changelog/2026-06-23-the-desktop-recording-widget-now-grows-downward-only-when-yo.md +6 -0
- package/corpus/templates/clips/changelog/2026-06-23-the-recording-start-sound-is-now-a-softer-more-modern-chime.md +6 -0
- package/corpus/templates/clips/chrome-extension/public/manifest.json +17 -8
- package/corpus/templates/clips/chrome-extension/src/background.ts +769 -180
- package/corpus/templates/clips/chrome-extension/src/content-script.ts +177 -0
- package/corpus/templates/clips/chrome-extension/src/offscreen.ts +466 -329
- package/corpus/templates/clips/chrome-extension/src/overlay.css +377 -0
- package/corpus/templates/clips/chrome-extension/src/overlay.html +12 -0
- package/corpus/templates/clips/chrome-extension/src/overlay.ts +330 -0
- package/corpus/templates/clips/chrome-extension/src/permission.html +227 -0
- package/corpus/templates/clips/chrome-extension/src/permission.ts +105 -0
- package/corpus/templates/clips/chrome-extension/src/popup.html +3 -0
- package/corpus/templates/clips/chrome-extension/src/popup.ts +50 -2
- package/corpus/templates/clips/chrome-extension/src/styles.css +50 -0
- package/corpus/templates/clips/chrome-extension/vite.config.ts +3 -0
- package/corpus/templates/clips/desktop/src/lib/audio-cue.ts +41 -24
- package/corpus/templates/clips/desktop/src/lib/bubble-webrtc.ts +119 -2
- package/corpus/templates/clips/desktop/src/overlays/countdown.tsx +21 -2
- package/corpus/templates/clips/desktop/src/styles.css +70 -2
- package/corpus/templates/clips/desktop/src-tauri/src/clips/mod.rs +73 -0
- package/corpus/templates/clips/server/routes/api/video/[recordingId].get.ts +34 -5
- package/corpus/templates/content/AGENTS.md +20 -3
- package/corpus/templates/content/actions/_database-utils.ts +15 -0
- package/corpus/templates/content/actions/_property-utils.ts +371 -4
- package/corpus/templates/content/actions/configure-document-property.ts +35 -1
- package/corpus/templates/content/actions/create-content-database.ts +5 -1
- package/corpus/templates/content/actions/delete-document-property.ts +52 -2
- package/corpus/templates/content/actions/duplicate-document-property.ts +34 -17
- package/corpus/templates/content/actions/reorder-document-property.ts +79 -0
- package/corpus/templates/content/actions/set-document-property.ts +39 -0
- package/corpus/templates/content/app/components/editor/DocumentBlockFields.tsx +806 -0
- package/corpus/templates/content/app/components/editor/DocumentDatabase.tsx +226 -68
- package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +59 -30
- package/corpus/templates/content/app/components/editor/DocumentProperties.tsx +25 -1
- package/corpus/templates/content/app/components/editor/blockFieldSaveController.ts +180 -0
- package/corpus/templates/content/app/components/editor/blockFieldSaveRegistry.ts +179 -0
- package/corpus/templates/content/app/components/editor/previewDocumentSaveController.ts +244 -0
- package/corpus/templates/content/app/components/editor/previewDocumentSaveRegistry.ts +132 -0
- package/corpus/templates/content/app/global.css +9 -0
- package/corpus/templates/content/app/hooks/use-document-properties.ts +21 -0
- package/corpus/templates/content/server/db/schema.ts +30 -0
- package/corpus/templates/content/server/plugins/db.ts +87 -0
- package/corpus/templates/content/shared/api.ts +7 -0
- package/corpus/templates/content/shared/properties.ts +109 -0
- package/corpus/templates/forms/.agents/skills/form-responses/SKILL.md +10 -0
- package/corpus/templates/forms/actions/export-responses.ts +6 -0
- package/corpus/templates/forms/actions/list-responses.ts +2 -0
- package/corpus/templates/forms/actions/response-insights.ts +2 -0
- package/corpus/templates/forms/app/pages/ResponsesPage.tsx +138 -1
- package/corpus/templates/forms/changelog/2026-06-23-response-tables-now-show-the-page-each-submission-came-from-.md +6 -0
- package/corpus/templates/forms/changelog/2026-06-23-response-tables-now-show-whether-feedback-came-from-the-web-.md +6 -0
- package/corpus/templates/forms/server/db/schema.ts +7 -0
- package/corpus/templates/forms/server/handlers/submissions.ts +19 -2
- package/corpus/templates/forms/server/lib/integrations.ts +76 -2
- package/corpus/templates/forms/server/plugins/db.ts +20 -0
- package/corpus/templates/forms/shared/types.ts +12 -0
- package/corpus/templates/plan/app/components/plan/wireframe/html-artboard.css +3 -1
- package/corpus/templates/plan/changelog/2026-06-23-large-diagrams-in-a-plan-now-scroll-within-their-block-inste.md +6 -0
- package/dist/action.js +87 -0
- package/dist/action.js.map +1 -1
- package/dist/agent/engine/builder-engine.d.ts +1 -1
- package/dist/agent/engine/builder-engine.d.ts.map +1 -1
- package/dist/agent/model-config.d.ts +2 -2
- package/dist/agent/model-config.d.ts.map +1 -1
- package/dist/agent/model-config.js +0 -2
- package/dist/agent/model-config.js.map +1 -1
- package/dist/agent/run-loop-with-resume.d.ts +13 -0
- package/dist/agent/run-loop-with-resume.d.ts.map +1 -1
- package/dist/agent/run-loop-with-resume.js +44 -0
- package/dist/agent/run-loop-with-resume.js.map +1 -1
- package/dist/cli/skills.js +2 -2
- package/dist/cli/skills.js.map +1 -1
- package/dist/client/AgentPanel.d.ts.map +1 -1
- package/dist/client/AgentPanel.js +5 -3
- package/dist/client/AgentPanel.js.map +1 -1
- package/dist/client/AssistantChat.d.ts +6 -1
- package/dist/client/AssistantChat.d.ts.map +1 -1
- package/dist/client/AssistantChat.js +2 -2
- package/dist/client/AssistantChat.js.map +1 -1
- package/dist/client/client-surface.d.ts +17 -0
- package/dist/client/client-surface.d.ts.map +1 -0
- package/dist/client/client-surface.js +24 -0
- package/dist/client/client-surface.js.map +1 -0
- package/dist/client/composer/TiptapComposer.d.ts +26 -1
- package/dist/client/composer/TiptapComposer.d.ts.map +1 -1
- package/dist/client/composer/TiptapComposer.js +27 -5
- package/dist/client/composer/TiptapComposer.js.map +1 -1
- package/dist/client/feedback-context.d.ts +3 -0
- package/dist/client/feedback-context.d.ts.map +1 -1
- package/dist/client/feedback-context.js +2 -0
- package/dist/client/feedback-context.js.map +1 -1
- package/dist/client/index.d.ts +1 -0
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +1 -0
- package/dist/client/index.js.map +1 -1
- package/dist/client/sse-event-processor.d.ts.map +1 -1
- package/dist/client/sse-event-processor.js +7 -1
- package/dist/client/sse-event-processor.js.map +1 -1
- package/dist/mcp/build-server.d.ts +21 -0
- package/dist/mcp/build-server.d.ts.map +1 -1
- package/dist/mcp/build-server.js +43 -1
- package/dist/mcp/build-server.js.map +1 -1
- package/dist/mcp/embed-app.d.ts.map +1 -1
- package/dist/mcp/embed-app.js +86 -2
- package/dist/mcp/embed-app.js.map +1 -1
- package/dist/mcp/oauth-route.d.ts.map +1 -1
- package/dist/mcp/oauth-route.js +95 -12
- package/dist/mcp/oauth-route.js.map +1 -1
- package/dist/server/core-routes-plugin.d.ts.map +1 -1
- package/dist/server/core-routes-plugin.js +63 -0
- package/dist/server/core-routes-plugin.js.map +1 -1
- package/dist/server/onboarding-html.d.ts.map +1 -1
- package/dist/server/onboarding-html.js +35 -0
- package/dist/server/onboarding-html.js.map +1 -1
- package/dist/templates/workspace-core/.agents/skills/reliable-mutations/SKILL.md +72 -0
- package/docs/design/durable-agent-runs.md +217 -0
- package/package.json +1 -1
- package/src/templates/workspace-core/.agents/skills/reliable-mutations/SKILL.md +72 -0
- package/corpus/templates/analytics/app/pages/About.tsx +0 -108
- 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
|
+
}
|