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