@richardmcquiston01/chat-and-react-core 0.5.0 → 0.7.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.
package/dist/index.mjs CHANGED
@@ -1,3 +1,57 @@
1
+ // src/index.ts
2
+ import {
3
+ localStorageAdapter,
4
+ sessionStorageAdapter
5
+ } from "@richardmcquiston01/storage-adapters/browser";
6
+ import { indexedDbAdapter } from "@richardmcquiston01/storage-adapters/indexeddb";
7
+
8
+ // src/adapters/browser-event-adapter.ts
9
+ var BrowserEventAdapter = class {
10
+ emit(event) {
11
+ if (typeof document === "undefined") return;
12
+ const name = event.type === "page:submit" ? "chat-page-submit" : "chat-form-complete";
13
+ document.dispatchEvent(new CustomEvent(name, { detail: event, bubbles: true }));
14
+ }
15
+ };
16
+
17
+ // src/adapters/console-error-adapter.ts
18
+ import {
19
+ ConsoleErrorAdapter as BaseConsoleErrorAdapter
20
+ } from "@richardmcquiston01/error-log-adapter";
21
+ var ConsoleErrorAdapter = class extends BaseConsoleErrorAdapter {
22
+ constructor(options = {}) {
23
+ super({ prefix: "[chat-and-react]", ...options });
24
+ }
25
+ };
26
+
27
+ // src/adapters/identity-input-adapter.ts
28
+ var IdentityInputAdapter = class {
29
+ transform(raw) {
30
+ return raw;
31
+ }
32
+ };
33
+
34
+ // src/rule-engine.ts
35
+ import jsonLogic from "json-logic-js";
36
+ var RuleEngine = class {
37
+ /**
38
+ * Evaluates a JSON Logic rule against an answer map.
39
+ * @param rule The JSON Logic rule to evaluate
40
+ * @param answers The answer map to evaluate the rule against
41
+ * @returns The boolean result of the rule evaluation
42
+ * @throws {{ code: 'RULE_EVAL_FAILED', message: string, cause: unknown }} if rule evaluation fails
43
+ */
44
+ evaluate(rule, answers) {
45
+ try {
46
+ return Boolean(
47
+ jsonLogic.apply(rule, answers)
48
+ );
49
+ } catch (cause) {
50
+ throw { code: "RULE_EVAL_FAILED", message: "Rule evaluation failed", cause };
51
+ }
52
+ }
53
+ };
54
+
1
55
  // src/schema-parser.ts
2
56
  var VALID_INPUTS = /* @__PURE__ */ new Set(["text", "textarea", "dropdown", "radio", "checkbox"]);
3
57
  var FORM_COMPLETED = "**FORM_COMPLETED**";
@@ -85,9 +139,7 @@ function validateQuestion(raw, pageId, questionIds, rules) {
85
139
  const c = condition;
86
140
  if ("ref" in c && typeof c["ref"] === "string") {
87
141
  if (!(c["ref"] in rules)) {
88
- throw makeError(
89
- `Question "${q["id"]}" x-chat-condition refs unknown rule "${c["ref"]}"`
90
- );
142
+ throw makeError(`Question "${q["id"]}" x-chat-condition refs unknown rule "${c["ref"]}"`);
91
143
  }
92
144
  }
93
145
  }
@@ -96,25 +148,6 @@ function makeError(message) {
96
148
  return { code: "SCHEMA_INVALID", message };
97
149
  }
98
150
 
99
- // src/rule-engine.ts
100
- import jsonLogic from "json-logic-js";
101
- var RuleEngine = class {
102
- /**
103
- * Evaluates a JSON Logic rule against an answer map.
104
- * @param rule The JSON Logic rule to evaluate
105
- * @param answers The answer map to evaluate the rule against
106
- * @returns The boolean result of the rule evaluation
107
- * @throws {{ code: 'RULE_EVAL_FAILED', message: string, cause: unknown }} if rule evaluation fails
108
- */
109
- evaluate(rule, answers) {
110
- try {
111
- return Boolean(jsonLogic.apply(rule, answers));
112
- } catch (cause) {
113
- throw { code: "RULE_EVAL_FAILED", message: "Rule evaluation failed", cause };
114
- }
115
- }
116
- };
117
-
118
151
  // src/session-manager.ts
