bunnyquery 1.9.7 → 1.10.0

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.
@@ -130,6 +130,91 @@ export interface ChatMessage {
130
130
  * still dimmed. Cleared (with _dimSending) by markStagedMessageReady the moment
131
131
  * the queue drains, which is when the turn genuinely becomes "(In queue)". */
132
132
  isAwaitingIndexing?: boolean;
133
+ /** PROTOCOL + PRESENTATIONAL: this bubble is being painted from a LIVE stream.
134
+ *
135
+ * It sits alongside `isPending`, never instead of it. The bubble is still the
136
+ * turn's "Thinking..." placeholder as far as every queue mechanism is concerned
137
+ * (_ownThinkingIndex, resolveQueuedUserBubble, typewriteLatestReply and the
138
+ * stray-pending sweep all find their target by isPending), and clearing that flag
139
+ * to mean "it has text now" would strand the turn's real answer beside an orphan.
140
+ * What this adds is the one thing those mechanisms do not care about and the VIEW
141
+ * does: `content` is already worth rendering, so draw the text instead of the
142
+ * spinner.
143
+ *
144
+ * Cleared the moment the turn settles, BEFORE the authoritative answer replaces
145
+ * the live text: from that instant the bubble is an ordinary reply being typed.
146
+ * The partially painted `content` is deliberately left in place across that clear,
147
+ * because it is what the typewriter resumes from instead of replaying from zero.
148
+ *
149
+ * Also read by shouldRescueInFlightMessage: a streaming bubble that has no server
150
+ * id yet is unrepresentable in a freshly fetched page, so it must survive the
151
+ * merge or its stream is orphaned with nothing left to paint into. */
152
+ _streaming?: boolean;
153
+ /**
154
+ * This turn's row is TERMINAL but carries no stored answer, because the answer
155
+ * was streamed and nobody ever finalized it: the bytes are in the chunk store,
156
+ * not on the row. The bubble's `content` is therefore UNKNOWN, not empty.
157
+ *
158
+ * That distinction is the whole of the fix for two bugs that looked unrelated.
159
+ * A refetch landing in the window between the row going 'resolved' and finalize
160
+ * storing the body used to ERASE the answer off the screen (the server copy has
161
+ * no content, so the merge dropped the local bubble that did); and a turn that
162
+ * settled while no poll was attached (closed tab, slept device) used to be
163
+ * unrecoverable, because the mapper emitted no assistant bubble at all for a
164
+ * terminal-but-empty row. Both are the same question, "what should the merge
165
+ * believe when the server copy is authoritative but empty", and the answer is
166
+ * this flag: an unknown answer NEVER overwrites a known one, and an unknown one
167
+ * left over after the merge is resolved by reading the chunks back
168
+ * (ChatSession.recoverStreamedAnswer).
169
+ *
170
+ * For a view it is a rendering hint and nothing more: a bubble carrying it with
171
+ * empty content is being fetched, so draw whatever this client draws for a
172
+ * loading answer. A host that ignores it renders an empty bubble for the second
173
+ * or two the recovery takes, which is what it would have rendered anyway.
174
+ *
175
+ * WHEN IT COMES OFF, because that is the half that loses answers. It comes off
176
+ * for a FACT about the turn and never for an event in the client: an answer was
177
+ * recovered and written in, or the chunks were read and were genuinely empty (in
178
+ * which case the empty bubble is removed as well, restoring the list the mapper
179
+ * used to produce). It stays ON when the read FAILED, when the read was STOPPED,
180
+ * and when a live turn settled having painted nothing - three states that say
181
+ * nothing whatever about the turn, and in which the chunks are all still there.
182
+ * A marker cleared on one of those is an answer nothing will ever go back for,
183
+ * so a host may see the same bubble marked across several loads while the reads
184
+ * keep failing; that is the recoverable state, not a stuck one.
185
+ */
186
+ _streamPending?: boolean;
187
+ /**
188
+ * IS ANYTHING ACTUALLY DRIVING THIS BUBBLE RIGHT NOW? The second half of
189
+ * `_streamPending`, and the half a view cannot do without.
190
+ *
191
+ * `_streamPending` says the answer is elsewhere; it does NOT say somebody is on
192
+ * their way to fetch it, and the two are different states that used to render
193
+ * identically. Recovery is capped per history load (STREAM_RECOVERY_PER_LOAD),
194
+ * so the third and later marked turns on a page are marked and nobody is reading
195
+ * them; a read that FAILED leaves the marker on with the attempt forgotten, which
196
+ * is also nobody. Both drew the same loader as a live turn, so a bubble could
197
+ * spin for the rest of the session with nothing behind it - the one thing a
198
+ * spinner must never do, because it is a promise that something is coming.
199
+ *
200
+ * Three states, and only the first of them may draw a spinner:
201
+ * 'active' a chunk read is in flight or queued for this turn. Something IS
202
+ * coming; the loader is honest.
203
+ * 'failed' the last read failed. Nothing is coming until somebody asks
204
+ * again, so the view owes the reader a way to ask.
205
+ * undefined nothing has been tried, or the attempt is over. Same obligation.
206
+ *
207
+ * Written by the engine only, and never persisted anywhere: it describes THIS
208
+ * session's fetching, not the turn. A fresh history page therefore arrives
209
+ * without it, and _adoptLocalAnswers re-stamps the page's still-marked bubbles
210
+ * from the session's own bookkeeping, so a reload during a read does not turn a
211
+ * live loader into a button (and back a second later).
212
+ *
213
+ * Read it through streamRecoveryPhase(msg), never directly: the phase folds in
214
+ * "does this bubble need the affordance at all", and both clients must not
215
+ * answer that twice.
216
+ */
217
+ _streamRecovery?: 'active' | 'failed';
133
218
  _serverItemId?: string;
134
219
  _localId?: string;
135
220
  _cancelling?: boolean;
@@ -11,11 +11,31 @@ export {
11
11
  configureChatEngine,
12
12
  chatEngineConfig,
13
13
  type ChatEngineConfig,
14
+ // The live-stream observation hook's payload. Exported so a consumer can type
15
+ // its handler against the engine rather than restate the shape.
16
+ type LiveStreamUpdate,
17
+ // "Can a streamed turn whose answer never reached its row be read back?" - which
18
+ // asks for the chunk READER and deliberately not for liveStreaming, because a
19
+ // row that already streamed stays recoverable long after streaming is switched
20
+ // off. Exported because a client with its OWN history mapper (agent.vue's fork)
21
+ // has to gate the `_streamPending` mark on exactly this predicate: gate it on
22
+ // liveStreaming and every already-streamed row is stranded the day the flag goes
23
+ // back off; gate it on nothing and a host with no reader gets a permanently
24
+ // empty bubble where it used to get none, which is strictly worse than the bug.
25
+ streamRecoveryEnabled,
26
+ // Whether a given skapi INSTANCE can carry skapi's half of the stream flag. Both
27
+ // clients gate their liveStreaming opt-in on it and degrade to buffered when it
28
+ // says no: an SDK too old to know the key drops it silently, leaving the
29
+ // destination streaming SSE into a buffered row that reads back empty.
30
+ skapiSupportsStreaming,
14
31
  } from './config';
15
32
 
16
33
  export {
17
34
  isServerExtractable,
18
35
  isOfficeFile,
36
+ isPagedReadFile,
37
+ isImageVisionFile,
38
+ isWindowedReadFile,
19
39
  makeExtractPlaceholder,
20
40
  composeUserMessage,
21
41
  type ExtractDirective,
@@ -47,7 +67,15 @@ export { buildChatGreeting, type ChatGreetingParams, type ChatGreetingParts } fr
47
67
 
48
68
  // Pure helpers (Tier-1.5): error detection, token budgeting, link/path
49
69
  // normalization, and history mapping — shared so both consumers stay identical.
50
- export { getErrorMessage, isErrorResponseBody, isAuthExpiredError, isNonRetryableRequestError, isProviderApiKeyError } from './errors';
70
+ export {
71
+ getErrorMessage, isErrorResponseBody, isAuthExpiredError, isNonRetryableRequestError, isProviderApiKeyError,
72
+ // The csr-poll STATUS ENVELOPE, and the provider error nested one level inside a
73
+ // failed one. Exported because "is this an envelope or a body" is asked in two
74
+ // places that must agree (the streamed settle, and every error reader), and it
75
+ // was answered twice before, one of the two one level too shallow, which is how
76
+ // a wrong API key on a streamed turn reported "No text response received".
77
+ isCsrStatusEnvelope, csrEnvelopeError,
78
+ } from './errors';
51
79
  export * from './budget';
52
80
  // Per-format UTF-8 declaration for files offered as a download. Shared so a fenced
53
81
  // block and a server-published file open identically in Excel, Word and a browser.
@@ -57,6 +85,11 @@ export * from './link_markup';
57
85
  export * from './image_preview';
58
86
  export * from './time';
59
87
  export * from './ai_agent';
88
+ // The SSE parser. skapi relays the provider's bytes without reading them, so this is
89
+ // where BunnyQuery's knowledge of the Anthropic and OpenAI wire formats lives. It has
90
+ // to be on the barrel or the built engine bundle does not contain it and
91
+ // www.skapi.com's `bunnyquery/engine` import resolves to undefined at runtime.
92
+ export * from './sse';
60
93
  export {
61
94
  filterListByClearHorizon,
62
95
  normalizeTextContent,
@@ -73,6 +106,11 @@ export {
73
106
  // rendered twice and then persisted into the history cache.
74
107
  shouldRescueInFlightMessage,
75
108
  type RescueDecisionContext,
109
+ // What the merge does when the server's copy of a turn is authoritative but
110
+ // EMPTY (a streamed row nobody finalized): the local answer wins, because an
111
+ // unknown answer is not an empty one. Shared for the same reason the rescue
112
+ // rule is: a client mapper that forks it erases answers off the screen.
113
+ adoptLocalAnswerIntoPage,
76
114
  // One bounded look at the bg-indexing queue: which files still have a live
77
115
  // pass. The dbfile browser's "indexed" badge uses this so a file only goes
78
116
  // green once the run is confirmed over, not when its src:: record appears.
@@ -114,7 +152,31 @@ export {
114
152
 
115
153
  // Tier-2: the stateful chat orchestration (queue/poll/cancel, typewriter,
116
154
  // bg-task drain, resolution). DOM-free; the consumer implements ChatHost.
117
- export { ChatSession } from './session';
155
+ //
156
+ // liveSafePrefix / typewriterResumeIndex are the two pure halves of live
157
+ // rendering: what is safe to show while the answer is still arriving, and where
158
+ // the typewriter picks up once the authoritative answer replaces it. Exported so
159
+ // they can be tested (and read) without a DOM.
160
+ //
161
+ // mayKeepStreamedAnswer is the ONE keep policy for a streamed turn: may this parse
162
+ // be stored as the row's permanent answer, given the row's own status. It is on the
163
+ // barrel because finalize is also the only way to release chunks, so every path
164
+ // that can reach it has to answer this identically - the live settle and the
165
+ // read-back once answered it separately and disagreed, and the disagreement
166
+ // released the chunks of a failed turn.
167
+ //
168
+ // streamRecoveryPhase / streamRecoveryLabels are the RENDER half of the same
169
+ // policy: given a bubble whose answer is still in the chunk store, is anything
170
+ // actually fetching it, and if not, what does the reader get offered instead of a
171
+ // spinner that will never resolve. Both clients read the phase from here rather
172
+ // than from `_streamRecovery` directly - the alternative is each of them deciding
173
+ // on its own when a loader is honest, which is precisely the fork this barrel
174
+ // exists to prevent.
175
+ export {
176
+ ChatSession, liveSafePrefix, typewriterResumeIndex, mayKeepStreamedAnswer,
177
+ streamRecoveryPhase, streamRecoveryLabels,
178
+ type StreamDispatchContext,
179
+ } from './session';
118
180
  export type { ChatHost, ChatIdentity, ChatState, ChatMessage, IndexingFileRef, PinnedDispatchContext } from './host';
119
181
 
120
182
  // Display transform: collapse a file's many background-indexing turns into one
@@ -133,6 +195,7 @@ export {
133
195
  export {
134
196
  // constants
135
197
  POLL_INTERVAL,
198
+ STREAM_POLL_INTERVAL,
136
199
  MAX_CONCURRENT_BG_POLLS,
137
200
  getVisionProfile,
138
201
  type VisionProfile,
@@ -147,6 +210,11 @@ export {
147
210
  MCP_NAME,
148
211
  DEFAULT_CLAUDE_MODEL,
149
212
  DEFAULT_OPENAI_MODEL,
213
+ // The ONE producer of the two `stream` flags a streamed chat turn needs. Exported
214
+ // so a test can assert the pair moves together, which is the whole point of it
215
+ // being a single function.
216
+ chatStreamWiring,
217
+ type ChatStreamWiring,
150
218
  // request builders + dispatch
151
219
  callClaudeWithMcp,
152
220
  callClaudeWithPublicMcp,
@@ -179,3 +247,44 @@ export {
179
247
  type AttachmentSaveInfo,
180
248
  type BgTaskEntry,
181
249
  } from './requests';
250
+
251
+ // The project's BunnyQuery settings, stored as a public record in the customer's
252
+ // own project ("bq::settings" in "__SETTINGS__") rather than on the skapi service
253
+ // record. Both clients prime it when a chat page opens and await it before the
254
+ // first upload writes an access group onto a record. See project_settings.ts.
255
+ export {
256
+ // where the record lives
257
+ PROJECT_SETTINGS_TABLE,
258
+ PROJECT_SETTINGS_UNIQUE_ID,
259
+ PROJECT_SETTINGS_ACCESS_GROUP,
260
+ // the choice, its labels and its default
261
+ UPLOAD_ACCESS_GROUPS,
262
+ UPLOAD_ACCESS_LABELS,
263
+ UPLOAD_ACCESS_HINTS,
264
+ UPLOAD_ACCESS_OPTIONS,
265
+ DEFAULT_UPLOAD_ACCESS_GROUP,
266
+ // pure readers over a settings `data` object
267
+ normalizeUploadAccessGroup,
268
+ normalizeProjectAccessSetting,
269
+ accessSettingFrom,
270
+ uploadAccessGroupFrom,
271
+ asksUploadAccessFrom,
272
+ // the per-project store
273
+ configureProjectSettings,
274
+ loadProjectSettings,
275
+ primeProjectSettings,
276
+ readyProjectSettings,
277
+ cachedProjectSettings,
278
+ projectSettingsSettled,
279
+ projectAccessSetting,
280
+ projectUploadAccessGroup,
281
+ projectAsksUploadAccess,
282
+ setProjectSettings,
283
+ patchProjectSettings,
284
+ clearProjectSettings,
285
+ // types
286
+ type UploadAccessGroup,
287
+ type ProjectAccessSetting,
288
+ type ProjectSettingsData,
289
+ type ProjectSettingsReader,
290
+ } from './project_settings';
@@ -2,7 +2,8 @@
2
2
  * Office-file server-side extraction helpers.
3
3
  *
4
4
  * Office documents (Microsoft .docx/.xlsx/.pptx, Hancom .hwpx, etc.) can't be
5
- * read by web_fetch (binary/zip). The proxy worker downloads them from db
5
+ * read by web_fetch (binary/zip), and neither can an RFC822 email (.eml), whose
6
+ * body and attachments are MIME-encoded. The proxy worker downloads them from db
6
7
  * storage, extracts their text server-side, and substitutes that text for a
7
8
  * placeholder token in the request body (carried under the reserved
8
9
  * `_skapi_extract` key, which the producer strips before the upstream call).
@@ -36,7 +37,11 @@ export type FileUrlDirective = {
36
37
  // Files whose text the worker extracts SERVER-SIDE and inlines for indexing,
37
38
  // instead of handing the agent a URL to fetch. Two groups:
38
39
  // (1) BINARY document formats the model can't read at all (OOXML, Hancom
39
- // HWP/HWPX, OpenDocument, EPUB) — the worker parses them.
40
+ // HWP/HWPX, OpenDocument, EPUB): the worker parses them. RFC822 email
41
+ // (.eml) belongs here although it is nominally text: it is a MIME
42
+ // container whose body is quoted-printable/base64 and whose attachments
43
+ // are base64 blobs, so it must be PARSED on the server, never decoded as
44
+ // text (a text route would inline the attachment blobs as prose).
40
45
  // (2) TEXT/data/markup/code formats. These ARE readable via web_fetch, BUT
41
46
  // some providers (OpenAI's Responses API) have no working file-fetch tool,
42
47
  // so the agent can't retrieve the URL at all. Extracting them server-side
@@ -48,6 +53,7 @@ const OFFICE_FILE_EXTENSIONS = new Set([
48
53
  'hwp', 'hwpx',
49
54
  'ods', 'odt', 'odp',
50
55
  'epub',
56
+ 'eml',
51
57
  ]);
52
58
 
53
59
  const TEXT_FILE_EXTENSIONS = new Set([
@@ -129,6 +135,11 @@ export const isOfficeFile = isServerExtractable;
129
135
  // .sql, .css, .toml ...). They are windowable, but adding them moves every small code
130
136
  // upload from one pass to multi-pass worker indexing, which is a cost change that wants
131
137
  // measuring on its own rather than riding along with this one.
138
+ //
139
+ // .eml is paged for the same reason the documents are: an email carrying a
140
+ // spreadsheet or a long reply thread routinely exceeds the 200k one-shot cap, and
141
+ // its layer extractor ships in the same change as this entry (listed before the
142
+ // layer lands, it would page UNSUPPORTED_FORMAT on every window).
132
143
  const PAGED_READ_EXTENSIONS = new Set([
133
144
  // grids
134
145
  'xls', 'xlsx', 'xlsm', 'ods',
@@ -142,6 +153,7 @@ const PAGED_READ_EXTENSIONS = new Set([
142
153
  'odt', 'odp',
143
154
  // other long-form documents
144
155
  'epub', 'rtf', 'html', 'htm',
156
+ 'eml', // email (RFC822): body plus attachment text, char-windowed
145
157
  // plain text / data / markup
146
158
  'txt', 'md', 'markdown', 'log', 'json', 'jsonl', 'ndjson', 'xml', 'yaml', 'yml',
147
159
  ]);
@@ -239,8 +251,8 @@ export interface ComposedUserMessage {
239
251
 
240
252
  // Compose the user's chat message from the typed text + uploaded attachment URLs.
241
253
  // Identical for every consumer (agent.vue + the BunnyQuery widget): appends a
242
- // markdown "Attached files" link block, and for office files
243
- // (.docx/.xlsx/.pptx/.hwpx) adds inline extraction placeholders to the LLM copy
254
+ // markdown "Attached files" link block, and for server-extractable files
255
+ // (.docx/.xlsx/.pptx/.hwpx/.eml, text/data/code) adds inline extraction placeholders to the LLM copy
244
256
  // ONLY (the proxy worker substitutes their text server-side; the display/history
245
257
  // copy stays clean so stale tokens never accumulate across replayed turns).
246
258
  // The directive `path` is the db storage path (uid-prefixed where applicable),
@@ -0,0 +1,303 @@
1
+ /**
2
+ * The project's BunnyQuery settings, held as a record in the project's own
3
+ * database rather than on the skapi service record.
4
+ *
5
+ * WHY A RECORD. The upload access group used to live on the service record as
6
+ * `default_access_group`, which was ALSO the skapi SDK's project-wide default
7
+ * for `table.access_group`. One field meant two things: "what BunnyQuery indexes
8
+ * new files at" and "what every SDK record call on this project defaults to".
9
+ * That coupling is gone. The SDK no longer has a project default at all, so this
10
+ * setting needs a home of its own, and a plain public record in the customer's
11
+ * own project is one every client can already reach with the calls it has.
12
+ *
13
+ * SHAPE. One record per project, holding an OBJECT rather than a single value:
14
+ *
15
+ * unique_id: 'bq::settings'
16
+ * table: { name: '__SETTINGS__', access_group: 'public' }
17
+ * data: { upload_access_group: 'authorized' }
18
+ *
19
+ * One record and one fetch covers every present and future project setting. A
20
+ * second setting is a new key, not a new record, so the "wait for settings
21
+ * before the first upload" hand-off below never has to become several waits.
22
+ *
23
+ * WHY PUBLIC. The widget reads this, and the widget frequently runs before there
24
+ * is any session. Group 0 is the only group an unauthenticated caller is served
25
+ * (`check_rec_access` returns immediately for "00" and refuses the rest). Note
26
+ * this is NOT sufficient on its own: skapi's `require_login` gate refuses ALL
27
+ * database reads from a signed-out visitor, and it defaults to true, so on most
28
+ * projects a signed-out widget still cannot read this and falls back to the
29
+ * default. That is survivable because the only thing a signed-out visitor could
30
+ * do with the value is upload, which they cannot do either.
31
+ *
32
+ * WHY THE VALUE MATTERS. The file BYTES are not what the access group controls.
33
+ * BunnyQuery uploads to db storage, whose object key carries no access group and
34
+ * whose read path performs no access check. What carries the group is the
35
+ * RECORDS: the `src::` file record in `file_summaries`, the `run::`/`done::`
36
+ * markers in `__INDEXING__`, and every content record the indexing agent
37
+ * extracts. Those are what a chat answers from, so those are what decide who the
38
+ * file is visible to. The same value is also handed to the chat system prompt as
39
+ * `indexAccessGroup`, because a record written under a different group is in a
40
+ * different table and never comes back with the rest of the file.
41
+ *
42
+ * TRANSPORT-FREE, like the rest of the engine. The store never imports a skapi
43
+ * instance; the consumer injects a reader. See configureProjectSettings.
44
+ */
45
+
46
+ /** The access groups a BunnyQuery upload may be recorded at. */
47
+ export type UploadAccessGroup = 'public' | 'authorized' | 'private';
48
+
49
+ export const UPLOAD_ACCESS_GROUPS: UploadAccessGroup[] = ['public', 'authorized', 'private'];
50
+
51
+ /**
52
+ * What the project's upload-access setting may be: one of the three groups the
53
+ * dashboard offers, or 'ask' to be prompted per upload.
54
+ *
55
+ * `'admin'` (99) is deliberately not offered: a file only a master can read is
56
+ * indistinguishable from one that failed to upload, and no dashboard control
57
+ * would produce it.
58
+ */
59
+ export type ProjectAccessSetting = UploadAccessGroup | 'ask';
60
+
61
+ /**
62
+ * `authorized` is the default because it is what every record written before
63
+ * this setting existed was hardcoded to. A project that never opens the setting
64
+ * keeps exactly the visibility it already had. It is also what the abandoned
65
+ * `default_access_group` service field was seeded to at project creation, so a
66
+ * project carrying that old value reads the same before and after the move.
67
+ */
68
+ export const DEFAULT_UPLOAD_ACCESS_GROUP: UploadAccessGroup = 'authorized';
69
+
70
+ /** Where the settings record lives. Shared so no client re-derives it. */
71
+ export const PROJECT_SETTINGS_TABLE = '__SETTINGS__';
72
+ export const PROJECT_SETTINGS_UNIQUE_ID = 'bq::settings';
73
+ export const PROJECT_SETTINGS_ACCESS_GROUP = 'public';
74
+
75
+ export const UPLOAD_ACCESS_LABELS: Record<UploadAccessGroup, string> = {
76
+ public: 'Public',
77
+ authorized: 'Signed in users',
78
+ private: 'Only me',
79
+ };
80
+
81
+ export const UPLOAD_ACCESS_HINTS: Record<UploadAccessGroup, string> = {
82
+ public: 'Anyone can ask about this file, including visitors who are not logged in.',
83
+ authorized: 'Only users signed in to this project can ask about this file.',
84
+ private: 'Only you can ask about this file.',
85
+ };
86
+
87
+ /** Menu/modal option list, in the order they should be shown. */
88
+ export const UPLOAD_ACCESS_OPTIONS = UPLOAD_ACCESS_GROUPS.map((value) => ({
89
+ value,
90
+ label: UPLOAD_ACCESS_LABELS[value],
91
+ hint: UPLOAD_ACCESS_HINTS[value],
92
+ }));
93
+
94
+ /** The settings record's `data`. Open-ended: future settings are new keys. */
95
+ export type ProjectSettingsData = {
96
+ upload_access_group?: unknown;
97
+ [key: string]: unknown;
98
+ };
99
+
100
+ /** Narrow an unknown stored value to a usable group, falling back to the default. */
101
+ export function normalizeUploadAccessGroup(value: any): UploadAccessGroup {
102
+ return UPLOAD_ACCESS_GROUPS.indexOf(value) === -1
103
+ ? DEFAULT_UPLOAD_ACCESS_GROUP
104
+ : (value as UploadAccessGroup);
105
+ }
106
+
107
+ /**
108
+ * The stored setting as written, or null when the project has never set one.
109
+ *
110
+ * Returns null rather than a default so callers can tell "unset" from "set to
111
+ * authorized". The settings page needs that distinction to decide what the
112
+ * control shows; upload paths do not and use uploadAccessGroupFrom instead.
113
+ */
114
+ export function normalizeProjectAccessSetting(value: any): ProjectAccessSetting | null {
115
+ if (value === 'ask') return 'ask';
116
+ return UPLOAD_ACCESS_GROUPS.indexOf(value) === -1 ? null : (value as ProjectAccessSetting);
117
+ }
118
+
119
+ /** The setting held in a settings-record `data`, or null when unset. */
120
+ export function accessSettingFrom(
121
+ data: ProjectSettingsData | null | undefined,
122
+ ): ProjectAccessSetting | null {
123
+ return normalizeProjectAccessSetting(data?.upload_access_group);
124
+ }
125
+
126
+ /** The group an upload lands in when the project is NOT set to 'ask'. */
127
+ export function uploadAccessGroupFrom(
128
+ data: ProjectSettingsData | null | undefined,
129
+ ): UploadAccessGroup {
130
+ const v = accessSettingFrom(data);
131
+ return v && v !== 'ask' ? v : DEFAULT_UPLOAD_ACCESS_GROUP;
132
+ }
133
+
134
+ /** True when the project wants to be asked per upload rather than told once. */
135
+ export function asksUploadAccessFrom(data: ProjectSettingsData | null | undefined): boolean {
136
+ return accessSettingFrom(data) === 'ask';
137
+ }
138
+
139
+ /**
140
+ * Fetch one project's settings record. Resolves the record's `data`, or null
141
+ * when there is no record.
142
+ *
143
+ * MAY REJECT, and the store treats a rejection as "no record": a signed-out
144
+ * visitor on a `require_login` project gets REQUIRE_LOGIN here, which is a
145
+ * normal outcome and not an error the user should ever see.
146
+ */
147
+ export type ProjectSettingsReader = (service: string) => Promise<ProjectSettingsData | null>;
148
+
149
+ type Entry = {
150
+ /** Resolved data, or null for "fetched, and there is none". */
151
+ data: ProjectSettingsData | null;
152
+ /** True once a fetch has SETTLED, so `data` is authoritative rather than absent. */
153
+ settled: boolean;
154
+ /** The in-flight fetch, deduped so N callers share ONE request. */
155
+ inflight: Promise<ProjectSettingsData | null> | null;
156
+ };
157
+
158
+ let reader: ProjectSettingsReader | null = null;
159
+
160
+ /**
161
+ * Keyed by service id, NEVER global.
162
+ *
163
+ * One client routinely serves MANY projects: the console switches projects
164
+ * without reloading, and an upload outlives the page that started it. A flat
165
+ * single-value cache lets one project's setting decide another project's upload
166
+ * group, which is a silent cross-project data-visibility bug, not a stale-read
167
+ * annoyance. Every accessor takes the service id for this reason.
168
+ */
169
+ const cache = new Map<string, Entry>();
170
+
171
+ export function configureProjectSettings(fn: ProjectSettingsReader | null): void {
172
+ reader = fn;
173
+ }
174
+
175
+ function entry(service: string): Entry {
176
+ let e = cache.get(service);
177
+ if (!e) {
178
+ e = { data: null, settled: false, inflight: null };
179
+ cache.set(service, e);
180
+ }
181
+ return e;
182
+ }
183
+
184
+ /**
185
+ * Start the fetch and hand back the promise, deduping concurrent callers.
186
+ *
187
+ * Never rejects: a failed read settles as null, which every accessor reads as
188
+ * "unset" and answers with the default. A settings fetch must not be able to
189
+ * fail an upload.
190
+ */
191
+ export function loadProjectSettings(service: string): Promise<ProjectSettingsData | null> {
192
+ if (!service) return Promise.resolve(null);
193
+ const e = entry(service);
194
+ if (e.settled) return Promise.resolve(e.data);
195
+ if (e.inflight) return e.inflight;
196
+ if (!reader) return Promise.resolve(null);
197
+
198
+ const run: Promise<ProjectSettingsData | null> = reader(service)
199
+ .then((data) => (data && typeof data === 'object' ? data : null))
200
+ .catch(() => null)
201
+ .then((data) => {
202
+ // Guard against a clear() that landed while this was in flight: the
203
+ // entry may have been replaced, so write through the map rather than
204
+ // through the captured object.
205
+ const cur = entry(service);
206
+ if (cur.inflight === run) {
207
+ cur.data = data;
208
+ cur.settled = true;
209
+ cur.inflight = null;
210
+ }
211
+ return data;
212
+ });
213
+
214
+ e.inflight = run;
215
+ return run;
216
+ }
217
+
218
+ /**
219
+ * Kick the fetch off without waiting for it. Call on chat/page open.
220
+ *
221
+ * Fire-and-forget by design: the page paints on the default and the first upload
222
+ * awaits the real value via readyProjectSettings. Nothing blocks on this.
223
+ */
224
+ export function primeProjectSettings(service: string): void {
225
+ void loadProjectSettings(service);
226
+ }
227
+
228
+ /**
229
+ * Await the settings for this project. What the FIRST upload calls.
230
+ *
231
+ * Cheap after the first call: a settled entry resolves immediately, and a
232
+ * primed-but-unsettled one joins the in-flight request rather than starting a
233
+ * second.
234
+ */
235
+ export function readyProjectSettings(service: string): Promise<ProjectSettingsData | null> {
236
+ return loadProjectSettings(service);
237
+ }
238
+
239
+ /**
240
+ * The cached data WITHOUT waiting, or null when nothing has settled yet.
241
+ *
242
+ * For synchronous readers (a template, a menu's current value). A caller that is
243
+ * about to WRITE an access group onto a record must use readyProjectSettings
244
+ * instead: answering from an unsettled cache is how a file lands in the wrong
245
+ * group on the first upload after a page load.
246
+ */
247
+ export function cachedProjectSettings(service: string): ProjectSettingsData | null {
248
+ const e = cache.get(service);
249
+ return e && e.settled ? e.data : null;
250
+ }
251
+
252
+ /** True once this project's settings have been fetched (whether or not one existed). */
253
+ export function projectSettingsSettled(service: string): boolean {
254
+ const e = cache.get(service);
255
+ return !!e && e.settled;
256
+ }
257
+
258
+ /** Sync convenience: the project's setting as stored, or null when unset/unsettled. */
259
+ export function projectAccessSetting(service: string): ProjectAccessSetting | null {
260
+ return accessSettingFrom(cachedProjectSettings(service));
261
+ }
262
+
263
+ /** Sync convenience: the upload group, falling back to the default. */
264
+ export function projectUploadAccessGroup(service: string): UploadAccessGroup {
265
+ return uploadAccessGroupFrom(cachedProjectSettings(service));
266
+ }
267
+
268
+ /** Sync convenience: does this project want a per-upload prompt? */
269
+ export function projectAsksUploadAccess(service: string): boolean {
270
+ return asksUploadAccessFrom(cachedProjectSettings(service));
271
+ }
272
+
273
+ /**
274
+ * Adopt a value the caller just WROTE, so the settings page reflects its own
275
+ * save without a re-fetch.
276
+ *
277
+ * Marks the entry settled: the writer knows the stored value better than a
278
+ * refetch would, and leaving it unsettled would send the next upload back to the
279
+ * network for a value already in hand.
280
+ */
281
+ export function setProjectSettings(service: string, data: ProjectSettingsData | null): void {
282
+ if (!service) return;
283
+ cache.set(service, { data: data || null, settled: true, inflight: null });
284
+ }
285
+
286
+ /** Merge one key into the cached settings, preserving the rest. */
287
+ export function patchProjectSettings(service: string, patch: ProjectSettingsData): void {
288
+ if (!service) return;
289
+ const cur = cachedProjectSettings(service) || {};
290
+ setProjectSettings(service, Object.assign({}, cur, patch));
291
+ }
292
+
293
+ /**
294
+ * Drop cached settings. Pass a service to drop one, omit to drop all.
295
+ *
296
+ * An in-flight fetch is abandoned rather than cancelled: its `.then` checks that
297
+ * the entry it is writing into is still its own, so a late response cannot
298
+ * repopulate a cleared project.
299
+ */
300
+ export function clearProjectSettings(service?: string): void {
301
+ if (service) cache.delete(service);
302
+ else cache.clear();
303
+ }