statewire 0.18.1 → 0.18.2

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.
@@ -94,16 +94,15 @@ type Admission = {
94
94
  export type ClientRec = {
95
95
  id: string;
96
96
  lastSeq: number;
97
- ctxSeq: number;
98
97
  touched: number;
99
- context: Record<string, unknown> | null;
100
- contextDocuments: StatewireDocuments.State;
101
98
  results: Map<number, { ts: number; cmd: StatewireCmd }>;
102
99
  inflight: Map<number, Admission>;
103
100
  /** The auth captured at the record's first attach; later attaches must match. */
104
101
  principal: unknown;
105
102
  };
106
103
 
104
+ // The ctx lane is attach scoped: a Conn is born with an empty context table
105
+ // and outlives its socket so an admitted command keeps reading its last value.
107
106
  export type Conn = {
108
107
  selection: StatewireProtocol.Selection;
109
108
  clientId: string;
@@ -114,6 +113,9 @@ export type Conn = {
114
113
  lease: string | null;
115
114
  /** The awaited `authorize` return captured at this attach's authorization. */
116
115
  auth: unknown;
116
+ ctxSeq: number;
117
+ context: Record<string, unknown> | null;
118
+ contextDocuments: StatewireDocuments.State;
117
119
  };
118
120
 
119
121
  type PendingConn = {
@@ -556,10 +558,7 @@ const notifyClientRecords = (h: SocketHostState) => {
556
558
  [...h.clients.values()].map((rec) => ({
557
559
  id: rec.id,
558
560
  lastSeq: admittedThrough(rec),
559
- ctxSeq: rec.ctxSeq,
560
561
  touched: rec.touched,
561
- context: rec.context,
562
- contextDocuments: [...rec.contextDocuments.documents],
563
562
  results: [...rec.results].map(([seq, entry]) => ({
564
563
  seq,
565
564
  ts: entry.ts,
@@ -636,12 +635,12 @@ const settleWire = (
636
635
  const callerOf = (
637
636
  h: SocketHostState,
638
637
  rec: ClientRec,
639
- auth: unknown,
638
+ conn: Conn,
640
639
  ): StatewireHost.Caller => ({
641
640
  clientId: rec.id,
642
- auth,
641
+ auth: conn.auth,
643
642
  get context() {
644
- return rec.context;
643
+ return conn.context;
645
644
  },
646
645
  get isConnected() {
647
646
  if (h.clients.get(rec.id) !== rec) return false;
@@ -668,9 +667,8 @@ const rejectStatement = (
668
667
  const admitCmd = (
669
668
  h: SocketHostState,
670
669
  rec: ClientRec,
670
+ conn: Conn,
671
671
  statement: CmdStatement,
672
- auth: unknown,
673
- selection: StatewireProtocol.Selection,
674
672
  ) => {
675
673
  const adm: Admission = {
676
674
  seq: statement.seq,
@@ -683,7 +681,7 @@ const admitCmd = (
683
681
  notifyClientRecords(h);
684
682
  if (
685
683
  statement.protocol !== h.protocol.name ||
686
- !selection.protocols.some((p) => p.name === statement.protocol)
684
+ !conn.selection.protocols.some((p) => p.name === statement.protocol)
687
685
  ) {
688
686
  settleCmd(h, rec, adm, {
689
687
  seq: adm.seq,
@@ -729,7 +727,7 @@ const admitCmd = (
729
727
  method: statement.method,
730
728
  params,
731
729
  clientId: rec.id,
732
- caller: callerOf(h, rec, auth),
730
+ caller: callerOf(h, rec, conn),
733
731
  onApplied: () => {
734
732
  if (adm.settled || adm.applied) return;
735
733
  adm.applied = true;
@@ -743,9 +741,10 @@ const admitCmd = (
743
741
  });
744
742
  };
745
743
 
746
- // One admission law, both lanes: absorbed statements answer from the record,
747
- // then the new suffix is admitted whole or the frame is refused whole via a
748
- // {syn} stating both watermarks, nothing consumed. A gap is never a close.
744
+ // One admission law, both lanes: absorbed statements answer from the record
745
+ // (cmd) or the attach (ctx), then the new suffix is admitted whole or the
746
+ // frame is refused whole via a {syn} stating both watermarks, nothing
747
+ // consumed. A gap is never a close.
749
748
  export type FrameAdmission =
750
749
  | { type: "ok" }
751
750
  | {
@@ -757,29 +756,25 @@ export type FrameAdmission =
757
756
 
758
757
  export const admitFrame = (
759
758
  h: SocketHostState,
760
- rec: ClientRec,
759
+ conn: Conn,
761
760
  frame: ParsedFrame,
762
- auth: unknown,
763
- selection: StatewireProtocol.Selection = {
764
- version: h.wireVersion.version,
765
- protocols: [{ name: h.protocol.name, version: h.protocol.version }],
766
- },
767
761
  ): FrameAdmission => {
762
+ const rec = h.clients.get(conn.clientId)!;
768
763
  rec.touched = Date.now();
769
764
  const through = admittedThrough(rec);
770
765
  for (const statement of frame.cmd) {
771
766
  if (statement.seq <= through) reemit(h, rec, statement.seq);
772
767
  }
773
- const ctxNew = frame.ctx.filter((s) => s.seq > rec.ctxSeq);
768
+ const ctxNew = frame.ctx.filter((s) => s.seq > conn.ctxSeq);
774
769
  const cmdNew = frame.cmd.filter((s) => s.seq > through);
775
770
  if (
776
- (ctxNew.length > 0 && ctxNew[0]!.seq !== rec.ctxSeq + 1) ||
771
+ (ctxNew.length > 0 && ctxNew[0]!.seq !== conn.ctxSeq + 1) ||
777
772
  (cmdNew.length > 0 && cmdNew[0]!.seq !== through + 1)
778
773
  ) {
779
774
  notifyClientRecords(h);
780
775
  return {
781
776
  type: "refused",
782
- syn: { syn: { seq: through, ctxSeq: rec.ctxSeq } },
777
+ syn: { syn: { seq: through, ctxSeq: conn.ctxSeq } },
783
778
  };
784
779
  }
785
780
  if (cmdNew.length > 0) {
@@ -796,7 +791,7 @@ export const admitFrame = (
796
791
  syn: {
797
792
  syn: {
798
793
  seq: through,
799
- ctxSeq: rec.ctxSeq,
794
+ ctxSeq: conn.ctxSeq,
800
795
  retry: COMMAND_CAPACITY_RETRY_MS,
801
796
  },
802
797
  },
@@ -812,17 +807,17 @@ export const admitFrame = (
812
807
  );
813
808
  return {
814
809
  type: "refused",
815
- syn: { syn: { seq: through, ctxSeq: rec.ctxSeq, retry } },
810
+ syn: { syn: { seq: through, ctxSeq: conn.ctxSeq, retry } },
816
811
  };
817
812
  }
818
813
  }
819
- let contextDocuments = rec.contextDocuments;
814
+ let contextDocuments = conn.contextDocuments;
820
815
  try {
821
816
  for (const statement of ctxNew)
822
817
  contextDocuments = applyDocumentOps(
823
818
  contextDocuments,
824
819
  statement.ops,
825
- selection.protocols,
820
+ conn.selection.protocols,
826
821
  true,
827
822
  );
828
823
  if (
@@ -833,11 +828,11 @@ export const admitFrame = (
833
828
  } catch {
834
829
  return violation("invalid-context");
835
830
  }
836
- rec.contextDocuments = contextDocuments;
837
- rec.context = (mainDocument(contextDocuments, h.protocol.name)?.value ??
831
+ conn.contextDocuments = contextDocuments;
832
+ conn.context = (mainDocument(contextDocuments, h.protocol.name)?.value ??
838
833
  null) as Record<string, unknown> | null;
839
- if (ctxNew.length) rec.ctxSeq = ctxNew.at(-1)!.seq;
840
- for (const statement of cmdNew) admitCmd(h, rec, statement, auth, selection);
834
+ if (ctxNew.length) conn.ctxSeq = ctxNew.at(-1)!.seq;
835
+ for (const statement of cmdNew) admitCmd(h, rec, conn, statement);
841
836
  if (cmdNew.length > 0) {
842
837
  // Memory host: admitted means retained — dur rides the admitting flush.
843
838
  queueCmd(h, rec.id, { seq: cmdNew.at(-1)!.seq, dur: true });
@@ -885,10 +880,9 @@ const message = (h: SocketHostState, conn: Conn, data: string) => {
885
880
  return;
886
881
  }
887
882
  if (frame.ctx.length === 0 && frame.cmd.length === 0) return;
888
- const rec = h.clients.get(conn.clientId)!;
889
883
  let admission: ReturnType<typeof admitFrame>;
890
884
  try {
891
- admission = admitFrame(h, rec, frame, conn.auth, conn.selection);
885
+ admission = admitFrame(h, conn, frame);
892
886
  } catch (error) {
893
887
  const reason = violationReason(error);
894
888
  if (reason === null) throw error;
@@ -1203,7 +1197,6 @@ const gcClients = (h: SocketHostState) => {
1203
1197
  };
1204
1198
 
1205
1199
  export type Registration = {
1206
- rec: ClientRec;
1207
1200
  conn: Conn;
1208
1201
  connection: StatewireHostInternal.SocketConnection;
1209
1202
  };
@@ -1265,10 +1258,7 @@ export const register = (
1265
1258
  const created: ClientRec = {
1266
1259
  id: clientId,
1267
1260
  lastSeq: 0,
1268
- ctxSeq: 0,
1269
1261
  touched: Date.now(),
1270
- context: null,
1271
- contextDocuments: { mode: "mounted", documents: new Map() },
1272
1262
  results: new Map(),
1273
1263
  inflight: new Map(),
1274
1264
  principal: auth,
@@ -1286,6 +1276,9 @@ export const register = (
1286
1276
  lastSentAt: 0,
1287
1277
  lease: null,
1288
1278
  auth,
1279
+ ctxSeq: 0,
1280
+ context: null,
1281
+ contextDocuments: { mode: "mounted", documents: new Map() },
1289
1282
  };
1290
1283
  h.conns.add(conn);
1291
1284
  gcClients(h);
@@ -1305,7 +1298,7 @@ export const register = (
1305
1298
  syn: {
1306
1299
  ...selection,
1307
1300
  seq: admittedThrough(rec),
1308
- ctxSeq: rec.ctxSeq,
1301
+ ctxSeq: 0,
1309
1302
  ...(h.idleWire && { idle: true }),
1310
1303
  },
1311
1304
  };
@@ -1317,7 +1310,7 @@ export const register = (
1317
1310
  message: (data) => message(h, conn, data),
1318
1311
  close: () => closeConn(h, conn),
1319
1312
  };
1320
- return { rec, conn, connection };
1313
+ return { conn, connection };
1321
1314
  };
1322
1315
 
1323
1316
  const connect = (
@@ -1367,15 +1360,14 @@ const restoreClientRecords = (
1367
1360
  records.map((record) => [
1368
1361
  record.id,
1369
1362
  {
1370
- ...record,
1371
- contextDocuments: {
1372
- mode: "mounted",
1373
- documents: new Map(record.contextDocuments ?? []),
1374
- },
1363
+ id: record.id,
1364
+ lastSeq: record.lastSeq,
1365
+ touched: record.touched,
1375
1366
  results: new Map(
1376
1367
  record.results.map(({ seq, ts, cmd }) => [seq, { ts, cmd }]),
1377
1368
  ),
1378
1369
  inflight: new Map(),
1370
+ principal: record.principal,
1379
1371
  },
1380
1372
  ]),
1381
1373
  );
@@ -1,5 +1,4 @@
1
1
  import type { StatewireProtocol } from "./wire/protocol";
2
- import type { StatewireDocuments } from "./wire/documents";
3
2
  import type { StatewireHost } from "./host";
4
3
  import type { StatewireCmd } from "./client/connection";
5
4
 
@@ -49,10 +48,7 @@ export namespace StatewireHostInternal {
49
48
  export type ClientRecord = {
50
49
  id: string;
51
50
  lastSeq: number;
52
- ctxSeq: number;
53
51
  touched: number;
54
- context: Record<string, unknown> | null;
55
- contextDocuments?: readonly [number, StatewireDocuments.Document][];
56
52
  results: readonly { seq: number; ts: number; cmd: StatewireCmd }[];
57
53
  principal: unknown;
58
54
  };
package/src/host.ts CHANGED
@@ -47,7 +47,7 @@ export namespace StatewireHost {
47
47
  * (in-process dispatch has none — reading it throws), `auth` is the value
48
48
  * `authorize` returned for the attach that admitted the command (undefined
49
49
  * for a void `authorize` and for in-process dispatch), `context` reads the
50
- * client's current ambient context live (null until the client sends one),
50
+ * admitting attach's context live (null until that attach sends one),
51
51
  * `isConnected` reports whether the client has an active stream attachment.
52
52
  */
53
53
  export type Caller = {
package/src/statewire.ts CHANGED
@@ -432,17 +432,10 @@ export namespace StatewireClient {
432
432
  meta?: unknown;
433
433
  };
434
434
 
435
- /** The persisted session: the lane identity, the lane clocks, and the commands the server has not marked durable. */
435
+ /** The persisted session: the lane identity, the cmd clock, and the commands the server has not marked durable. */
436
436
  export type SessionRecord = {
437
437
  clientId: string;
438
438
  nextSeq: number;
439
- ctxNextSeq: number;
440
- context?: {
441
- ops: unknown[];
442
- pending: { seq: number; ops: unknown[] }[];
443
- /** Recover the original wire's unpersisted single-document context after the next hello. */
444
- recover?: "version-1";
445
- };
446
439
  commands: readonly SessionCommand[];
447
440
  /** Terminal verdicts parked for the sharer that minted the seq (shared storage only). */
448
441
  verdicts?: readonly SessionVerdict[];
package/src/storage.ts CHANGED
@@ -4,7 +4,6 @@ import { resource } from "@assistant-ui/tap";
4
4
  import type { StatewireClient } from "./statewire";
5
5
  import type { StatewireProtocol } from "./wire/protocol";
6
6
  import { negotiationHeaders, protocolToken } from "./wire/protocol";
7
- import { applyDocumentOps } from "./wire/documents";
8
7
 
9
8
  const isRecord = (value: unknown): value is Record<string, unknown> =>
10
9
  typeof value === "object" && value !== null && !Array.isArray(value);
@@ -92,9 +91,7 @@ const isSessionRecord = (
92
91
  isRecord(value) &&
93
92
  typeof value["clientId"] === "string" &&
94
93
  value["clientId"].length > 0 &&
95
- isContext(value["context"]) &&
96
94
  isPositiveSafeInteger(value["nextSeq"]) &&
97
- isPositiveSafeInteger(value["ctxNextSeq"]) &&
98
95
  Array.isArray(value["commands"]) &&
99
96
  value["commands"].every(isSessionCommand) &&
100
97
  (value["verdicts"] === undefined ||
@@ -123,74 +120,6 @@ const isProducer = (value: unknown) => {
123
120
  }
124
121
  };
125
122
 
126
- const isContextOperation = (value: unknown) => {
127
- if (!isRecord(value)) return false;
128
- if (
129
- value["doc"] !== undefined &&
130
- (typeof value["doc"] !== "number" ||
131
- !Number.isSafeInteger(value["doc"]) ||
132
- value["doc"] < 0)
133
- )
134
- return false;
135
- if (value["op"] === "mount") {
136
- try {
137
- applyDocumentOps(
138
- { mode: "mounted", documents: new Map() },
139
- [value],
140
- undefined,
141
- true,
142
- );
143
- return true;
144
- } catch {
145
- return false;
146
- }
147
- }
148
- if (value["op"] === "unmount") return true;
149
- if (!["add", "replace", "remove"].includes(value["op"] as string))
150
- return false;
151
- return (
152
- Array.isArray(value["path"]) &&
153
- value["path"].every(
154
- (part) =>
155
- typeof part === "string" ||
156
- (typeof part === "number" && Number.isSafeInteger(part) && part >= 0),
157
- ) &&
158
- (value["op"] === "remove" || "value" in value)
159
- );
160
- };
161
-
162
- const isContext = (value: unknown) => {
163
- if (value === undefined) return true;
164
- if (
165
- !isRecord(value) ||
166
- !Array.isArray(value["ops"]) ||
167
- !Array.isArray(value["pending"])
168
- )
169
- return false;
170
- if (value["recover"] !== undefined && value["recover"] !== "version-1")
171
- return false;
172
- try {
173
- const documents = applyDocumentOps(
174
- { mode: "mounted", documents: new Map() },
175
- value["ops"],
176
- undefined,
177
- true,
178
- );
179
- for (const entry of value["pending"]) {
180
- if (
181
- !isRecord(entry) ||
182
- !isPositiveSafeInteger(entry["seq"]) ||
183
- !Array.isArray(entry["ops"])
184
- )
185
- return false;
186
- if (!entry["ops"].every(isContextOperation)) return false;
187
- }
188
- return documents.mode === "mounted";
189
- } catch {
190
- return false;
191
- }
192
- };
193
-
194
123
  const decode = (
195
124
  raw: string | null,
196
125
  legacyProtocol: KeyValueSessionStorage.Options["legacyProtocol"],
@@ -202,14 +131,18 @@ const decode = (
202
131
  const {
203
132
  statewireStorageVersion: format,
204
133
  savedAt: _savedAt,
134
+ ctxNextSeq: _ctxNextSeq,
135
+ context: _context,
205
136
  ...value
206
137
  } = parsed as StatewireClient.SessionRecord & {
207
138
  statewireStorageVersion?: unknown;
208
139
  savedAt?: unknown;
140
+ ctxNextSeq?: unknown;
141
+ context?: unknown;
209
142
  };
210
143
  if (!isSessionRecord(value))
211
144
  throw new Error("statewire: invalid stored session record");
212
- if (format !== undefined && format !== 1)
145
+ if (format !== undefined && format !== 1 && format !== 2)
213
146
  throw new Error("statewire: unsupported session storage version");
214
147
  if (legacyProtocol === undefined || format !== undefined) return value;
215
148
  const produced = {
@@ -218,22 +151,6 @@ const decode = (
218
151
  };
219
152
  return {
220
153
  ...value,
221
- ...(value.context === undefined && value.ctxNextSeq > 1
222
- ? {
223
- context: {
224
- ops: [
225
- {
226
- op: "mount",
227
- protocol: legacyProtocol.name,
228
- main: true,
229
- value: {},
230
- },
231
- ],
232
- pending: [],
233
- recover: "version-1",
234
- },
235
- }
236
- : {}),
237
154
  commands: value.commands.map((command) =>
238
155
  command.produced === undefined
239
156
  ? {
@@ -279,12 +196,11 @@ const sweep = async (
279
196
  const format = isRecord(value)
280
197
  ? value["statewireStorageVersion"]
281
198
  : undefined;
282
- if (format !== undefined && format !== 1) return undefined;
199
+ if (format !== undefined && format !== 1 && format !== 2) return undefined;
283
200
  if (
284
201
  !isSessionRecord(value) ||
285
202
  value.commands.length ||
286
- value.failed?.length ||
287
- value.context?.pending.length
203
+ value.failed?.length
288
204
  )
289
205
  return undefined;
290
206
  const savedAt =
@@ -502,7 +418,7 @@ const useKeyValueSessionStorage = (
502
418
  throw new Error("statewire: invalid session record");
503
419
  const encoded = JSON.stringify({
504
420
  ...record,
505
- statewireStorageVersion: 1,
421
+ statewireStorageVersion: 2,
506
422
  savedAt: Date.now(),
507
423
  });
508
424
  state.record = record;
@@ -32,7 +32,7 @@ export type StatewireFrame = {
32
32
  export type StatewireRemoteProps = {
33
33
  beforeAttach?: () => void | Promise<void>;
34
34
  clientId: string;
35
- /** Lanes to ride the attach frame (no-lease supersession only); records the included seqs. */
35
+ /** Lanes to ride the attach frame: the context restatement, plus cmds on no-lease supersession; records the included seqs. */
36
36
  takeAttachLanes: () => StatewireFrame | null;
37
37
  onHello: (args: { ops: unknown[]; syn: StatewireSyn }) => void;
38
38
  onOps: (args: { ops: unknown[]; cmds: StatewireCmd[] }) => void;
@@ -82,6 +82,7 @@ export const createMockTransport = <TState>(
82
82
  clientId = id;
83
83
  if (!clients.has(id)) clients.set(id, { seq: 0, ctxSeq: 0 });
84
84
  const rec = clients.get(id)!;
85
+ rec.ctxSeq = 0;
85
86
  const frame = JSON.parse(String(init?.body ?? "{}")) as StatewireFrame;
86
87
  admit(id, frame);
87
88
  return openSSEStream(
@@ -93,7 +94,7 @@ export const createMockTransport = <TState>(
93
94
  ops: [{ op: "mount", protocol, main: true, value: state }],
94
95
  syn: {
95
96
  seq: rec.seq,
96
- ctxSeq: rec.ctxSeq,
97
+ ctxSeq: 0,
97
98
  version: "2026-09-07",
98
99
  protocols: [{ name: protocol, version: "2026-09-07" }],
99
100
  },