119
152
  var KEY_SESSION = "car:session-id";
120
153
  var keyProgress = (id) => `car:progress:${id}`;
@@ -133,10 +166,7 @@ var SessionManager = class {
133
166
  }
134
167
  saveProgress(currentPageId, answers) {
135
168
  if (!this.available) return;
136
- this.storage.setItem(
137
- keyProgress(this.sessionId),
138
- JSON.stringify({ currentPageId, answers })
139
- );
169
+ this.storage.setItem(keyProgress(this.sessionId), JSON.stringify({ currentPageId, answers }));
140
170
  }
141
171
  loadProgress() {
142
172
  if (!this.available) return null;
@@ -150,10 +180,7 @@ var SessionManager = class {
150
180
  }
151
181
  saveInput(questionId, value) {
152
182
  if (!this.available) return;
153
- this.storage.setItem(
154
- keyInput(this.sessionId, questionId),
155
- JSON.stringify(value)
156
- );
183
+ this.storage.setItem(keyInput(this.sessionId, questionId), JSON.stringify(value));
157
184
  }
158
185
  loadInput(questionId) {
159
186
  if (!this.available) return null;
@@ -198,35 +225,25 @@ var SessionManager = class {
198
225
  }
199
226
  };
200
227
 
201
- // src/adapters/identity-input-adapter.ts
202
- var IdentityInputAdapter = class {
203
- transform(raw) {
204
- return raw;
205
- }
206
- };
207
-
208
- // src/adapters/browser-event-adapter.ts
209
- var BrowserEventAdapter = class {
210
- emit(event) {
211
- if (typeof document === "undefined") return;
212
- const name = event.type === "page:submit" ? "chat-page-submit" : "chat-form-complete";
213
- document.dispatchEvent(new CustomEvent(name, { detail: event, bubbles: true }));
214
- }
215
- };
216
-
217
- // src/adapters/console-error-adapter.ts
218
- var ConsoleErrorAdapter = class {
219
- log(error) {
220
- console.error("[chat-and-react]", error);
221
- }
222
- };
223
-
224
228
  // src/chat-controller.ts
225
229
  var DEFAULT_OPTIONS = {
226
230
  userResumable: true,
227
231
  localStorage: true,
228
- autoComplete: true
232
+ autoComplete: true,
233
+ allowEdit: false,
234
+ autoHideQuestions: 0,
235
+ autoHideAnswers: 0,
236
+ hideAnimation: "fade",
237
+ typingSpeed: 0,
238
+ thinkingDelay: 0,
239
+ thinkingText: "",
240
+ showContinueIcon: true
229
241
  };
242
+ function stripUndefined(source) {
243
+ return Object.fromEntries(
244
+ Object.entries(source).filter(([, value]) => value !== void 0)
245
+ );
246
+ }
230
247
  var ChatController = class {
231
248
  /**
232
249
  * @param config - Controller configuration including schema and optional adapters.
@@ -239,12 +256,19 @@ var ChatController = class {
239
256
  this.currentPage = null;
240
257
  this.allAnswers = {};
241
258
  this.visitIndex = 0;
259
+ /** Pages submitted in this session, in visit order. Used for editing earlier answers. */
260
+ this.route = [];
261
+ /** Index in `route` of the page being edited, or null when not editing. */
262
+ this.editIndex = null;
263
+ /** The page the user was on before they started editing an earlier one. */
264
+ this.resumePageId = null;
242
265
  this.listeners = /* @__PURE__ */ new Map();
243
266
  this.config = {
244
267
  inputAdapter: config.inputAdapter ?? new IdentityInputAdapter(),
245
268
  outputAdapter: config.outputAdapter ?? new BrowserEventAdapter(),
246
269
  errorAdapter: config.errorAdapter ?? new ConsoleErrorAdapter(),
247
- schema: config.schema
270
+ schema: config.schema,
271
+ options: config.options ?? {}
248
272
  };
249
273
  this.session = sessionManager ?? new SessionManager();
250
274
  }
@@ -259,27 +283,29 @@ var ChatController = class {
259
283
  } catch (cause) {
260
284
  this.handleFatal({ code: "ADAPTER_ERROR", message: "InputAdapter.transform() threw", cause });
261
285
  }
286
+ let schema;
262
287
  try {
263
- this.schema = parseSchema(raw);
288
+ schema = parseSchema(raw);
264
289
  } catch (e) {
265
290
  this.handleFatal(e);
266
291
  }
292
+ this.schema = schema;
267
293
  if (!this.session.isStorageAvailable) {
268
- this.config.errorAdapter.log({
294
+ this.logError({
269
295
  code: "STORAGE_UNAVAILABLE",
270
296
  message: "localStorage unavailable; running in memory-only mode"
271
297
  });
272
298
  }
273
- const schemaOpts = this.schema["x-chat-options"] ?? {};
299
+ const schemaOpts = schema["x-chat-options"] ?? {};
274
300
  this.options = {
275
- userResumable: schemaOpts.userResumable ?? DEFAULT_OPTIONS.userResumable,
276
- localStorage: schemaOpts.localStorage ?? DEFAULT_OPTIONS.localStorage,
277
- autoComplete: schemaOpts.autoComplete ?? DEFAULT_OPTIONS.autoComplete
301
+ ...DEFAULT_OPTIONS,
302
+ ...stripUndefined(schemaOpts),
303
+ ...stripUndefined(this.config.options)
278
304
  };
279
305
  if (this.options.userResumable) {
280
306
  const saved = this.session.loadProgress();
281
307
  if (saved) {
282
- const page = this.schema["x-chat-pages"].find((p) => p.id === saved.currentPageId);
308
+ const page = schema["x-chat-pages"].find((p) => p.id === saved.currentPageId);
283
309
  if (page) {
284
310
  this.allAnswers = saved.answers;
285
311
  this.currentPage = page;
@@ -287,7 +313,7 @@ var ChatController = class {
287
313
  }
288
314
  }
289
315
  }
290
- this.currentPage = this.schema["x-chat-pages"][0];
316
+ this.currentPage = schema["x-chat-pages"][0];
291
317
  }
292
318
  /**
293
319
  * Returns the page currently being presented to the user, or null if complete.
@@ -309,6 +335,38 @@ var ChatController = class {
309
335
  getOptions() {
310
336
  return { ...this.options };
311
337
  }
338
+ /**
339
+ * Returns the pages the user has submitted in this session, in visit order.
340
+ * A session restored from storage starts with an empty route.
341
+ */
342
+ getRoute() {
343
+ return this.route.map((entry) => ({ pageId: entry.pageId, answers: { ...entry.answers } }));
344
+ }
345
+ /**
346
+ * Returns every answer given so far, for pre-filling inputs.
347
+ */
348
+ getAnswers() {
349
+ return { ...this.allAnswers };
350
+ }
351
+ /**
352
+ * Re-opens a page the user already submitted so its answers can be changed.
353
+ * Call `submitPage` afterwards to save the new answers. Not available once the
354
+ * form is complete.
355
+ *
356
+ * @param routeIndex - Position of the page in `getRoute()`.
357
+ * @returns true when the page was re-opened.
358
+ */
359
+ reopenPage(routeIndex) {
360
+ if (!this.schema || !this.currentPage) return false;
361
+ const entry = this.route[routeIndex];
362
+ if (!entry) return false;
363
+ const page = this.schema["x-chat-pages"].find((p) => p.id === entry.pageId);
364
+ if (!page) return false;
365
+ if (this.editIndex === null) this.resumePageId = this.currentPage.id;
366
+ this.editIndex = routeIndex;
367
+ this.currentPage = page;
368
+ return true;
369
+ }
312
370
  /**
313
371
  * Submits the answers for the current page and advances the session.
314
372
  * - Accumulates answers into `allAnswers`.
@@ -316,28 +374,67 @@ var ChatController = class {
316
374
  * - Resolves branching to find the next page.
317
375
  * - Emits `form:complete` and clears session when `**FORM_COMPLETED**` is reached.
318
376
  *
377
+ * When the page was re-opened with `reopenPage`, the new answers replace the
378
+ * old ones and `page:submit` is emitted with `edited: true`. If the page's
379
+ * branching still leads to the same next page, the form returns to the page the
380
+ * user was on. If it leads somewhere else, the pages that followed it are
381
+ * dropped, along with their answers, and the form continues from the new page.
382
+ *
319
383
  * @param pageAnswers - Answers collected for the current page.
320
384
  */
321
385
  submitPage(pageAnswers) {
322
- if (!this.currentPage || !this.schema) return;
323
- this.allAnswers = { ...this.allAnswers, ...pageAnswers };
386
+ if (!this.currentPage || !this.schema) return "advanced";
324
387
  const pageId = this.currentPage.id;
325
388
  const currentVisitIndex = this.visitIndex;
326
- const nextPageId = this.resolveBranching(this.currentPage, this.allAnswers);
389
+ const editedIndex = this.editIndex;
390
+ const isEdit = editedIndex !== null;
391
+ let outcome = "advanced";
392
+ let nextPageId;
393
+ let resumePageId = null;
394
+ if (isEdit) {
395
+ const entry = this.route[editedIndex];
396
+ const remaining = { ...this.allAnswers };
397
+ for (const questionId of Object.keys(entry.answers)) delete remaining[questionId];
398
+ this.allAnswers = { ...remaining, ...pageAnswers };
399
+ entry.answers = { ...pageAnswers };
400
+ nextPageId = this.resolveBranching(this.currentPage, this.allAnswers);
401
+ const expectedNextPageId = this.route[editedIndex + 1]?.pageId ?? this.resumePageId;
402
+ if (nextPageId === expectedNextPageId) {
403
+ outcome = "edit-resumed";
404
+ resumePageId = this.resumePageId;
405
+ } else {
406
+ outcome = "edit-rerouted";
407
+ for (const later of this.route.slice(editedIndex + 1)) {
408
+ for (const questionId of Object.keys(later.answers)) delete this.allAnswers[questionId];
409
+ }
410
+ this.route.length = editedIndex + 1;
411
+ }
412
+ this.editIndex = null;
413
+ this.resumePageId = null;
414
+ } else {
415
+ this.allAnswers = { ...this.allAnswers, ...pageAnswers };
416
+ this.route.push({ pageId, answers: { ...pageAnswers } });
417
+ nextPageId = this.resolveBranching(this.currentPage, this.allAnswers);
418
+ }
327
419
  if (this.options.userResumable) {
328
- const progressPageId = nextPageId === "**FORM_COMPLETED**" ? pageId : nextPageId;
420
+ const progressPageId = outcome === "edit-resumed" && resumePageId ? resumePageId : nextPageId === "**FORM_COMPLETED**" ? pageId : nextPageId;
329
421
  this.session.saveProgress(progressPageId, this.allAnswers);
330
422
  const submitEvent = {
331
423
  type: "page:submit",
332
424
  sessionId: this.session.getSessionId(),
333
425
  pageId,
334
426
  visitIndex: currentVisitIndex,
335
- answers: pageAnswers
427
+ answers: pageAnswers,
428
+ ...isEdit ? { edited: true } : {}
336
429
  };
337
430
  this.safeEmit(submitEvent);
338
431
  this.emit("page:submit", submitEvent);
339
432
  }
340
433
  this.visitIndex++;
434
+ if (outcome === "edit-resumed" && resumePageId) {
435
+ this.currentPage = this.schema["x-chat-pages"].find((p) => p.id === resumePageId) ?? null;
436
+ return outcome;
437
+ }
341
438
  if (nextPageId === "**FORM_COMPLETED**") {
342
439
  this.session.clearAll();
343
440
  const completeEvent = {
@@ -351,6 +448,7 @@ var ChatController = class {
351
448
  } else {
352
449
  this.currentPage = this.schema["x-chat-pages"].find((p) => p.id === nextPageId) ?? null;
353
450
  }
451
+ return outcome;
354
452
  }
355
453
  /**
356
454
  * Registers an event listener for the given event type.
@@ -359,8 +457,12 @@ var ChatController = class {
359
457
  * @param listener - The callback to invoke when the event fires.
360
458
  */
361
459
  on(event, listener) {
362
- if (!this.listeners.has(event)) this.listeners.set(event, /* @__PURE__ */ new Set());
363
- this.listeners.get(event).add(listener);
460
+ let listenersForEvent = this.listeners.get(event);
461
+ if (!listenersForEvent) {
462
+ listenersForEvent = /* @__PURE__ */ new Set();
463
+ this.listeners.set(event, listenersForEvent);
464
+ }
465
+ listenersForEvent.add(listener);
364
466
  }
365
467
  /**
366
468
  * Removes a previously registered event listener.
@@ -375,6 +477,7 @@ var ChatController = class {
375
477
  * Tears down the controller: removes all listeners and nulls internal state.
376
478
  */
377
479
  destroy() {
480
+ this.session.dispose?.();
378
481
  this.listeners.clear();
379
482
  this.currentPage = null;
380
483
  this.schema = null;
@@ -396,12 +499,24 @@ var ChatController = class {
396
499
  rule = condition;
397
500
  }
398
501
  try {
399
- return this.ruleEngine.evaluate(rule, this.allAnswers);
502
+ return this.ruleEngine.evaluate(rule, this.answersForRules());
400
503
  } catch (e) {
401
- this.config.errorAdapter.log(e);
504
+ this.logError(e);
402
505
  return false;
403
506
  }
404
507
  }
508
+ /**
509
+ * The answers question conditions are checked against. While an earlier page is
510
+ * being edited, that page's own answers are left out, so its questions are
511
+ * shown exactly as they were the first time the page was asked.
512
+ */
513
+ answersForRules() {
514
+ if (this.editIndex === null) return this.allAnswers;
515
+ const rest = { ...this.allAnswers };
516
+ for (const questionId of Object.keys(this.route[this.editIndex].answers))
517
+ delete rest[questionId];
518
+ return rest;
519
+ }
405
520
  /**
406
521
  * Walks the branching rules top-to-bottom. Returns the first match's destination_id.
407
522
  * Falls back to `**FORM_COMPLETED**` if nothing matches (should not happen with valid schemas).
@@ -422,7 +537,7 @@ var ChatController = class {
422
537
  try {
423
538
  this.config.outputAdapter.emit(event);
424
539
  } catch (cause) {
425
- this.config.errorAdapter.log({
540
+ this.logError({
426
541
  code: "ADAPTER_ERROR",
427
542
  message: `OutputAdapter.emit() threw for ${event.type}`,
428
543
  cause
@@ -433,24 +548,276 @@ var ChatController = class {
433
548
  * Notifies all registered listeners for the given event.
434
549
  */
435
550
  emit(event, payload) {
436
- this.listeners.get(event)?.forEach((l) => l(payload));
551
+ this.listeners.get(event)?.forEach((l) => {
552
+ l(payload);
553
+ });
437
554
  }
438
555
  /**
439
556
  * Logs the error via the error adapter and then throws it.
440
557
  * Used for unrecoverable errors (schema invalid, adapter failure on start).
441
558
  */
442
559
  handleFatal(error) {
443
- this.config.errorAdapter.log(error);
560
+ this.logError(error);
444
561
  throw error;
445
562
  }
563
+ /**
564
+ * Sends the error to the error adapter without waiting for it. If the adapter
565
+ * reports a failure, rejects, or throws, that is written to `console.error` so
566
+ * the original error is never lost and the form keeps running.
567
+ */
568
+ logError(entry) {
569
+ const reportFailure = (reason) => {
570
+ console.error("[chat-and-react] ErrorLogAdapter failed to record an error:", reason, entry);
571
+ };
572
+ try {
573
+ void Promise.resolve(this.config.errorAdapter.log(entry)).then(
574
+ (result) => {
575
+ if (result?.ok === false) {
576
+ reportFailure(result.error);
577
+ }
578
+ },
579
+ reportFailure
580
+ );
581
+ } catch (cause) {
582
+ reportFailure(cause);
583
+ }
584
+ }
585
+ };
586
+
587
+ // src/storage/adapter-session-manager.ts
588
+ var KEY_SESSION2 = "car:session-id";
589
+ var keyRecord = (sessionId) => `car:session:${sessionId}`;
590
+ var MAX_CONFLICT_RETRIES = 3;
591
+ var emptyRecord = () => ({ progress: null, inputs: {} });
592
+ function parseRecord(raw) {
593
+ try {
594
+ const parsed = JSON.parse(raw);
595
+ return {
596
+ progress: parsed.progress ?? null,
597
+ inputs: parsed.inputs && typeof parsed.inputs === "object" ? parsed.inputs : {}
598
+ };
599
+ } catch {
600
+ return null;
601
+ }
602
+ }
603
+ var AdapterSessionManager = class _AdapterSessionManager {
604
+ constructor(adapter, sessionId, available, initial, options) {
605
+ this.adapter = adapter;
606
+ this.sessionId = sessionId;
607
+ this.available = available;
608
+ this.options = options;
609
+ this.dirtyInputs = /* @__PURE__ */ new Set();
610
+ this.dirtyProgress = false;
611
+ this.pending = false;
612
+ this.persisting = null;
613
+ this.signal = new AbortController().signal;
614
+ this.unsubscribe = null;
615
+ const parsed = initial ? parseRecord(initial.value) : null;
616
+ this.record = parsed ?? emptyRecord();
617
+ this.revision = initial?.revision;
618
+ if (initial && !parsed) {
619
+ this.report(
620
+ `Saved session ${sessionId} could not be read as JSON; starting with an empty session`
621
+ );
622
+ }
623
+ if (available) {
624
+ this.unsubscribe = adapter.subscribe?.(keyRecord(sessionId), (stored) => this.applyRemote(stored)) ?? null;
625
+ }
626
+ }
627
+ /** Loads (or creates) the session id and its saved record, then returns the manager. */
628
+ static async create(adapter, options = {}) {
629
+ const signal = new AbortController().signal;
630
+ const fail = (message) => options.onError?.({ code: "STORAGE_UNAVAILABLE", message });
631
+ let sessionId = null;
632
+ let available = true;
633
+ const idResult = await adapter.get(KEY_SESSION2, { signal });
634
+ if (idResult.ok) {
635
+ sessionId = idResult.value?.value ?? null;
636
+ } else {
637
+ available = false;
638
+ fail(`Could not read the session id: ${idResult.error}. Running in memory-only mode`);
639
+ }
640
+ if (available && sessionId === null) {
641
+ sessionId = crypto.randomUUID();
642
+ const saved = await adapter.set(KEY_SESSION2, sessionId, {
643
+ signal
644
+ });
645
+ if (!saved.ok) {
646
+ available = false;
647
+ fail(`Could not save the session id: ${saved.error}. Running in memory-only mode`);
648
+ }
649
+ }
650
+ const id = sessionId ?? crypto.randomUUID();
651
+ let initial = null;
652
+ if (available) {
653
+ const recordResult = await adapter.get(keyRecord(id), {
654
+ signal
655
+ });
656
+ if (recordResult.ok) {
657
+ initial = recordResult.value;
658
+ } else {
659
+ available = false;
660
+ fail(`Could not read saved progress: ${recordResult.error}. Running in memory-only mode`);
661
+ }
662
+ }
663
+ return new _AdapterSessionManager(adapter, id, available, initial, options);
664
+ }
665
+ getSessionId() {
666
+ return this.sessionId;
667
+ }
668
+ get isStorageAvailable() {
669
+ return this.available;
670
+ }
671
+ saveProgress(currentPageId, answers) {
672
+ this.record.progress = { currentPageId, answers };
673
+ this.dirtyProgress = true;
674
+ this.schedulePersist();
675
+ }
676
+ loadProgress() {
677
+ return this.record.progress;
678
+ }
679
+ saveInput(questionId, value) {
680
+ this.record.inputs[questionId] = value;
681
+ this.dirtyInputs.add(questionId);
682
+ this.schedulePersist();
683
+ }
684
+ loadInput(questionId) {
685
+ return this.record.inputs[questionId] ?? null;
686
+ }
687
+ clearAll() {
688
+ this.record = emptyRecord();
689
+ this.dirtyInputs.clear();
690
+ this.dirtyProgress = false;
691
+ this.pending = false;
692
+ if (!this.available) return;
693
+ void this.removeRecord();
694
+ }
695
+ dispose() {
696
+ this.unsubscribe?.();
697
+ this.unsubscribe = null;
698
+ }
699
+ /** Resolves when every save made so far has finished. Mostly for tests. */
700
+ async flush() {
701
+ while (this.persisting) {
702
+ await this.persisting;
703
+ }
704
+ }
705
+ report(message, cause) {
706
+ this.options.onError?.({ code: "STORAGE_UNAVAILABLE", message, cause });
707
+ }
708
+ schedulePersist() {
709
+ if (!this.available) return;
710
+ this.pending = true;
711
+ this.persisting ?? (this.persisting = this.drain());
712
+ }
713
+ /** Saves until nothing is left to save. Never rejects. */
714
+ async drain() {
715
+ let conflicts = 0;
716
+ try {
717
+ while (this.pending) {
718
+ this.pending = false;
719
+ const outcome = await this.persistOnce();
720
+ conflicts = outcome === "conflict" ? conflicts + 1 : 0;
721
+ if (outcome === "conflict") {
722
+ if (conflicts >= MAX_CONFLICT_RETRIES) {
723
+ this.report(
724
+ `Gave up saving session ${this.sessionId} after ${conflicts} revision conflicts in a row`
725
+ );
726
+ break;
727
+ }
728
+ this.pending = true;
729
+ }
730
+ }
731
+ } finally {
732
+ this.persisting = null;
733
+ }
734
+ }
735
+ async persistOnce() {
736
+ const sentInputs = new Set(this.dirtyInputs);
737
+ const sentProgress = this.dirtyProgress;
738
+ this.dirtyInputs.clear();
739
+ this.dirtyProgress = false;
740
+ const result = await this.adapter.set(
741
+ keyRecord(this.sessionId),
742
+ JSON.stringify(this.record),
743
+ { signal: this.signal, ifRevision: this.revision }
744
+ );
745
+ if (result.ok) {
746
+ this.revision = result.value.revision;
747
+ return "saved";
748
+ }
749
+ for (const questionId of sentInputs) this.dirtyInputs.add(questionId);
750
+ this.dirtyProgress = this.dirtyProgress || sentProgress;
751
+ if (result.code !== "conflict") {
752
+ this.report(`Could not save session ${this.sessionId}: ${result.error}`);
753
+ return "failed";
754
+ }
755
+ const latest = await this.adapter.get(
756
+ keyRecord(this.sessionId),
757
+ { signal: this.signal }
758
+ );
759
+ if (!latest.ok) {
760
+ this.report(
761
+ `Session ${this.sessionId} changed elsewhere, but the new version could not be read: ${latest.error}`
762
+ );
763
+ return "failed";
764
+ }
765
+ this.applyRemote(latest.value);
766
+ return "conflict";
767
+ }
768
+ /** Merges a version written elsewhere with this tab's unsaved changes. */
769
+ applyRemote(stored) {
770
+ const before = JSON.stringify(this.record.progress);
771
+ if (!stored) {
772
+ if (!this.dirtyProgress && this.dirtyInputs.size === 0) {
773
+ this.record = emptyRecord();
774
+ }
775
+ this.revision = void 0;
776
+ } else {
777
+ const remote = parseRecord(stored.value);
778
+ if (!remote) {
779
+ this.report(
780
+ `Session ${this.sessionId} was changed elsewhere to a value that is not valid JSON; ignoring it`
781
+ );
782
+ this.revision = stored.revision;
783
+ return;
784
+ }
785
+ const inputs = { ...remote.inputs };
786
+ for (const questionId of this.dirtyInputs) {
787
+ const mine = this.record.inputs[questionId];
788
+ if (mine !== void 0) inputs[questionId] = mine;
789
+ }
790
+ this.record = {
791
+ progress: this.dirtyProgress ? this.record.progress : remote.progress,
792
+ inputs
793
+ };
794
+ this.revision = stored.revision;
795
+ }
796
+ if (JSON.stringify(this.record.progress) !== before) {
797
+ this.options.onExternalChange?.(this.record.progress);
798
+ }
799
+ }
800
+ async removeRecord() {
801
+ const key = keyRecord(this.sessionId);
802
+ const result = this.adapter.remove ? await this.adapter.remove(key, { signal: this.signal }) : await this.adapter.set(key, JSON.stringify(emptyRecord()), { signal: this.signal });
803
+ if (result.ok) {
804
+ this.revision = void 0;
805
+ } else {
806
+ this.report(`Could not clear session ${this.sessionId}: ${result.error}`);
807
+ }
808
+ }
446
809
  };
447
810
  export {
811
+ AdapterSessionManager,
448
812
  BrowserEventAdapter,
449
813
  ChatController,
450
814
  ConsoleErrorAdapter,
451
815
  IdentityInputAdapter,
452
816
  RuleEngine,
453
817
  SessionManager,
454
- parseSchema
818
+ indexedDbAdapter,
819
+ localStorageAdapter,
820
+ parseSchema,
821
+ sessionStorageAdapter
455
822
  };
456
823
  //# sourceMappingURL=index.mjs.map