bunnyquery 1.9.4 → 1.9.6
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/bunnyquery.js +37 -12
- package/dist/engine.cjs +38 -11
- package/dist/engine.cjs.map +1 -1
- package/dist/engine.d.mts +52 -1
- package/dist/engine.d.ts +52 -1
- package/dist/engine.mjs +37 -12
- package/dist/engine.mjs.map +1 -1
- package/package.json +1 -1
- package/src/engine/history.ts +111 -5
- package/src/engine/index.ts +5 -0
- package/src/engine/session.ts +24 -9
package/package.json
CHANGED
package/src/engine/history.ts
CHANGED
|
@@ -323,7 +323,34 @@ export type SplitHistoryResult = {
|
|
|
323
323
|
};
|
|
324
324
|
|
|
325
325
|
export async function getSplitChatHistory(
|
|
326
|
-
params: {
|
|
326
|
+
params: {
|
|
327
|
+
service: string;
|
|
328
|
+
owner: string;
|
|
329
|
+
platform: 'claude' | 'openai';
|
|
330
|
+
userId?: string;
|
|
331
|
+
/**
|
|
332
|
+
* Scope the SURFACE fetch to this chat's own queue instead of "everything
|
|
333
|
+
* that is not the bg queue".
|
|
334
|
+
*
|
|
335
|
+
* Set for an ANONYMOUS visitor, and only for one. The backend identifies an
|
|
336
|
+
* unauthenticated caller as `ip + "(" + user_agent + ")"`, and the default
|
|
337
|
+
* surface fetch (queue_exclude, no queue) is scoped by exactly that string
|
|
338
|
+
* server side - so two anonymous visitors behind one NAT on the same browser
|
|
339
|
+
* build read each other's transcript, which is the thing per-device history
|
|
340
|
+
* exists to prevent. Reading the device's own queue instead scopes it by a
|
|
341
|
+
* value the client controls and the other device does not share.
|
|
342
|
+
*
|
|
343
|
+
* NOT used for a signed-in caller. Their turns are already scoped by their
|
|
344
|
+
* `sub`, and queue_exact would additionally hide any history sent under a
|
|
345
|
+
* different queue name than the current userId (an older fallback, a
|
|
346
|
+
* pre-rename row), which queue_exclude still returns.
|
|
347
|
+
*
|
|
348
|
+
* The queue name is unguessable but NOT secret: it travels on every request
|
|
349
|
+
* and queue listings are not user-scoped server side. Anonymous transcripts
|
|
350
|
+
* are non-confidential by construction.
|
|
351
|
+
*/
|
|
352
|
+
scopeSurfaceToQueue?: boolean;
|
|
353
|
+
},
|
|
327
354
|
fetchOptions: Record<string, any>,
|
|
328
355
|
/** Test seam: replaces getChatHistory. Not for production callers. */
|
|
329
356
|
_fetchImpl?: typeof getChatHistory,
|
|
@@ -345,7 +372,34 @@ export async function getSplitChatHistory(
|
|
|
345
372
|
|
|
346
373
|
async function _getSplitChatHistoryLocked(
|
|
347
374
|
key: string,
|
|
348
|
-
params: {
|
|
375
|
+
params: {
|
|
376
|
+
service: string;
|
|
377
|
+
owner: string;
|
|
378
|
+
platform: 'claude' | 'openai';
|
|
379
|
+
userId?: string;
|
|
380
|
+
/**
|
|
381
|
+
* Scope the SURFACE fetch to this chat's own queue instead of "everything
|
|
382
|
+
* that is not the bg queue".
|
|
383
|
+
*
|
|
384
|
+
* Set for an ANONYMOUS visitor, and only for one. The backend identifies an
|
|
385
|
+
* unauthenticated caller as `ip + "(" + user_agent + ")"`, and the default
|
|
386
|
+
* surface fetch (queue_exclude, no queue) is scoped by exactly that string
|
|
387
|
+
* server side - so two anonymous visitors behind one NAT on the same browser
|
|
388
|
+
* build read each other's transcript, which is the thing per-device history
|
|
389
|
+
* exists to prevent. Reading the device's own queue instead scopes it by a
|
|
390
|
+
* value the client controls and the other device does not share.
|
|
391
|
+
*
|
|
392
|
+
* NOT used for a signed-in caller. Their turns are already scoped by their
|
|
393
|
+
* `sub`, and queue_exact would additionally hide any history sent under a
|
|
394
|
+
* different queue name than the current userId (an older fallback, a
|
|
395
|
+
* pre-rename row), which queue_exclude still returns.
|
|
396
|
+
*
|
|
397
|
+
* The queue name is unguessable but NOT secret: it travels on every request
|
|
398
|
+
* and queue listings are not user-scoped server side. Anonymous transcripts
|
|
399
|
+
* are non-confidential by construction.
|
|
400
|
+
*/
|
|
401
|
+
scopeSurfaceToQueue?: boolean;
|
|
402
|
+
},
|
|
349
403
|
fetchOptions: Record<string, any>,
|
|
350
404
|
releaseLock: () => void,
|
|
351
405
|
_fetchImpl?: typeof getChatHistory,
|
|
@@ -353,6 +407,14 @@ async function _getSplitChatHistoryLocked(
|
|
|
353
407
|
const fetch = _fetchImpl || getChatHistory;
|
|
354
408
|
const bgQueue = bgIndexingQueueName(params.userId, params.service);
|
|
355
409
|
const base = { service: params.service, owner: params.owner, platform: params.platform };
|
|
410
|
+
// What the SURFACE (non-background) fetch filters on. `queue_exclude` is the
|
|
411
|
+
// default and returns every row that is not on the bg chain; the anonymous
|
|
412
|
+
// path narrows to this chat's OWN queue instead. Same queue name the chat
|
|
413
|
+
// turns are dispatched under (requests.ts `queue: userId || service`), so the
|
|
414
|
+
// two cannot drift.
|
|
415
|
+
const surfaceScope: Record<string, any> = params.scopeSurfaceToQueue && params.userId
|
|
416
|
+
? { queue: params.userId, queue_exact: true }
|
|
417
|
+
: { queue_exclude: bgQueue };
|
|
356
418
|
const fetchMore = !!(fetchOptions && fetchOptions.fetchMore);
|
|
357
419
|
const limit = fetchOptions && fetchOptions.limit;
|
|
358
420
|
|
|
@@ -404,14 +466,14 @@ async function _getSplitChatHistoryLocked(
|
|
|
404
466
|
} else {
|
|
405
467
|
const sOpts: any = { fetchMore };
|
|
406
468
|
if (limit) sOpts.limit = limit;
|
|
407
|
-
let s = await fetch({ ...base,
|
|
469
|
+
let s = await fetch({ ...base, ...surfaceScope }, sOpts);
|
|
408
470
|
// Loop past empty-but-not-end pages (see SURFACE_EMPTY_MAX_PAGES).
|
|
409
471
|
let hops = 0;
|
|
410
472
|
while (s && !s.endOfList && !((s.list || []).length) && hops < SURFACE_EMPTY_MAX_PAGES) {
|
|
411
473
|
hops++;
|
|
412
474
|
const nOpts: any = { fetchMore: true };
|
|
413
475
|
if (limit) nOpts.limit = limit;
|
|
414
|
-
s = await fetch({ ...base,
|
|
476
|
+
s = await fetch({ ...base, ...surfaceScope }, nOpts);
|
|
415
477
|
}
|
|
416
478
|
state.pendingSurface = {
|
|
417
479
|
list: (s && Array.isArray(s.list)) ? s.list : [],
|
|
@@ -588,9 +650,53 @@ async function _getSplitChatHistoryLocked(
|
|
|
588
650
|
}
|
|
589
651
|
|
|
590
652
|
|
|
653
|
+
/**
|
|
654
|
+
* THE chat key. Every cache, every ownership stamp and every "is this turn for
|
|
655
|
+
* the chat on screen?" comparison is built here and nowhere else.
|
|
656
|
+
*
|
|
657
|
+
* It used to be written out by hand in four places. When a third segment was
|
|
658
|
+
* added for the chat identity - a browser can hold an anonymous conversation and
|
|
659
|
+
* a signed-in one on the SAME project, and the two must not share a cache - only
|
|
660
|
+
* one of those four was updated. The rest kept producing the two-segment form,
|
|
661
|
+
* so `key !== getHistoryCacheKey()` became permanently true: every send was
|
|
662
|
+
* treated as belonging to another project, the optimistic bubble and the
|
|
663
|
+
* "Thinking..." placeholder were never pushed, and nothing appeared until the
|
|
664
|
+
* server history caught up. One function, so a twin cannot drift again.
|
|
665
|
+
*/
|
|
666
|
+
export function chatCacheKey(
|
|
667
|
+
projectId: string | undefined,
|
|
668
|
+
platform: string | undefined,
|
|
669
|
+
userId?: string,
|
|
670
|
+
): string {
|
|
671
|
+
if (!projectId || platform === 'none') return '';
|
|
672
|
+
return projectId + '#' + platform + '#' + (userId || '');
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/**
|
|
676
|
+
* The INDEXING scope key: project + platform, deliberately WITHOUT the identity.
|
|
677
|
+
*
|
|
678
|
+
* Claiming, stopping and cancelling a file's indexing are scoped per project and
|
|
679
|
+
* platform because a storage path is project-relative and one ChatSession serves
|
|
680
|
+
* every project. They are NOT per user: an anonymous visitor cannot upload or
|
|
681
|
+
* index at all, so there is no second identity to separate, and folding the
|
|
682
|
+
* identity in here would only have to be threaded through BgTaskEntry to no end.
|
|
683
|
+
*
|
|
684
|
+
* Kept separate from chatCacheKey ON PURPOSE. These two were the same string
|
|
685
|
+
* once, which is exactly how adding a segment to one silently broke the other.
|
|
686
|
+
*/
|
|
687
|
+
export function indexScopeKey(
|
|
688
|
+
projectId: string | undefined,
|
|
689
|
+
platform: string | undefined,
|
|
690
|
+
): string {
|
|
691
|
+
if (!projectId || platform === 'none') return '';
|
|
692
|
+
return projectId + '#' + platform;
|
|
693
|
+
}
|
|
694
|
+
|
|
591
695
|
export type MapHistoryOptions = {
|
|
592
696
|
clearedAt: number;
|
|
593
697
|
projectId: string;
|
|
698
|
+
/** Chat identity, so the `_ownerKey` stamp matches chatCacheKey(). */
|
|
699
|
+
userId?: string;
|
|
594
700
|
/** View-side display formatter for "Indexing:/Reindexing: …" bubbles. */
|
|
595
701
|
formatIndexingLabel: (name: string, mime?: string, size?: number | null, storagePath?: string, reindex?: boolean, continued?: boolean) => string;
|
|
596
702
|
};
|
|
@@ -728,7 +834,7 @@ export function mapHistoryListToMessages(list: any[], platform: 'claude' | 'open
|
|
|
728
834
|
// unchallenged. That is what let one project's transcript survive on screen
|
|
729
835
|
// into another project and be persisted under its key.
|
|
730
836
|
if (opts.projectId) {
|
|
731
|
-
var ownerKey = opts.projectId
|
|
837
|
+
var ownerKey = chatCacheKey(opts.projectId, platform, opts.userId);
|
|
732
838
|
for (var oi = 0; oi < mapped.length; oi++) mapped[oi]._ownerKey = ownerKey;
|
|
733
839
|
}
|
|
734
840
|
return { messages: mapped, runningItemIds: runningItemIds };
|
package/src/engine/index.ts
CHANGED
|
@@ -81,6 +81,11 @@ export {
|
|
|
81
81
|
__resetSplitHistoryState,
|
|
82
82
|
type IndexingRequestRef,
|
|
83
83
|
type MapHistoryOptions,
|
|
84
|
+
// THE two key builders. Exported so a consumer (and a test) can assert the
|
|
85
|
+
// shape rather than rebuild it: a hand-built twin drifting out of step with
|
|
86
|
+
// getHistoryCacheKey is what stopped the chat rendering sent messages.
|
|
87
|
+
chatCacheKey,
|
|
88
|
+
indexScopeKey,
|
|
84
89
|
} from './history';
|
|
85
90
|
|
|
86
91
|
// Older history is reachable only by scrolling to the top of the message box, so
|
package/src/engine/session.ts
CHANGED
|
@@ -46,7 +46,7 @@ import { isErrorResponseBody, isAuthExpiredError, isNonRetryableRequestError, ge
|
|
|
46
46
|
import { buildBoundedChatMessages } from './budget';
|
|
47
47
|
import { createInlineLinkRegex, sanitizeAttachmentLinksForHistory } from './links';
|
|
48
48
|
import { markImagePreviewStale } from './image_preview';
|
|
49
|
-
import { mapHistoryListToMessages, extractLastUserTextFromRequest, isIndexingRequestText, parseIndexingRequestText, probeBgQueue, BG_PROBE_TTL_MS, getSplitChatHistory, shouldRescueInFlightMessage } from './history';
|
|
49
|
+
import { chatCacheKey, indexScopeKey, mapHistoryListToMessages, extractLastUserTextFromRequest, isIndexingRequestText, parseIndexingRequestText, probeBgQueue, BG_PROBE_TTL_MS, getSplitChatHistory, shouldRescueInFlightMessage } from './history';
|
|
50
50
|
import { wallClockNow } from './time';
|
|
51
51
|
import { parseAttachmentContent } from './attachment_parsers';
|
|
52
52
|
import type { ChatHost, ChatState, ChatMessage, ChatIdentity, PinnedDispatchContext } from './host';
|
|
@@ -325,7 +325,8 @@ export class ChatSession {
|
|
|
325
325
|
/** Storage paths are project-relative, and one ChatSession serves every
|
|
326
326
|
* project, so a claim has to be scoped the way a stop is (_indexKeyOf). */
|
|
327
327
|
private _indexClaimKey(storagePath: string): string {
|
|
328
|
-
|
|
328
|
+
var id = this.host.getIdentity();
|
|
329
|
+
return indexScopeKey(id.projectId, id.platform) + '|' + storagePath;
|
|
329
330
|
}
|
|
330
331
|
|
|
331
332
|
/**
|
|
@@ -813,8 +814,7 @@ export class ChatSession {
|
|
|
813
814
|
*/
|
|
814
815
|
getHistoryCacheKey(): string {
|
|
815
816
|
var id = this.host.getIdentity();
|
|
816
|
-
|
|
817
|
-
return id.projectId + '#' + id.platform + '#' + (id.userId || '');
|
|
817
|
+
return chatCacheKey(id.projectId, id.platform, id.userId);
|
|
818
818
|
}
|
|
819
819
|
|
|
820
820
|
// ─── compact-stub hydration ─────────────────────────────────────────────
|
|
@@ -1482,7 +1482,7 @@ export class ChatSession {
|
|
|
1482
1482
|
// getIdentity()/getHistoryCacheKey() to the new project) can't
|
|
1483
1483
|
// misattribute this turn's bubbles to that project.
|
|
1484
1484
|
// (platform === 'none' already returned above, so projectId is the only gate)
|
|
1485
|
-
var key =
|
|
1485
|
+
var key = chatCacheKey(id.projectId, id.platform, id.userId);
|
|
1486
1486
|
// True when the pinned chat is NOT the one currently on screen. Then
|
|
1487
1487
|
// state.messages belongs to a different project and MUST NOT be touched:
|
|
1488
1488
|
// the turn is staged in the pinned chat's cache instead and shows up when
|
|
@@ -2159,7 +2159,8 @@ export class ChatSession {
|
|
|
2159
2159
|
if (!group || !group.key) return;
|
|
2160
2160
|
// The group belongs to the chat on screen, so scope its key the same way
|
|
2161
2161
|
// _indexKeyOf scopes a queued task's.
|
|
2162
|
-
var
|
|
2162
|
+
var idn = this.host.getIdentity();
|
|
2163
|
+
var scoped = indexScopeKey(idn.projectId, idn.platform) + '|' + group.key;
|
|
2163
2164
|
this.cancelledIndexKeys.add(scoped);
|
|
2164
2165
|
// Remember WHICH RUN was stopped, by the ids of the passes it is made of.
|
|
2165
2166
|
// Everything else here stops the work without leaving any mark on the
|
|
@@ -2709,7 +2710,7 @@ export class ChatSession {
|
|
|
2709
2710
|
if (!entry) return '';
|
|
2710
2711
|
var file = entry.storagePath || entry.filename;
|
|
2711
2712
|
if (!file) return '';
|
|
2712
|
-
return entry.projectId
|
|
2713
|
+
return indexScopeKey(entry.projectId, entry.platform) + '|' + file;
|
|
2713
2714
|
}
|
|
2714
2715
|
|
|
2715
2716
|
/**
|
|
@@ -3435,7 +3436,7 @@ export class ChatSession {
|
|
|
3435
3436
|
// request is built from. The rescue below compares against this rather
|
|
3436
3437
|
// than a live getHistoryCacheKey(), so a project switch mid-fetch can't
|
|
3437
3438
|
// make another chat's in-flight bubbles look local.
|
|
3438
|
-
var loadKey = (
|
|
3439
|
+
var loadKey = chatCacheKey(id.projectId, id.platform, id.userId);
|
|
3439
3440
|
if (token === undefined) token = this.state.gateRefreshToken;
|
|
3440
3441
|
if ((this.state.loadingHistory && this.state.historyRequestToken === token) || id.platform === 'none' || !id.projectId) {
|
|
3441
3442
|
return Promise.resolve();
|
|
@@ -3464,7 +3465,16 @@ export class ChatSession {
|
|
|
3464
3465
|
// stubs, tiled to the surface page's time window with module-level
|
|
3465
3466
|
// state (buffering + retry safety) — see getSplitChatHistory. Returns
|
|
3466
3467
|
// the exact single-fetch shape; startKeyHistory is bookkeeping only.
|
|
3467
|
-
var fetchHistory = function () {
|
|
3468
|
+
var fetchHistory = function () {
|
|
3469
|
+
return getSplitChatHistory({
|
|
3470
|
+
service: projectId, owner: owner, platform: platform, userId: id.userId,
|
|
3471
|
+
// An anonymous visitor's history is scoped server side by
|
|
3472
|
+
// ip + "(" + user_agent + ")", which two devices behind one NAT
|
|
3473
|
+
// share. Read this device's own queue instead. See
|
|
3474
|
+
// scopeSurfaceToQueue.
|
|
3475
|
+
scopeSurfaceToQueue: !!id.anonymous,
|
|
3476
|
+
}, options);
|
|
3477
|
+
};
|
|
3468
3478
|
|
|
3469
3479
|
return Promise.resolve().then(fetchHistory).catch(function (err: any) {
|
|
3470
3480
|
if (isAuthExpiredError(err) && !isNonRetryableRequestError(err)) return self.host.refreshSession().then(fetchHistory);
|
|
@@ -3489,6 +3499,10 @@ export class ChatSession {
|
|
|
3489
3499
|
var mapped = mapHistoryListToMessages(list, platform, {
|
|
3490
3500
|
clearedAt: self.host.getClearedAt(),
|
|
3491
3501
|
projectId: id.projectId,
|
|
3502
|
+
// So the `_ownerKey` stamped on server history matches loadKey and
|
|
3503
|
+
// the cache key. Without it every mapped bubble carries a
|
|
3504
|
+
// two-segment stamp that no comparison can ever match.
|
|
3505
|
+
userId: id.userId,
|
|
3492
3506
|
formatIndexingLabel: self.host.formatIndexingLabel,
|
|
3493
3507
|
}).messages;
|
|
3494
3508
|
// Re-apply any previously-hydrated compact bodies: a refresh maps
|
|
@@ -3782,6 +3796,7 @@ export class ChatSession {
|
|
|
3782
3796
|
var m2 = mapHistoryListToMessages(sorted, platform, {
|
|
3783
3797
|
clearedAt: self.host.getClearedAt(),
|
|
3784
3798
|
projectId: id.projectId,
|
|
3799
|
+
userId: id.userId,
|
|
3785
3800
|
formatIndexingLabel: self.host.formatIndexingLabel,
|
|
3786
3801
|
}).messages;
|
|
3787
3802
|
self.applyHydratedBodies(m2);
|