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