@xema/omni-protocol 0.1.62 → 0.1.63

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/testing.d.ts CHANGED
@@ -43,6 +43,14 @@ export interface ExerciseAdapterOptions {
43
43
  drive?: boolean;
44
44
  /** How long the drive waits for each thing the provider owes it. Defaults to 5000. */
45
45
  driveTimeoutMs?: number;
46
+ /**
47
+ * Builds the adapter again, as a host reload does: a new object with no memory, for the same
48
+ * login. Given it, the drive -- once the host's muted leg is recorded -- connects a second adapter
49
+ * with the same context and the same store and expects its snapshot to carry the task with that
50
+ * leg. A platform that holds the record hands it back; an adapter that composed the record in
51
+ * memory has nothing, and is named. Without it the store is required and never read.
52
+ */
53
+ rebuild?: () => Adapter<Channel>;
46
54
  }
47
55
  /**
48
56
  * Adapter conformance exercise: validates the manifest, opens an authenticated session,
package/dist/testing.js CHANGED
@@ -161,14 +161,12 @@ export async function exerciseAdapter(adapter, context, options = {}) {
161
161
  const breaks = new BreakStream();
162
162
  let seeded = false;
163
163
  const storedSecrets = new Map();
164
- const authentication = await adapter.createAuthenticationSession({
165
- ...context,
166
- secrets: {
167
- get: async (key) => storedSecrets.get(key),
168
- set: async (key, value) => { storedSecrets.set(key, value); },
169
- delete: async (key) => { storedSecrets.delete(key); },
170
- },
171
- });
164
+ const authenticationSecrets = {
165
+ get: async (key) => storedSecrets.get(key),
166
+ set: async (key, value) => { storedSecrets.set(key, value); },
167
+ delete: async (key) => { storedSecrets.delete(key); },
168
+ };
169
+ const authentication = await adapter.createAuthenticationSession({ ...context, secrets: authenticationSecrets });
172
170
  let connection;
173
171
  let unsubscribe;
174
172
  let unsubscribeAuthentication;
@@ -376,6 +374,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
376
374
  violations.push(...await driveOneCall({
377
375
  connection: live, manifest: adapter.manifest, channel: adapter.manifest.channel, softphone, snapshot, events, waiters, stream, localAudio,
378
376
  timeoutMs: options.driveTimeoutMs ?? 5000,
377
+ context, secrets: authenticationSecrets, reader, rebuild: options.rebuild,
379
378
  }));
380
379
  }
381
380
  }
@@ -956,6 +955,70 @@ async function driveOneCall(drive) {
956
955
  };
957
956
  drive.waiters.add(waiter);
958
957
  });
958
+ const recordSurvivesReload = async (at) => {
959
+ let again;
960
+ try {
961
+ again = drive.rebuild();
962
+ }
963
+ catch (error) {
964
+ refuse("drive.reload.rejected", "drive.reload", `building the adapter again threw: ${String(error)}`);
965
+ return;
966
+ }
967
+ let session;
968
+ let second;
969
+ try {
970
+ // The same provider, built again: a different manifest is a different adapter, and proves nothing about this one.
971
+ if (again.manifest.id !== drive.manifest.id) {
972
+ refuse("drive.reload.manifest", "drive.reload.manifest", `rebuild returned an adapter for ${String(again.manifest.id)}; the reload is of ${String(drive.manifest.id)}`);
973
+ return;
974
+ }
975
+ session = await again.createAuthenticationSession({ ...drive.context, secrets: drive.secrets });
976
+ second = await again.connect(drive.context);
977
+ const snapshot = await second.snapshot();
978
+ // The second adapter's snapshot is held to everything a first one is.
979
+ found.push(...validateSnapshot(snapshot, again.manifest, "drive.reload.snapshot", drive.reader()));
980
+ const carried = isRecord(snapshot) && Array.isArray(snapshot.tasks)
981
+ ? snapshot.tasks.find(task => isRecord(task) && task.id === taskId) : undefined;
982
+ if (carried === undefined) {
983
+ refuse("drive.reload.snapshot", "drive.reload.snapshot", `a second adapter built from the same login does not carry ${taskId} on its snapshot, and the task is still open`);
984
+ return;
985
+ }
986
+ const history = carried.handlingHistory;
987
+ const steps = isRecord(history) && Array.isArray(history.steps) ? history.steps : [];
988
+ // A record once read is not unread across a reload either: every entry the first adapter published is here.
989
+ const before = latestTask().handlingHistory;
990
+ const wasRead = isRecord(before) && Array.isArray(before.steps) ? before.steps.filter(isRecord) : [];
991
+ const lost = wasRead.filter(entry => !steps.some(now => isRecord(now) && now.step === entry.step && now.at === entry.at))
992
+ .map(entry => `${String(entry.step)}@${String(entry.at)}`);
993
+ if (lost.length > 0) {
994
+ refuse("drive.reload.history", "drive.reload.history", `a second adapter built from the same login carries ${taskId} without ${lost.join(", ")}, which the first had published: a record once read is not unread`);
995
+ }
996
+ const leg = steps.find(entry => isRecord(entry) && entry.step === "muted" && entry.at === at);
997
+ if (leg === undefined) {
998
+ refuse("drive.reload.history", "drive.reload.history", `a second adapter built from the same login carries ${taskId} without the muted leg at ${at}: the record was composed in memory and died with the adapter; the login's store is where it lives`);
999
+ }
1000
+ else if (leg.mutedBy !== "host") {
1001
+ refuse("drive.reload.history", "drive.reload.history", `the reloaded record's muted leg at ${at} says mutedBy ${String(leg.mutedBy)}; the host reported host`);
1002
+ }
1003
+ }
1004
+ catch (error) {
1005
+ refuse("drive.reload.rejected", "drive.reload", `the second adapter rejected rather than answered: ${String(error)}`);
1006
+ }
1007
+ finally {
1008
+ try {
1009
+ await second?.disconnect();
1010
+ }
1011
+ catch (error) {
1012
+ refuse("drive.reload.rejected", "drive.reload", `the second adapter's disconnect threw: ${String(error)}`);
1013
+ }
1014
+ try {
1015
+ await session?.close();
1016
+ }
1017
+ catch (error) {
1018
+ refuse("drive.reload.rejected", "drive.reload", `the second adapter's session threw on close: ${String(error)}`);
1019
+ }
1020
+ }
1021
+ };
959
1022
  // The task, as first seen: on the snapshot, or on the first offer.
960
1023
  let cursor = 0;
961
1024
  let task = isRecord(drive.snapshot) && Array.isArray(drive.snapshot.tasks) ? drive.snapshot.tasks.find(isTask) : undefined;
@@ -1113,6 +1176,11 @@ async function driveOneCall(drive) {
1113
1176
  setMuted(false);
1114
1177
  await report({ seconds: Math.round((Date.now() - began) / 1000), ended: true });
1115
1178
  mutedLeg = { at, task: latestTask() };
1179
+ // 3c. A host reload destroys the adapter object and keeps the login's store. Built again from
1180
+ // the same login, a second adapter carries the task and the leg -- from its platform, or from
1181
+ // the store -- or it composed the record in memory and the record died with it.
1182
+ if (drive.rebuild !== undefined)
1183
+ await recordSurvivesReload(at);
1116
1184
  }
1117
1185
  // 4. Hold and resume, where offered.
1118
1186
  if (latestTask().phase === "in-progress" && offers("hold")) {
package/guide.md CHANGED
@@ -4590,7 +4590,17 @@ violation (`drive.command.failed`), and an event the provider owes and never sen
4590
4590
  the drive mutes it for one second and reports the leg through `recordStep`, begun and then ended,
4591
4591
  expecting each report `recorded` (`drive.recordStep.failed`, `.rejected`); and where the provider
4592
4592
  restates the task's record afterwards, the leg is in it or the hole is named
4593
- (`drive.recordStep.history`). Outside the handling phases with `hold` still declared -- in
4593
+ (`drive.recordStep.history`). Given `rebuild`, a way to build the adapter again as a host reload
4594
+ does, the drive then connects a second adapter for the same login with the same context and the
4595
+ same `store`, and expects its snapshot to carry the task with that leg and the host's word: a
4596
+ platform that holds the record hands it back, and an adapter that composed the record in memory
4597
+ has nothing and is named (`drive.reload.snapshot`, `drive.reload.history`, `.rejected`). The second
4598
+ adapter is held to what the first was: the same provider (`drive.reload.manifest`), a snapshot that
4599
+ stands as any snapshot must, and a record that lost none of the entries the first had published --
4600
+ a record once read is not unread across a reload either. A reconnect on the same adapter object
4601
+ would prove nothing, since an in-process adapter's memory survives it; only a second object
4602
+ separates kept-in-the-store from never-lost. Outside the
4603
+ handling phases with `hold` still declared -- in
4594
4604
  `confirmed`, where the provider publishes it, and in `completing` once the call has ended -- the
4595
4605
  drive sends `hold` past the validator that would hold it back, and expects `failed`: the adapter
4596
4606
  is the second gate on a control on the contact, and one that applies it where there is nothing to
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xema/omni-protocol",
3
- "version": "0.1.62",
3
+ "version": "0.1.63",
4
4
  "description": "The Omni protocol: the contract every provider adapter implements",
5
5
  "type": "module",
6
6
  "license": "MIT",