@agent-native/core 0.161.10 → 0.161.14
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/corpus/README.md +1 -1
- package/corpus/templates/analytics/actions/clear-selected-dashboard-object.ts +71 -0
- package/corpus/templates/analytics/actions/view-screen.ts +68 -4
- package/corpus/templates/analytics/app/hooks/use-dashboard-chat-context.ts +41 -66
- package/corpus/templates/analytics/app/lib/selected-object.ts +53 -0
- package/corpus/templates/analytics/app/pages/Ask.tsx +21 -0
- package/corpus/templates/analytics/changelog/2026-08-18-ask-no-stale-dashboard-context.md +6 -0
- package/corpus/templates/calendar/app/root.tsx +12 -4
- package/corpus/templates/calendar/changelog/2026-08-18-calendar-stays-responsive-in-the-desktop-app-without-focus-t.md +6 -0
- package/corpus/templates/clips/app/components/library/recording-card.tsx +19 -3
- package/corpus/templates/clips/changelog/2026-08-18-deleting-a-recording-no-longer-freezes-clips.md +6 -0
- package/corpus/templates/clips/desktop/src/app.tsx +9 -10
- package/corpus/templates/clips/desktop/src/lib/recorder.ts +4 -3
- package/corpus/templates/clips/desktop/src/lib/recording-start-guard.ts +26 -0
- package/corpus/templates/content/actions/export-document.ts +68 -3
- package/corpus/templates/content/changelog/2026-08-06-database-exports-now-include-their-pages.md +6 -0
- package/corpus/templates/content/shared/document-export.ts +25 -0
- package/corpus/templates/design/.agents/skills/creative-context/SKILL.md +3 -0
- package/corpus/templates/design/app/components/editor/PromptDialog.tsx +66 -0
- package/corpus/templates/design/app/pages/Index.tsx +45 -1
- package/corpus/templates/design/app/pages/design-editor/creative-context-precedent.ts +233 -0
- package/corpus/templates/design/changelog/2026-08-11-new-designs-now-skip-the-intake-questions-and-follow-your-ex.md +6 -0
- package/corpus/templates/dispatch/app/chat-first-pages.ts +40 -40
- package/corpus/templates/dispatch/app/i18n-data.ts +5 -5
- package/corpus/templates/factory/AGENTS.md +30 -29
- package/corpus/templates/slides/app/components/layout/AgentWorkIndicator.tsx +56 -9
- package/dist/action.js +68 -2
- package/dist/agent/chat-health-alert.js +10 -1
- package/dist/client/AssistantChat.js +1 -0
- package/dist/client/chat-first/app-open-actions.js +1 -1
- package/dist/client/chat-first/copy.js +2 -2
- package/dist/client/conversation/use-near-bottom-autoscroll.js +34 -4
- package/dist/client/embed-auth.js +40 -3
- package/dist/client/mcp-apps/McpAppRenderer.js +7 -2
- package/dist/client/route-state.js +42 -5
- package/dist/client/use-change-version.js +71 -25
- package/dist/client/use-db-sync.d.ts +1 -0
- package/dist/client/use-db-sync.js +223 -45
- package/dist/collab/awareness.js +37 -4
- package/dist/collab/client.js +17 -1
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/mcp/embed-app.js +71 -2
- package/dist/notifications/routes.d.ts +1 -1
- package/dist/observability/routes.d.ts +3 -3
- package/dist/secrets/routes.d.ts +9 -9
- package/dist/server/poll.d.ts +22 -14
- package/dist/server/poll.js +243 -67
- package/dist/styles/agent-conversation.css +25 -4
- package/package.json +1 -1
|
@@ -15,6 +15,8 @@ const HIDDEN_POLL_INTERVAL_MS = 10_000;
|
|
|
15
15
|
const POLL_AUTH_FAILURE_COOLDOWN_MS = 60_000;
|
|
16
16
|
const LOCAL_SSE_RECONNECT_BASE_MS = 1_000;
|
|
17
17
|
const LOCAL_SSE_RECONNECT_MAX_MS = 30_000;
|
|
18
|
+
const ACTIVE_CHAT_TTL_MS = 5 * 60 * 1_000;
|
|
19
|
+
const ACTIVE_CHAT_MAX = 1_000;
|
|
18
20
|
/**
|
|
19
21
|
* Max cadence for SSE/poll-driven query invalidation in `useDbSync`. Events
|
|
20
22
|
* that arrive within this window of the first one in a burst are merged into
|
|
@@ -42,6 +44,61 @@ class HttpStatusError extends Error {
|
|
|
42
44
|
this.status = status;
|
|
43
45
|
}
|
|
44
46
|
}
|
|
47
|
+
const INITIAL_SYNC_CURSOR = { version: 0, id: "" };
|
|
48
|
+
function encodeSyncCursor(cursor) {
|
|
49
|
+
return `${cursor.version}.${cursor.id}`;
|
|
50
|
+
}
|
|
51
|
+
function decodeSyncCursor(value) {
|
|
52
|
+
if (typeof value !== "string")
|
|
53
|
+
return undefined;
|
|
54
|
+
const separator = value.indexOf(".");
|
|
55
|
+
if (separator < 1)
|
|
56
|
+
return undefined;
|
|
57
|
+
const version = Number(value.slice(0, separator));
|
|
58
|
+
const id = value.slice(separator + 1);
|
|
59
|
+
if (!Number.isSafeInteger(version) || version < 0)
|
|
60
|
+
return undefined;
|
|
61
|
+
return { version, id };
|
|
62
|
+
}
|
|
63
|
+
function compareSyncCursors(a, b) {
|
|
64
|
+
if (a.version !== b.version)
|
|
65
|
+
return a.version - b.version;
|
|
66
|
+
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
|
67
|
+
}
|
|
68
|
+
function syncEventCursor(event) {
|
|
69
|
+
if (typeof event.version !== "number" ||
|
|
70
|
+
!Number.isSafeInteger(event.version) ||
|
|
71
|
+
typeof event.cursorId !== "string") {
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
return { version: event.version, id: event.cursorId };
|
|
75
|
+
}
|
|
76
|
+
function maxSyncCursor(a, b) {
|
|
77
|
+
return b && compareSyncCursors(b, a) > 0 ? b : a;
|
|
78
|
+
}
|
|
79
|
+
function cursorForEvents(events) {
|
|
80
|
+
let cursor;
|
|
81
|
+
for (const event of events) {
|
|
82
|
+
const eventCursor = syncEventCursor(event) ??
|
|
83
|
+
(typeof event.version === "number" &&
|
|
84
|
+
Number.isSafeInteger(event.version) &&
|
|
85
|
+
event.version >= 0
|
|
86
|
+
? { version: event.version, id: "" }
|
|
87
|
+
: undefined);
|
|
88
|
+
if (eventCursor &&
|
|
89
|
+
(!cursor || compareSyncCursors(eventCursor, cursor) > 0)) {
|
|
90
|
+
cursor = eventCursor;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return cursor;
|
|
94
|
+
}
|
|
95
|
+
function isSyncEventAfterCursor(event, cursor, subscriberVersion) {
|
|
96
|
+
const eventCursor = syncEventCursor(event);
|
|
97
|
+
if (eventCursor)
|
|
98
|
+
return compareSyncCursors(eventCursor, cursor) > 0;
|
|
99
|
+
const version = typeof event.version === "number" ? event.version : 0;
|
|
100
|
+
return version === 0 || version > subscriberVersion;
|
|
101
|
+
}
|
|
45
102
|
function getPollAbortMs(interval) {
|
|
46
103
|
return Math.max(POLL_ABORT_MIN_MS, interval * 4);
|
|
47
104
|
}
|
|
@@ -163,17 +220,35 @@ export function isInteractionCriticalSyncEvent(event) {
|
|
|
163
220
|
INTERACTION_CRITICAL_APP_STATE_KEYS.some((key) => event.key === key ||
|
|
164
221
|
(typeof event.key === "string" && event.key.startsWith(`${key}:`)))));
|
|
165
222
|
}
|
|
166
|
-
|
|
223
|
+
function appStateQueryMatchesKeys(query, changedKeys) {
|
|
224
|
+
if (query.queryKey[0] !== "app-state")
|
|
225
|
+
return false;
|
|
226
|
+
// The aggregate query is the fallback for callers that read all app state.
|
|
227
|
+
if (query.queryKey.length === 1)
|
|
228
|
+
return true;
|
|
229
|
+
const queryStateKey = query.queryKey[1];
|
|
230
|
+
if (typeof queryStateKey !== "string")
|
|
231
|
+
return false;
|
|
232
|
+
return changedKeys.some((changedKey) => changedKey === "*" ||
|
|
233
|
+
changedKey === queryStateKey ||
|
|
234
|
+
changedKey.startsWith(`${queryStateKey}:`) ||
|
|
235
|
+
queryStateKey.startsWith(`${changedKey}:`));
|
|
236
|
+
}
|
|
237
|
+
async function fetchPollJson(pollUrl, cursor, interval, token) {
|
|
167
238
|
const controller = typeof AbortController === "undefined" ? null : new AbortController();
|
|
168
239
|
const timeout = controller
|
|
169
240
|
? setTimeout(() => controller.abort(), getPollAbortMs(interval))
|
|
170
241
|
: null;
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
|
|
242
|
+
// The numeric `since` remains for backward-compatible servers. The
|
|
243
|
+
// composite cursor closes the same-millisecond cross-instance gap by
|
|
244
|
+
// ordering durable rows on `(version,id)`.
|
|
245
|
+
const separator = pollUrl.includes("?") ? "&" : "?";
|
|
246
|
+
const cursorQuery = `since=${cursor.version}`;
|
|
247
|
+
const compositeCursorQuery = cursor.version > 0 || cursor.id
|
|
248
|
+
? `&cursor=${encodeURIComponent(encodeSyncCursor(cursor))}`
|
|
249
|
+
: "";
|
|
250
|
+
const tokenQuery = token ? `&token=${encodeURIComponent(token)}` : "";
|
|
251
|
+
const url = `${pollUrl}${separator}${cursorQuery}${compositeCursorQuery}${tokenQuery}`;
|
|
177
252
|
try {
|
|
178
253
|
const res = await fetch(url, controller ? { signal: controller.signal } : undefined);
|
|
179
254
|
if (!res.ok)
|
|
@@ -192,7 +267,7 @@ class SyncTransport {
|
|
|
192
267
|
sseUrl;
|
|
193
268
|
gateway;
|
|
194
269
|
subscribers = new Map();
|
|
195
|
-
|
|
270
|
+
cursorRef = { ...INITIAL_SYNC_CURSOR };
|
|
196
271
|
timer = null;
|
|
197
272
|
stopped = false;
|
|
198
273
|
inFlight = false;
|
|
@@ -202,7 +277,7 @@ class SyncTransport {
|
|
|
202
277
|
sseConnected = false;
|
|
203
278
|
authFailureUntil = 0;
|
|
204
279
|
consecutiveFailures = 0;
|
|
205
|
-
activeChatIds = new
|
|
280
|
+
activeChatIds = new Map();
|
|
206
281
|
// Hosted-gateway state. `mode` starts "hosted" when a binding is present and
|
|
207
282
|
// flips to "local" on health-gate revert; `token` is the current subscribe
|
|
208
283
|
// token (minted from the app, rotated over the stream), never part of any
|
|
@@ -237,7 +312,9 @@ class SyncTransport {
|
|
|
237
312
|
const base = `${this.gateway.sseUrl}?token=${encodeURIComponent(this.token)}`;
|
|
238
313
|
// Cursor lets the gateway replay the reconnect gap on connect instead of
|
|
239
314
|
// deferring it to the next poll; 0 on first connect means nothing to replay.
|
|
240
|
-
return this.
|
|
315
|
+
return this.cursorRef.version > 0 || this.cursorRef.id
|
|
316
|
+
? `${base}&since=${this.cursorRef.version}&cursor=${encodeURIComponent(encodeSyncCursor(this.cursorRef))}`
|
|
317
|
+
: base;
|
|
241
318
|
}
|
|
242
319
|
return this.sseUrl;
|
|
243
320
|
}
|
|
@@ -322,7 +399,7 @@ class SyncTransport {
|
|
|
322
399
|
}
|
|
323
400
|
/**
|
|
324
401
|
* Health-gate back to the app's own /poll + /events with the cursor intact
|
|
325
|
-
* (
|
|
402
|
+
* (cursorRef is untouched), so delivery stays poll-equivalent - never a
|
|
326
403
|
* silent stall.
|
|
327
404
|
*/
|
|
328
405
|
revertToLocal() {
|
|
@@ -418,6 +495,12 @@ class SyncTransport {
|
|
|
418
495
|
return isFinite(min) ? min : IDLE_POLL_INTERVAL_MS;
|
|
419
496
|
}
|
|
420
497
|
get isActive() {
|
|
498
|
+
const now = Date.now();
|
|
499
|
+
for (const [id, lastSeen] of this.activeChatIds) {
|
|
500
|
+
if (now - lastSeen > ACTIVE_CHAT_TTL_MS) {
|
|
501
|
+
this.activeChatIds.delete(id);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
421
504
|
return this.activeChatIds.size > 0;
|
|
422
505
|
}
|
|
423
506
|
get effectiveFallbackInterval() {
|
|
@@ -431,9 +514,9 @@ class SyncTransport {
|
|
|
431
514
|
// -------------------------------------------------------------------------
|
|
432
515
|
// Event fan-out
|
|
433
516
|
// -------------------------------------------------------------------------
|
|
434
|
-
fan(events, version) {
|
|
517
|
+
fan(events, version, cursor = this.cursorRef) {
|
|
435
518
|
for (const sub of this.subscribers.values()) {
|
|
436
|
-
sub.onEvents(events, version);
|
|
519
|
+
sub.onEvents(events, version, cursor);
|
|
437
520
|
}
|
|
438
521
|
}
|
|
439
522
|
setSseConnected(connected) {
|
|
@@ -536,7 +619,7 @@ class SyncTransport {
|
|
|
536
619
|
// already origin-scoped, and the dev gateway serves every workspace app
|
|
537
620
|
// from one origin — so an origin key would elect a single leader across
|
|
538
621
|
// different apps. A follower applies whatever the leader sends into its own
|
|
539
|
-
// `
|
|
622
|
+
// `cursorRef` (see openChannel), and that cursor is the `?since=` for its
|
|
540
623
|
// own poll, so it would skip its own app's events from then on. The poll
|
|
541
624
|
// URL is app-scoped, which is exactly the granularity a stream serves.
|
|
542
625
|
return `${SSE_LEADER_LOCK_PREFIX}${this.pollUrl}`;
|
|
@@ -612,8 +695,9 @@ class SyncTransport {
|
|
|
612
695
|
if (this.leaderState === "leader")
|
|
613
696
|
return;
|
|
614
697
|
if (frame?.type === "events") {
|
|
615
|
-
this.applyVersion(frame.events, frame.version);
|
|
616
|
-
this.
|
|
698
|
+
this.applyVersion(frame.events, frame.cursor ? undefined : frame.version);
|
|
699
|
+
this.cursorRef = maxSyncCursor(this.cursorRef, frame.cursor);
|
|
700
|
+
this.fan(frame.events, frame.version, this.cursorRef);
|
|
617
701
|
}
|
|
618
702
|
else if (frame?.type === "sse-state") {
|
|
619
703
|
this.capabilities = frame.capabilities;
|
|
@@ -719,7 +803,7 @@ class SyncTransport {
|
|
|
719
803
|
if (this.mode === "hosted" && this.gateway) {
|
|
720
804
|
// Browser auto-reconnect reuses the URL frozen at construction, so it
|
|
721
805
|
// would replay from a stale `since`. Own the reconnect so the next
|
|
722
|
-
// connect rebuilds activeSseUrl from the current
|
|
806
|
+
// connect rebuilds activeSseUrl from the current cursorRef. CLOSED also
|
|
723
807
|
// refreshes the token (expired/rotated/deploy); CONNECTING keeps it. A
|
|
724
808
|
// successful reconnect resets the count in onopen; a hard-down gateway
|
|
725
809
|
// trips the threshold and health-gates to local.
|
|
@@ -746,8 +830,18 @@ class SyncTransport {
|
|
|
746
830
|
const events = normalizeEventPayload(payload);
|
|
747
831
|
const version = typeof payload?.version === "number" ? payload.version : undefined;
|
|
748
832
|
this.applyVersion(events, version);
|
|
749
|
-
this.fan(events, version);
|
|
750
|
-
|
|
833
|
+
this.fan(events, version, this.cursorRef);
|
|
834
|
+
const eventCursor = cursorForEvents(events);
|
|
835
|
+
this.broadcast({
|
|
836
|
+
type: "events",
|
|
837
|
+
events,
|
|
838
|
+
version,
|
|
839
|
+
// A follower may only adopt a cursor for events it received. The
|
|
840
|
+
// transport cursor can be ahead after a poll response skipped
|
|
841
|
+
// filtered/unread durable rows, which would make the follower lose
|
|
842
|
+
// those rows on its own safety poll.
|
|
843
|
+
cursor: eventCursor,
|
|
844
|
+
});
|
|
751
845
|
}
|
|
752
846
|
catch {
|
|
753
847
|
// Ignore malformed SSE frames; polling is the safety net.
|
|
@@ -789,17 +883,15 @@ class SyncTransport {
|
|
|
789
883
|
* Advance the transport's shared version cursor. Subscribers receive the
|
|
790
884
|
* raw events and decide independently which ones are "fresh" relative to
|
|
791
885
|
* their own cursor, but the transport-level cursor ensures the poll
|
|
792
|
-
* `?since=`
|
|
886
|
+
* `?since=&cursor=` parameters always advance.
|
|
793
887
|
*/
|
|
794
888
|
applyVersion(events, version) {
|
|
795
|
-
|
|
889
|
+
if (typeof version === "number" && version > this.cursorRef.version) {
|
|
890
|
+
this.cursorRef = { version, id: "" };
|
|
891
|
+
}
|
|
796
892
|
for (const evt of events) {
|
|
797
|
-
|
|
798
|
-
if (v > max)
|
|
799
|
-
max = v;
|
|
893
|
+
this.cursorRef = maxSyncCursor(this.cursorRef, syncEventCursor(evt));
|
|
800
894
|
}
|
|
801
|
-
if (max > this.versionRef)
|
|
802
|
-
this.versionRef = max;
|
|
803
895
|
}
|
|
804
896
|
async poll() {
|
|
805
897
|
if (this.stopped || this.inFlight)
|
|
@@ -813,13 +905,19 @@ class SyncTransport {
|
|
|
813
905
|
if (!ok || this.stopped)
|
|
814
906
|
return;
|
|
815
907
|
}
|
|
816
|
-
const data = await fetchPollJson(this.activePollUrl, this.
|
|
908
|
+
const data = await fetchPollJson(this.activePollUrl, this.cursorRef, this.effectiveInterval, this.mode === "hosted" ? (this.token ?? undefined) : undefined);
|
|
817
909
|
if (this.stopped)
|
|
818
910
|
return;
|
|
819
911
|
this.consecutiveFailures = 0;
|
|
820
912
|
const events = data.events ?? [];
|
|
821
|
-
|
|
822
|
-
|
|
913
|
+
const responseCursor = decodeSyncCursor(data.cursor);
|
|
914
|
+
// A paged durable response's numeric version is the high-water mark,
|
|
915
|
+
// not necessarily the last row in this page. When a composite cursor is
|
|
916
|
+
// present, advance from event ids/that cursor only - applying the high
|
|
917
|
+
// water first would skip the remainder of a same-version page.
|
|
918
|
+
this.applyVersion(events, responseCursor ? undefined : data.version);
|
|
919
|
+
this.cursorRef = maxSyncCursor(this.cursorRef, responseCursor);
|
|
920
|
+
this.fan(events, data.version, this.cursorRef);
|
|
823
921
|
}
|
|
824
922
|
catch (err) {
|
|
825
923
|
if (this.stopped)
|
|
@@ -901,10 +999,20 @@ class SyncTransport {
|
|
|
901
999
|
? detail.tabId
|
|
902
1000
|
: "__default__";
|
|
903
1001
|
const wasActive = this.isActive;
|
|
904
|
-
if (running)
|
|
905
|
-
|
|
906
|
-
|
|
1002
|
+
if (running) {
|
|
1003
|
+
// Reinsert to refresh both the TTL and insertion order for the cap.
|
|
1004
|
+
this.activeChatIds.delete(id);
|
|
1005
|
+
this.activeChatIds.set(id, Date.now());
|
|
1006
|
+
while (this.activeChatIds.size > ACTIVE_CHAT_MAX) {
|
|
1007
|
+
const oldestId = this.activeChatIds.keys().next().value;
|
|
1008
|
+
if (typeof oldestId !== "string")
|
|
1009
|
+
break;
|
|
1010
|
+
this.activeChatIds.delete(oldestId);
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
else {
|
|
907
1014
|
this.activeChatIds.delete(id);
|
|
1015
|
+
}
|
|
908
1016
|
if (wasActive === this.isActive)
|
|
909
1017
|
return;
|
|
910
1018
|
if (this.isActive) {
|
|
@@ -932,6 +1040,7 @@ class SyncTransport {
|
|
|
932
1040
|
}
|
|
933
1041
|
teardown() {
|
|
934
1042
|
this.stopped = true;
|
|
1043
|
+
this.activeChatIds.clear();
|
|
935
1044
|
this.closeEvents();
|
|
936
1045
|
// Hand this app's stream slot to a waiting tab before dropping the
|
|
937
1046
|
// channel, so the next leader is elected without waiting on a poll.
|
|
@@ -1071,6 +1180,7 @@ export function useDbSync(options = {}) {
|
|
|
1071
1180
|
// Per-subscriber version cursor: tracks which events have already been
|
|
1072
1181
|
// processed by THIS subscriber so stale poll re-deliveries are ignored.
|
|
1073
1182
|
let subscriberVersion = 0;
|
|
1183
|
+
let subscriberCursor = { ...INITIAL_SYNC_CURSOR };
|
|
1074
1184
|
// Coalesce bursts of SSE-driven invalidation into at most one flush per
|
|
1075
1185
|
// INVALIDATE_COALESCE_MS. A chatty doc (many small agent edits, several
|
|
1076
1186
|
// peers editing at once) can otherwise deliver a handful of `action`/
|
|
@@ -1091,6 +1201,8 @@ export function useDbSync(options = {}) {
|
|
|
1091
1201
|
// coalesced behavior.
|
|
1092
1202
|
let pendingInvalidateEvents = [];
|
|
1093
1203
|
let invalidateTimer = null;
|
|
1204
|
+
let disposed = false;
|
|
1205
|
+
const pendingTrailingRefreshes = [];
|
|
1094
1206
|
function flushInvalidateBatch() {
|
|
1095
1207
|
if (invalidateTimer) {
|
|
1096
1208
|
clearTimeout(invalidateTimer);
|
|
@@ -1158,23 +1270,60 @@ export function useDbSync(options = {}) {
|
|
|
1158
1270
|
// in flight, let it finish instead of aborting and immediately
|
|
1159
1271
|
// launching the same request again. Repeated action events otherwise
|
|
1160
1272
|
// turn a slow endpoint into a cancel/restart loop that never settles.
|
|
1273
|
+
const hasPendingTrailingRefresh = (filters, predicateIdentity) => pendingTrailingRefreshes.some((pending) => {
|
|
1274
|
+
if (pending.filters?.dedupeKey !== filters?.dedupeKey)
|
|
1275
|
+
return false;
|
|
1276
|
+
if (filters?.dedupeKey)
|
|
1277
|
+
return true;
|
|
1278
|
+
const pendingKey = pending.filters?.queryKey;
|
|
1279
|
+
const filterKey = filters?.queryKey;
|
|
1280
|
+
if (pendingKey || filterKey) {
|
|
1281
|
+
if (!pendingKey ||
|
|
1282
|
+
!filterKey ||
|
|
1283
|
+
pendingKey.length !== filterKey.length) {
|
|
1284
|
+
return false;
|
|
1285
|
+
}
|
|
1286
|
+
return pendingKey.every((key, index) => key === filterKey[index]);
|
|
1287
|
+
}
|
|
1288
|
+
return pending.predicateIdentity === predicateIdentity;
|
|
1289
|
+
});
|
|
1161
1290
|
const invalidateWithoutCancel = (requested) => {
|
|
1162
1291
|
const callerPredicate = requested?.predicate;
|
|
1163
|
-
const
|
|
1292
|
+
const normalizedFilters = {
|
|
1164
1293
|
...requested,
|
|
1165
1294
|
predicate: (query) => !hasTerminalAuthFailure(query) &&
|
|
1166
1295
|
(callerPredicate?.(query) ?? true),
|
|
1167
1296
|
};
|
|
1168
|
-
const needsTrailingRefresh = (queryClient.isFetching?.(
|
|
1169
|
-
const completion = queryClient.invalidateQueries(
|
|
1297
|
+
const needsTrailingRefresh = (queryClient.isFetching?.(normalizedFilters) ?? 0) > 0;
|
|
1298
|
+
const completion = queryClient.invalidateQueries(normalizedFilters, {
|
|
1170
1299
|
cancelRefetch: false,
|
|
1171
1300
|
});
|
|
1172
1301
|
// TanStack Query deliberately leaves an in-flight fetch alone when
|
|
1173
1302
|
// cancelRefetch is false. Queue one post-settlement invalidation so
|
|
1174
1303
|
// a write that landed after that read began cannot be cleared as
|
|
1175
1304
|
// fresh by the older response.
|
|
1176
|
-
if (
|
|
1177
|
-
|
|
1305
|
+
if (!disposed &&
|
|
1306
|
+
needsTrailingRefresh &&
|
|
1307
|
+
completion instanceof Promise &&
|
|
1308
|
+
!hasPendingTrailingRefresh(normalizedFilters, callerPredicate)) {
|
|
1309
|
+
const pending = {
|
|
1310
|
+
filters: normalizedFilters,
|
|
1311
|
+
completion,
|
|
1312
|
+
predicateIdentity: callerPredicate,
|
|
1313
|
+
};
|
|
1314
|
+
pendingTrailingRefreshes.push(pending);
|
|
1315
|
+
void completion.then(() => {
|
|
1316
|
+
const index = pendingTrailingRefreshes.indexOf(pending);
|
|
1317
|
+
if (index < 0)
|
|
1318
|
+
return;
|
|
1319
|
+
pendingTrailingRefreshes.splice(index, 1);
|
|
1320
|
+
if (!disposed)
|
|
1321
|
+
queryClient.invalidateQueries(normalizedFilters);
|
|
1322
|
+
}, () => {
|
|
1323
|
+
const index = pendingTrailingRefreshes.indexOf(pending);
|
|
1324
|
+
if (index >= 0)
|
|
1325
|
+
pendingTrailingRefreshes.splice(index, 1);
|
|
1326
|
+
});
|
|
1178
1327
|
}
|
|
1179
1328
|
};
|
|
1180
1329
|
const hasActionEvent = invalidating.some((evt) => evt.source === "action" && !isSuppressedActionEvent(evt));
|
|
@@ -1240,8 +1389,26 @@ export function useDbSync(options = {}) {
|
|
|
1240
1389
|
invalidateWithoutCancel({ queryKey: ["tools"] });
|
|
1241
1390
|
}
|
|
1242
1391
|
}
|
|
1243
|
-
|
|
1244
|
-
|
|
1392
|
+
const appStateKeys = invalidating
|
|
1393
|
+
.filter((evt) => evt.source === "app-state")
|
|
1394
|
+
.map((evt) => evt.key)
|
|
1395
|
+
.map((key) => (typeof key === "string" && key ? key : "*"));
|
|
1396
|
+
if (appStateKeys.length > 0) {
|
|
1397
|
+
const appStateDedupeKey = Array.from(new Set(appStateKeys))
|
|
1398
|
+
.sort()
|
|
1399
|
+
.map((key) => `${key.length}:${key}`)
|
|
1400
|
+
.join("|");
|
|
1401
|
+
const needsBroadAppStateRefresh = appStateKeys.some((key) => key === "*" ||
|
|
1402
|
+
isInteractionCriticalSyncEvent({
|
|
1403
|
+
source: "app-state",
|
|
1404
|
+
key,
|
|
1405
|
+
}));
|
|
1406
|
+
invalidateWithoutCancel(needsBroadAppStateRefresh
|
|
1407
|
+
? { queryKey: ["app-state"] }
|
|
1408
|
+
: {
|
|
1409
|
+
dedupeKey: `app-state:${appStateDedupeKey}`,
|
|
1410
|
+
predicate: (query) => appStateQueryMatchesKeys(query, appStateKeys),
|
|
1411
|
+
});
|
|
1245
1412
|
}
|
|
1246
1413
|
if (hasAppStateEvent(invalidating, "navigate")) {
|
|
1247
1414
|
invalidateWithoutCancel({ queryKey: ["navigate-command"] });
|
|
@@ -1261,16 +1428,20 @@ export function useDbSync(options = {}) {
|
|
|
1261
1428
|
onEventRef.current?.(evt);
|
|
1262
1429
|
}
|
|
1263
1430
|
}
|
|
1264
|
-
function onEvents(events, version) {
|
|
1431
|
+
function onEvents(events, version, cursor) {
|
|
1265
1432
|
const freshEvents = events.filter((event) => {
|
|
1266
|
-
|
|
1267
|
-
return v === 0 || v > subscriberVersion;
|
|
1433
|
+
return isSyncEventAfterCursor(event, subscriberCursor, subscriberVersion);
|
|
1268
1434
|
});
|
|
1269
1435
|
if (freshEvents.length > 0) {
|
|
1270
1436
|
queueInvalidateBatch(freshEvents);
|
|
1271
1437
|
}
|
|
1272
1438
|
const maxEventVersion = freshEvents.reduce((max, event) => Math.max(max, typeof event.version === "number" ? event.version : 0), 0);
|
|
1273
1439
|
subscriberVersion = Math.max(subscriberVersion, version ?? 0, maxEventVersion);
|
|
1440
|
+
for (const event of freshEvents) {
|
|
1441
|
+
subscriberCursor = maxSyncCursor(subscriberCursor, syncEventCursor(event));
|
|
1442
|
+
}
|
|
1443
|
+
if (cursor)
|
|
1444
|
+
subscriberCursor = maxSyncCursor(subscriberCursor, cursor);
|
|
1274
1445
|
}
|
|
1275
1446
|
const transport = getOrCreateTransport(pollUrl, sseUrl, resolveGatewayBinding(sseUrl));
|
|
1276
1447
|
transport.add(id, {
|
|
@@ -1281,12 +1452,14 @@ export function useDbSync(options = {}) {
|
|
|
1281
1452
|
fallbackInterval,
|
|
1282
1453
|
});
|
|
1283
1454
|
return () => {
|
|
1455
|
+
disposed = true;
|
|
1284
1456
|
if (invalidateTimer) {
|
|
1285
1457
|
clearTimeout(invalidateTimer);
|
|
1286
1458
|
// Flush synchronously on unmount so a pending batch isn't silently
|
|
1287
1459
|
// dropped (e.g. a route change right after an agent edit lands).
|
|
1288
1460
|
flushInvalidateBatch();
|
|
1289
1461
|
}
|
|
1462
|
+
pendingTrailingRefreshes.length = 0;
|
|
1290
1463
|
transport.remove(id);
|
|
1291
1464
|
// If the registry still holds this transport, and the transport is now
|
|
1292
1465
|
// empty, evict it so the next mount gets a fresh instance rather than a
|
|
@@ -1335,16 +1508,21 @@ export function useScreenRefreshKey(options = {}) {
|
|
|
1335
1508
|
const id = Symbol("useScreenRefreshKey");
|
|
1336
1509
|
// Per-subscriber version cursor (same freshness logic as useDbSync).
|
|
1337
1510
|
let subscriberVersion = 0;
|
|
1338
|
-
|
|
1511
|
+
let subscriberCursor = { ...INITIAL_SYNC_CURSOR };
|
|
1512
|
+
function onEvents(events, version, cursor) {
|
|
1339
1513
|
const freshEvents = events.filter((event) => {
|
|
1340
|
-
|
|
1341
|
-
return v === 0 || v > subscriberVersion;
|
|
1514
|
+
return isSyncEventAfterCursor(event, subscriberCursor, subscriberVersion);
|
|
1342
1515
|
});
|
|
1343
1516
|
if (freshEvents.some((e) => e.source === "screen-refresh")) {
|
|
1344
1517
|
setKey((k) => k + 1);
|
|
1345
1518
|
}
|
|
1346
1519
|
const maxEventVersion = freshEvents.reduce((max, event) => Math.max(max, typeof event.version === "number" ? event.version : 0), 0);
|
|
1347
1520
|
subscriberVersion = Math.max(subscriberVersion, version ?? 0, maxEventVersion);
|
|
1521
|
+
for (const event of freshEvents) {
|
|
1522
|
+
subscriberCursor = maxSyncCursor(subscriberCursor, syncEventCursor(event));
|
|
1523
|
+
}
|
|
1524
|
+
if (cursor)
|
|
1525
|
+
subscriberCursor = maxSyncCursor(subscriberCursor, cursor);
|
|
1348
1526
|
}
|
|
1349
1527
|
const transport = getOrCreateTransport(pollUrl, sseUrl, resolveGatewayBinding(sseUrl));
|
|
1350
1528
|
transport.add(id, {
|
package/dist/collab/awareness.js
CHANGED
|
@@ -16,6 +16,8 @@ import { readBody } from "../server/h3-helpers.js";
|
|
|
16
16
|
import { deleteAwarenessRow, loadAwarenessRows, upsertAwarenessRow, } from "./awareness-store.js";
|
|
17
17
|
const AWARENESS_TIMEOUT = 30_000; // 30 seconds
|
|
18
18
|
const CLEAR_TOMBSTONE_TTL = AWARENESS_TIMEOUT + 5_000;
|
|
19
|
+
const AWARENESS_SCOPE_TIMEOUT = AWARENESS_TIMEOUT + 5_000;
|
|
20
|
+
const MAX_AWARENESS_SCOPES = 10_000;
|
|
19
21
|
// ---------------------------------------------------------------------------
|
|
20
22
|
// Awareness event emitter — fast-path for push delivery to SSE-connected peers.
|
|
21
23
|
// The SSE handler (poll-events) subscribes and forwards events to its stream.
|
|
@@ -24,21 +26,52 @@ export const AWARENESS_CHANGE_EVENT = "awareness-change";
|
|
|
24
26
|
const _awarenessEmitter = new EventEmitter();
|
|
25
27
|
_awarenessEmitter.setMaxListeners(0);
|
|
26
28
|
const _awarenessScopes = new Map();
|
|
29
|
+
function pruneAwarenessScopes(now) {
|
|
30
|
+
for (const [docId, entry] of _awarenessScopes) {
|
|
31
|
+
if (now - entry.lastSeen > AWARENESS_SCOPE_TIMEOUT) {
|
|
32
|
+
_awarenessScopes.delete(docId);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
while (_awarenessScopes.size > MAX_AWARENESS_SCOPES) {
|
|
36
|
+
const oldest = _awarenessScopes.keys().next().value;
|
|
37
|
+
if (!oldest)
|
|
38
|
+
break;
|
|
39
|
+
_awarenessScopes.delete(oldest);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
27
42
|
export function getAwarenessEmitter() {
|
|
28
43
|
return _awarenessEmitter;
|
|
29
44
|
}
|
|
30
45
|
export function rememberAwarenessScope(docId, scope) {
|
|
31
|
-
|
|
46
|
+
const now = Date.now();
|
|
47
|
+
pruneAwarenessScopes(now);
|
|
48
|
+
const existing = _awarenessScopes.get(docId);
|
|
49
|
+
if (!scope) {
|
|
50
|
+
// Agent-presence heartbeats emit scope-less awareness changes. They still
|
|
51
|
+
// prove the document is active and must keep the access scope alive.
|
|
52
|
+
if (existing) {
|
|
53
|
+
_awarenessScopes.delete(docId);
|
|
54
|
+
_awarenessScopes.set(docId, { ...existing, lastSeen: now });
|
|
55
|
+
}
|
|
32
56
|
return;
|
|
33
|
-
|
|
57
|
+
}
|
|
58
|
+
const next = {
|
|
59
|
+
...(existing?.scope ?? {}),
|
|
60
|
+
...scope,
|
|
61
|
+
};
|
|
34
62
|
if (!next.owner && !next.orgId && !next.resourceType && !next.resourceId) {
|
|
35
63
|
return;
|
|
36
64
|
}
|
|
37
|
-
_awarenessScopes.
|
|
65
|
+
_awarenessScopes.delete(docId);
|
|
66
|
+
_awarenessScopes.set(docId, { scope: next, lastSeen: now });
|
|
67
|
+
// Enforce the cap after insertion too. Pruning only before insertion lets
|
|
68
|
+
// the map temporarily exceed its advertised bound on every new document.
|
|
69
|
+
pruneAwarenessScopes(now);
|
|
38
70
|
}
|
|
39
71
|
export function emitAwarenessChange(docId, states, scope) {
|
|
40
72
|
rememberAwarenessScope(docId, scope);
|
|
41
|
-
|
|
73
|
+
pruneAwarenessScopes(Date.now());
|
|
74
|
+
const resolvedScope = _awarenessScopes.get(docId)?.scope ?? {};
|
|
42
75
|
const event = {
|
|
43
76
|
source: "awareness",
|
|
44
77
|
type: "awareness-change",
|
package/dist/collab/client.js
CHANGED
|
@@ -131,10 +131,21 @@ function calcBackoff(consecutiveErrors) {
|
|
|
131
131
|
// setLocalStateField calls within a 150ms window are coalesced into one POST.
|
|
132
132
|
// ---------------------------------------------------------------------------
|
|
133
133
|
const _awarenessThrottleTimers = new Map();
|
|
134
|
+
function awarenessThrottleKey(baseUrl, docId, clientId) {
|
|
135
|
+
return `${baseUrl}::${docId}::${clientId}`;
|
|
136
|
+
}
|
|
137
|
+
function cancelAwarenessPush(baseUrl, docId, clientId) {
|
|
138
|
+
const key = awarenessThrottleKey(baseUrl, docId, clientId);
|
|
139
|
+
const timer = _awarenessThrottleTimers.get(key);
|
|
140
|
+
if (timer === undefined)
|
|
141
|
+
return;
|
|
142
|
+
clearTimeout(timer);
|
|
143
|
+
_awarenessThrottleTimers.delete(key);
|
|
144
|
+
}
|
|
134
145
|
function scheduleAwarenessPush(baseUrl, docId, clientId, getState) {
|
|
135
146
|
if (typeof window === "undefined")
|
|
136
147
|
return;
|
|
137
|
-
const key =
|
|
148
|
+
const key = awarenessThrottleKey(baseUrl, docId, clientId);
|
|
138
149
|
if (_awarenessThrottleTimers.has(key))
|
|
139
150
|
return; // already scheduled
|
|
140
151
|
const timer = setTimeout(() => {
|
|
@@ -264,6 +275,10 @@ class CollabDocConnection {
|
|
|
264
275
|
remove(id) {
|
|
265
276
|
this.subscribers.delete(id);
|
|
266
277
|
if (this.subscribers.size === 0) {
|
|
278
|
+
// A lingered connection may be remounted for StrictMode, but it has no
|
|
279
|
+
// live consumer while empty. Do not let a queued presence push escape
|
|
280
|
+
// after the last subscriber has gone away.
|
|
281
|
+
cancelAwarenessPush(this.baseUrl, this.docId, this.ydoc.clientID);
|
|
267
282
|
this.scheduleDispose();
|
|
268
283
|
}
|
|
269
284
|
else {
|
|
@@ -305,6 +320,7 @@ class CollabDocConnection {
|
|
|
305
320
|
// `setLocalState(null)` does not schedule a push (matches the previous
|
|
306
321
|
// per-hook effect cleanup ordering).
|
|
307
322
|
this.awareness.off("change", this.handleAwarenessChange);
|
|
323
|
+
cancelAwarenessPush(this.baseUrl, this.docId, this.ydoc.clientID);
|
|
308
324
|
this.awareness.destroy();
|
|
309
325
|
this.ydoc.destroy();
|
|
310
326
|
if (collabConnectionRegistry.get(this.registryKey) === this) {
|
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
* Body: { json: any, fieldName?: string, type?: "map"|"array", requestSource?: string }
|
|
14
14
|
*/
|
|
15
15
|
export declare const postCollabJson: import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<{
|
|
16
|
-
ok?: undefined;
|
|
17
16
|
error: string;
|
|
17
|
+
ok?: undefined;
|
|
18
18
|
} | {
|
|
19
19
|
error?: undefined;
|
|
20
20
|
ok: boolean;
|