statewire 0.16.0 → 0.17.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.
Files changed (83) hide show
  1. package/README.md +12 -4
  2. package/dist/client/connection.d.ts +5 -1
  3. package/dist/client/connection.d.ts.map +1 -1
  4. package/dist/client/connection.js +19 -4
  5. package/dist/client/connection.js.map +1 -1
  6. package/dist/client/core.d.ts.map +1 -1
  7. package/dist/client/core.js +2 -0
  8. package/dist/client/core.js.map +1 -1
  9. package/dist/client/session.d.ts +15 -4
  10. package/dist/client/session.d.ts.map +1 -1
  11. package/dist/client/session.js +232 -50
  12. package/dist/client/session.js.map +1 -1
  13. package/dist/client/sessionStore.d.ts +5 -1
  14. package/dist/client/sessionStore.d.ts.map +1 -1
  15. package/dist/client/sessionStore.js +3 -1
  16. package/dist/client/sessionStore.js.map +1 -1
  17. package/dist/host/host.d.ts +1 -0
  18. package/dist/host/host.d.ts.map +1 -1
  19. package/dist/host/host.js.map +1 -1
  20. package/dist/host/http.d.ts.map +1 -1
  21. package/dist/host/http.js +27 -9
  22. package/dist/host/http.js.map +1 -1
  23. package/dist/host/socket.d.ts +10 -3
  24. package/dist/host/socket.d.ts.map +1 -1
  25. package/dist/host/socket.js +103 -39
  26. package/dist/host/socket.js.map +1 -1
  27. package/dist/host-internal.d.ts +9 -1
  28. package/dist/host-internal.d.ts.map +1 -1
  29. package/dist/host-internal.js +1 -1
  30. package/dist/index.d.ts +3 -1
  31. package/dist/statewire.d.ts +33 -2
  32. package/dist/statewire.d.ts.map +1 -1
  33. package/dist/statewire.js +34 -3
  34. package/dist/statewire.js.map +1 -1
  35. package/dist/transports/fixtures.js +1 -0
  36. package/dist/transports/fixtures.js.map +1 -1
  37. package/dist/transports/remote.d.ts.map +1 -1
  38. package/dist/transports/remote.js +15 -8
  39. package/dist/transports/remote.js.map +1 -1
  40. package/dist/transports/sse.d.ts.map +1 -1
  41. package/dist/transports/sse.js +8 -8
  42. package/dist/transports/sse.js.map +1 -1
  43. package/dist/transports/testing.d.ts +1 -1
  44. package/dist/transports/testing.d.ts.map +1 -1
  45. package/dist/transports/testing.js +12 -4
  46. package/dist/transports/testing.js.map +1 -1
  47. package/dist/transports/ws.js +2 -2
  48. package/dist/transports/ws.js.map +1 -1
  49. package/dist/types.d.ts +7 -0
  50. package/dist/types.d.ts.map +1 -1
  51. package/dist/types.js.map +1 -1
  52. package/dist/wire/documents.d.ts +25 -2
  53. package/dist/wire/documents.d.ts.map +1 -1
  54. package/dist/wire/documents.js +27 -7
  55. package/dist/wire/documents.js.map +1 -1
  56. package/dist/wire/protocol.d.ts +55 -0
  57. package/dist/wire/protocol.d.ts.map +1 -0
  58. package/dist/wire/protocol.js +221 -0
  59. package/dist/wire/protocol.js.map +1 -0
  60. package/dist/wire/version.d.ts +1 -3
  61. package/dist/wire/version.d.ts.map +1 -1
  62. package/dist/wire/version.js +1 -3
  63. package/dist/wire/version.js.map +1 -1
  64. package/package.json +1 -1
  65. package/src/client/connection.ts +43 -13
  66. package/src/client/core.ts +6 -0
  67. package/src/client/session.ts +338 -39
  68. package/src/client/sessionStore.ts +8 -0
  69. package/src/host/host.ts +1 -0
  70. package/src/host/http.ts +36 -7
  71. package/src/host/socket.ts +141 -50
  72. package/src/host-internal.ts +12 -1
  73. package/src/index.ts +3 -0
  74. package/src/statewire.ts +109 -6
  75. package/src/transports/fixtures.ts +1 -1
  76. package/src/transports/remote.ts +16 -7
  77. package/src/transports/sse.ts +18 -8
  78. package/src/transports/testing.ts +10 -2
  79. package/src/transports/ws.ts +1 -1
  80. package/src/types.ts +11 -0
  81. package/src/wire/documents.ts +126 -8
  82. package/src/wire/protocol.ts +327 -0
  83. package/src/wire/version.ts +0 -2
package/src/statewire.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import type { StatewireDocuments } from "./wire/documents";
2
+ import type { StatewireProtocol } from "./wire/protocol";
1
3
  import type { StatewireHost } from "./host";
2
4
  import { useMemo, useRef, useState, useSyncExternalStore } from "react";
3
5
  import {
@@ -13,18 +15,32 @@ import type { ResourceElement } from "@assistant-ui/tap";
13
15
  import { CONNECTED_CONNECTION, useStatewireCore } from "./client/core";
14
16
  import type { Statewire } from "./types";
15
17
  import type { TERMINAL } from "./client/connection";
16
- import { SessionStorageContext } from "./client/sessionStore";
18
+ import {
19
+ DocumentStorageContext,
20
+ ProtocolContext,
21
+ SessionStorageContext,
22
+ } from "./client/sessionStore";
17
23
 
18
24
  const useSessionTransport = ({
19
25
  transport,
20
26
  session,
27
+ documents,
28
+ protocol,
21
29
  }: {
22
30
  transport: ResourceElement<Statewire<any, any>>;
23
31
  session: StatewireClient.SessionStorage | undefined;
32
+ documents: StatewireDocuments.Storage | undefined;
33
+ protocol: StatewireProtocol.Options;
24
34
  }): Statewire<unknown> =>
25
35
  useContextProvider(SessionStorageContext, session, () =>
26
36
  // oxlint-disable-next-line react-hooks/rules-of-hooks
27
- useResource(transport),
37
+ useContextProvider(DocumentStorageContext, documents, () =>
38
+ // oxlint-disable-next-line react-hooks/rules-of-hooks
39
+ useContextProvider(ProtocolContext, protocol, () =>
40
+ // oxlint-disable-next-line react-hooks/rules-of-hooks
41
+ useResource(transport),
42
+ ),
43
+ ),
28
44
  );
29
45
 
30
46
  // The transport owns the session; it reads the storage off the context this
@@ -45,8 +61,30 @@ export const useStatewire = <
45
61
  ? [withKey("session", options.storage.session)]
46
62
  : [],
47
63
  )[0];
64
+ const documents = useResources(
65
+ options.storage?.documents === undefined
66
+ ? []
67
+ : [withKey("documents", options.storage.documents)],
68
+ )[0];
69
+ const protocol: StatewireProtocol.Options = {
70
+ ...(options.protocol !== undefined && { protocol: options.protocol }),
71
+ ...(options.wireVersion !== undefined && {
72
+ wireVersion: options.wireVersion,
73
+ }),
74
+ ...(options.applicationProtocols !== undefined && {
75
+ applicationProtocols: options.applicationProtocols,
76
+ }),
77
+ ...(options.keyPrefixes !== undefined && {
78
+ keyPrefixes: options.keyPrefixes,
79
+ }),
80
+ };
48
81
  const value = useResource(
49
- SessionTransport({ transport: options.transport, session }),
82
+ SessionTransport({
83
+ transport: options.transport,
84
+ session,
85
+ documents,
86
+ protocol,
87
+ }),
50
88
  );
51
89
  const core = useStatewireCore(() => latest.current, value);
52
90
  const [subscribe] = useState(
@@ -76,6 +114,26 @@ export const useStatewire = <
76
114
  };
77
115
  return {
78
116
  state,
117
+ documents: value.documents,
118
+ selection: value.selection,
119
+ invoke: (
120
+ protocol: string,
121
+ method: string,
122
+ params: readonly unknown[],
123
+ ) => {
124
+ if (!value.invoke)
125
+ throw new Error(
126
+ "statewire: transport does not support protocol commands",
127
+ );
128
+ return value.invoke(protocol, method, params);
129
+ },
130
+ updateContext: (ops: readonly unknown[]) => {
131
+ if (!value.updateContext)
132
+ throw new Error(
133
+ "statewire: transport does not support context documents",
134
+ );
135
+ value.updateContext(ops);
136
+ },
79
137
  getState: core.getState as () => TState,
80
138
  connection,
81
139
  commands: core.commands as Statewire.CommandsProxy<TCommands>,
@@ -129,6 +187,23 @@ export class StatewireClient<
129
187
  return this.root.getValue().commands;
130
188
  }
131
189
 
190
+ get documents(): StatewireDocuments.Source | undefined {
191
+ return this.root.getValue().documents;
192
+ }
193
+ get selection(): StatewireProtocol.Selection | undefined {
194
+ return this.root.getValue().selection;
195
+ }
196
+ invoke(
197
+ protocol: string,
198
+ method: string,
199
+ params: readonly unknown[],
200
+ ): Promise<unknown> {
201
+ return this.root.getValue().invoke(protocol, method, params);
202
+ }
203
+ updateContext(ops: readonly unknown[]): void {
204
+ this.root.getValue().updateContext(ops);
205
+ }
206
+
132
207
  get isBusy(): boolean {
133
208
  return this.root.getValue().isBusy;
134
209
  }
@@ -165,10 +240,17 @@ export namespace StatewireClient {
165
240
  TState,
166
241
  TCommands extends Statewire.Commands = Statewire.Commands,
167
242
  > = {
243
+ protocol?: string;
244
+ keyPrefixes?: Readonly<Record<string, readonly string[]>>;
245
+ wireVersion?: StatewireProtocol.Range;
246
+ applicationProtocols?: readonly StatewireProtocol.Offer[] | "*";
168
247
  transport: ResourceElement<Statewire<TState, TCommands>>;
169
248
  /** Durable session: the mounted storage holds this client's lane identity and its un-durable commands, which resend on the next attach. */
170
249
  storage?:
171
- | { session?: ResourceElement<SessionStorage> | undefined }
250
+ | {
251
+ session?: ResourceElement<SessionStorage> | undefined;
252
+ documents?: ResourceElement<StatewireDocuments.Storage> | undefined;
253
+ }
172
254
  | undefined;
173
255
  /** Served as the canonical state until the attach snapshot; own commands fold over it. */
174
256
  initialState?: TState;
@@ -197,6 +279,14 @@ export namespace StatewireClient {
197
279
  > = {
198
280
  /** The statewire's state type — remote statewires include `undefined` until the first attach snapshot applies. */
199
281
  state: TState;
282
+ documents: StatewireDocuments.Source | undefined;
283
+ selection: StatewireProtocol.Selection | undefined;
284
+ invoke: (
285
+ protocol: string,
286
+ method: string,
287
+ params: readonly unknown[],
288
+ ) => Promise<unknown>;
289
+ updateContext: (ops: readonly unknown[]) => void;
200
290
  /** Identity-stable across renders; reads the current canonical state without subscribing the caller. */
201
291
  getState: () => TState;
202
292
  connection: Connection;
@@ -318,6 +408,8 @@ export namespace StatewireClient {
318
408
 
319
409
  /** A non-accepted send retained for the user: the wire statement, its failure, and its original clocks. */
320
410
  export type FailedCommand = {
411
+ readonly protocol: string;
412
+ readonly produced?: SessionCommand["produced"];
321
413
  readonly command: string;
322
414
  readonly params: readonly unknown[];
323
415
  readonly meta?: unknown;
@@ -329,6 +421,11 @@ export namespace StatewireClient {
329
421
  /** A command as persisted while un-durable: its lane seq plus the wire statement. */
330
422
  export type SessionCommand = {
331
423
  seq: number;
424
+ protocol: string;
425
+ produced?: {
426
+ wire: StatewireProtocol.Range;
427
+ protocols: readonly StatewireProtocol.Offer[] | "*";
428
+ };
332
429
  method: string;
333
430
  params: unknown[];
334
431
  meta?: unknown;
@@ -339,6 +436,7 @@ export namespace StatewireClient {
339
436
  clientId: string;
340
437
  nextSeq: number;
341
438
  ctxNextSeq: number;
439
+ context?: { ops: unknown[]; pending: { seq: number; ops: unknown[] }[] };
342
440
  commands: readonly SessionCommand[];
343
441
  /** Terminal verdicts parked for the sharer that minted the seq (shared storage only). */
344
442
  verdicts?: readonly SessionVerdict[];
@@ -360,6 +458,11 @@ export namespace StatewireClient {
360
458
  export type SessionStorage = {
361
459
  load: () => SessionRecord | null;
362
460
  save: (record: SessionRecord) => void;
461
+ /** Upgrade incompatible stored commands; null discards the old record and starts a new client identity. */
462
+ migrate?: (
463
+ record: SessionRecord,
464
+ current: NonNullable<SessionCommand["produced"]>,
465
+ ) => SessionRecord | null;
363
466
  /** Serializes record mutations across sharers; presence marks the storage shared. */
364
467
  lock?: <T>(fn: () => T | Promise<T>) => Promise<T>;
365
468
  /** Notifies of record saves by other sharers; returns unsubscribe. Required alongside `lock`. */
@@ -373,7 +476,7 @@ export namespace StatewireClient {
373
476
  | HeadersInit
374
477
  | ((ctx: { staleAuth: boolean }) => HeadersInit | Promise<HeadersInit>);
375
478
 
376
- export type HttpOptions = {
479
+ export type HttpOptions = StatewireProtocol.Options & {
377
480
  sessionStore?: SessionStorage | undefined;
378
481
  url: string;
379
482
  /** The session does not exist on the server yet: the first attach waits for the first command send (or an explicit `reconnect()`). */
@@ -387,7 +490,7 @@ export namespace StatewireClient {
387
490
  onDone?: () => void;
388
491
  };
389
492
 
390
- export type WebsocketOptions = {
493
+ export type WebsocketOptions = StatewireProtocol.Options & {
391
494
  sessionStore?: SessionStorage | undefined;
392
495
  url: string;
393
496
  /** The session does not exist on the server yet: the first attach waits for the first command send (or an explicit `reconnect()`). */
@@ -24,7 +24,7 @@ export const commandFrame = (
24
24
  method: string,
25
25
  seq: number,
26
26
  params: unknown[] = [],
27
- ) => JSON.stringify({ cmd: [{ method, params, seq }] });
27
+ ) => JSON.stringify({ cmd: [{ protocol: "default", method, params, seq }] });
28
28
 
29
29
  export type SocketRecorder = {
30
30
  sent: string[];
@@ -1,6 +1,10 @@
1
- import { useEffect } from "react";
1
+ import { useContext, useEffect } from "react";
2
+ import { ProtocolContext } from "../client/sessionStore";
2
3
  import type { Statewire } from "../types";
3
4
  import {
5
+ documentSource,
6
+ invokeProtocol,
7
+ updateContext,
4
8
  discardFailed,
5
9
  failedCommands,
6
10
  retryFailed,
@@ -22,16 +26,21 @@ export const useStatewireRemote = <
22
26
  options: TOptions,
23
27
  useLink: (options: TOptions, ctx: StatewireRemoteProps) => RemoteLink,
24
28
  ): Statewire<any, any> => {
25
- const s = useStatewireSession({
26
- sessionStore: options.sessionStore,
27
- onSessionLost: options.onSessionLost,
28
- onDone: options.onDone,
29
- });
30
- const conn = useLink(options, s.ctx);
29
+ const inherited = useContext(ProtocolContext);
30
+ const configured = { ...inherited, ...options };
31
+ const s = useStatewireSession(configured);
32
+ const conn = useLink(configured, s.ctx);
31
33
  useEffect(() => setTransport(s, conn), [s, conn]);
32
34
 
33
35
  return {
34
36
  state: s.canonical,
37
+ documents: documentSource(s),
38
+ get selection() {
39
+ return s.selection;
40
+ },
41
+ invoke: (protocol, method, params) =>
42
+ invokeProtocol(s, protocol, method, params),
43
+ updateContext: (ops) => updateContext(s, ops),
35
44
  commands: s.commands,
36
45
  connection: conn.connection,
37
46
  source: s.source,
@@ -7,7 +7,6 @@ import {
7
7
  classify,
8
8
  handleEnvelope,
9
9
  resolveHeaders,
10
- unsupportedVersion,
11
10
  useLink,
12
11
  validateRefusalSyn,
13
12
  } from "../client/connection";
@@ -22,7 +21,7 @@ import type { Statewire } from "../types";
22
21
  import type { StatewireClient } from "../statewire";
23
22
  import type { StatewireHost } from "../host";
24
23
  import { useStatewireRemote } from "./remote";
25
- import { PROTOCOL_VERSION, VERSION_HEADER } from "../wire/version";
24
+ import { negotiationHeaders, parseNegotiation } from "../wire/protocol";
26
25
 
27
26
  /** A client frame's lanes: stamped statements, strictly consecutive per lane. */
28
27
  export type StatewireFrame = {
@@ -121,6 +120,10 @@ const attemptStream = async (
121
120
  ): Promise<AttemptOutcome> => {
122
121
  c.lease = null;
123
122
  const headers = await resolveHeaders(options.headers, c.authStale);
123
+ for (const [name, value] of Object.entries(negotiationHeaders(options))) {
124
+ if (!headers.has(name)) headers.set(name, value);
125
+ }
126
+ c.negotiation = parseNegotiation(headers);
124
127
  c.authStale = false;
125
128
  const lanes = flushTapSync(() => c.ctx.takeAttachLanes());
126
129
  const body = lanes === null ? undefined : JSON.stringify(lanes);
@@ -150,14 +153,21 @@ const attemptStream = async (
150
153
  } finally {
151
154
  clearTimeout(headerTimer);
152
155
  }
153
- if (!res.ok) return flushTapSync(() => settleFin(c, httpFin(res)));
156
+ if (!res.ok) {
157
+ const body = await res
158
+ .clone()
159
+ .json()
160
+ .catch(() => null);
161
+ return (
162
+ flushTapSync(() =>
163
+ body?.fin === undefined
164
+ ? settleFin(c, httpFin(res))
165
+ : handleEnvelope(c, body),
166
+ ) ?? "stop"
167
+ );
168
+ }
154
169
  if (!res.body)
155
170
  throw new Error("statewire: protocol error: stream response has no body");
156
- const version = res.headers.get(VERSION_HEADER)?.trim();
157
- if (version !== undefined && version !== PROTOCOL_VERSION) {
158
- res.body.cancel().catch(() => {});
159
- return unsupportedVersion(c, version);
160
- }
161
171
  c.lease = res.headers.get(LEASE_HEADER);
162
172
  const reader = res.body.getReader();
163
173
  try {
@@ -41,6 +41,7 @@ let mockCount = 0;
41
41
 
42
42
  export const createMockTransport = <TState>(
43
43
  initialState: TState,
44
+ protocol = "default",
44
45
  ): MockTransport<TState> => {
45
46
  let state: unknown = initialState;
46
47
  let clientId = "";
@@ -57,11 +58,13 @@ export const createMockTransport = <TState>(
57
58
  const admit = (id: string, frame: StatewireFrame) => {
58
59
  const rec = clients.get(id)!;
59
60
  for (const statement of frame.ctx ?? []) {
61
+ if (statement.seq <= rec.ctxSeq) continue;
60
62
  rec.ctxSeq = Math.max(rec.ctxSeq, statement.seq);
61
63
  const root = statement.ops[0] as { value?: unknown } | undefined;
62
64
  commands.push({ context: root?.value });
63
65
  }
64
66
  for (const statement of frame.cmd ?? []) {
67
+ if (statement.seq <= rec.seq) continue;
65
68
  rec.seq = Math.max(rec.seq, statement.seq);
66
69
  commands.push({ method: statement.method, params: statement.params });
67
70
  }
@@ -87,8 +90,13 @@ export const createMockTransport = <TState>(
87
90
  (handle) => {
88
91
  handle.send(
89
92
  sseFrame({
90
- ops: [{ op: "replace", path: [], value: state }],
91
- syn: { seq: rec.seq, ctxSeq: rec.ctxSeq },
93
+ ops: [{ op: "mount", protocol, main: true, value: state }],
94
+ syn: {
95
+ seq: rec.seq,
96
+ ctxSeq: rec.ctxSeq,
97
+ version: "2026-09-07",
98
+ protocols: [{ name: protocol, version: "2026-09-07" }],
99
+ },
92
100
  }),
93
101
  );
94
102
  },
@@ -72,7 +72,7 @@ const runSocket = (
72
72
  ws.onerror = () => {};
73
73
  ws.onopen = () => {
74
74
  const negotiated = ws.protocol.trim();
75
- if (negotiated !== "" && negotiated !== WS_SUBPROTOCOL) {
75
+ if (negotiated !== WS_SUBPROTOCOL) {
76
76
  outcome = unsupportedVersion(c, negotiated);
77
77
  ws.close();
78
78
  return;
package/src/types.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import type { StatewireDocuments } from "./wire/documents";
2
+ import type { StatewireProtocol } from "./wire/protocol";
1
3
  import type { StatewireClient } from "./statewire";
2
4
  import type { StatewirePath } from "./wire/ops";
3
5
 
@@ -16,6 +18,7 @@ export namespace Statewire {
16
18
 
17
19
  export type CommandUpdate<TCommands extends Commands = Commands> = {
18
20
  [K in keyof TCommands]: {
21
+ readonly protocol?: string;
19
22
  method: K;
20
23
  params: Parameters<TCommands[K]>;
21
24
  seq: number;
@@ -50,6 +53,14 @@ export interface Statewire<
50
53
  TCommands extends Statewire.Commands = Statewire.Commands,
51
54
  > {
52
55
  state: TState;
56
+ documents?: StatewireDocuments.Source;
57
+ selection?: StatewireProtocol.Selection | undefined;
58
+ invoke?: (
59
+ protocol: string,
60
+ method: string,
61
+ params: readonly unknown[],
62
+ ) => Promise<unknown>;
63
+ updateContext?: (ops: readonly unknown[]) => void;
53
64
  commands: Statewire.CommandsProxy<TCommands>;
54
65
  connection?: StatewireClient.Connection;
55
66
  source?: Statewire.StateSource;
@@ -1,9 +1,31 @@
1
1
  import { applyOps } from "./ops";
2
2
  import { reviveValue } from "./meta";
3
+ import { protocolToken } from "./protocol";
3
4
  import type { StatewirePath } from "./ops";
4
5
 
5
6
  export namespace StatewireDocuments {
6
- export type Document = { key: readonly string[] | null; value: unknown };
7
+ export type CacheEntry = { version: string; value: unknown };
8
+ export type Storage = {
9
+ namespace?: string;
10
+ load: (identity: readonly string[]) => CacheEntry | undefined;
11
+ save: (identity: readonly string[], entry: CacheEntry) => void;
12
+ subscribe?: (listener: () => void) => () => void;
13
+ };
14
+ export type Source = {
15
+ get: (
16
+ protocol: string,
17
+ key?: readonly string[],
18
+ prefix?: readonly string[],
19
+ ) => unknown;
20
+ list: () => ReadonlyMap<number, Document>;
21
+ subscribe: (listener: () => void) => () => void;
22
+ };
23
+ export type Document = {
24
+ key: readonly string[] | null;
25
+ protocol?: string;
26
+ main?: boolean;
27
+ value: unknown;
28
+ };
7
29
  export type State = {
8
30
  mode: "mounted" | "keyless" | null;
9
31
  documents: ReadonlyMap<number, Document>;
@@ -17,8 +39,12 @@ const invalid: (message: string) => never = (message) => {
17
39
  export const applyDocumentOps = (
18
40
  previous: StatewireDocuments.State,
19
41
  ops: readonly unknown[],
42
+ protocols?: readonly { name: string; version: string }[],
43
+ strict = false,
20
44
  ) => {
21
- let mode = previous.mode;
45
+ let mode =
46
+ previous.mode ??
47
+ (protocols !== undefined && ops.length === 0 ? "mounted" : null);
22
48
  const documents = new Map(previous.documents);
23
49
  const changedPaths: StatewirePath[] = [];
24
50
  for (const raw of ops) {
@@ -35,10 +61,13 @@ export const applyDocumentOps = (
35
61
  op.op as string,
36
62
  )
37
63
  ) {
64
+ if (strict) invalid("unknown context operation");
38
65
  const { doc: _doc, ...unknown } = op;
39
66
  applyOps(undefined, [unknown]);
40
67
  continue;
41
68
  }
69
+ if (protocols !== undefined && mode === null && op.op !== "mount")
70
+ invalid("negotiated state must start with mounts");
42
71
  if (mode === null) {
43
72
  if (op.op === "mount") mode = "mounted";
44
73
  else if (
@@ -64,23 +93,73 @@ export const applyDocumentOps = (
64
93
  if (op.op === "mount") {
65
94
  if (documents.has(doc)) invalid("document is already mounted");
66
95
  if (
67
- !Array.isArray(op.key) ||
68
- !op.key.every((key) => typeof key === "string") ||
96
+ (op.key !== undefined &&
97
+ (!Array.isArray(op.key) ||
98
+ !op.key.every((key) => typeof key === "string"))) ||
69
99
  !("value" in op)
70
100
  ) {
71
- invalid("mount requires a key array and value");
101
+ invalid("mount requires value and an optional key array");
72
102
  }
103
+ if (
104
+ op.protocol !== undefined &&
105
+ (typeof op.protocol !== "string" ||
106
+ !protocolToken.test(op.protocol) ||
107
+ op.protocol === "*")
108
+ )
109
+ invalid("invalid mount protocol");
110
+ if (
111
+ protocols !== undefined &&
112
+ !protocols.some((p) => p.name === op.protocol)
113
+ )
114
+ invalid("mount protocol was not negotiated");
115
+ if (op.main !== undefined && typeof op.main !== "boolean")
116
+ invalid("main must be boolean");
117
+ if (
118
+ op.main === true &&
119
+ [...documents.values()].some(
120
+ (d) => d.protocol === op.protocol && d.main,
121
+ )
122
+ )
123
+ invalid("protocol already has a main document");
124
+ if (
125
+ op.key !== undefined &&
126
+ [...documents.values()].some(
127
+ (d) =>
128
+ d.protocol === op.protocol &&
129
+ JSON.stringify(d.key) === JSON.stringify(op.key),
130
+ )
131
+ )
132
+ invalid("protocol document key is already mounted");
73
133
  documents.set(doc, {
74
- key: op.key,
134
+ key: op.key === undefined ? null : (op.key as string[]),
135
+ ...(op.protocol === undefined
136
+ ? {}
137
+ : { protocol: op.protocol as string }),
138
+ ...(op.main === undefined ? {} : { main: op.main as boolean }),
75
139
  value:
76
- op.meta === undefined ? op.value : reviveValue(op.value, op.meta),
140
+ op.meta === undefined
141
+ ? op.value
142
+ : reviveValue(op.value, op.meta, { rejectUnknown: strict }),
77
143
  });
78
144
  if (doc === 0) changedPaths.push([]);
79
145
  } else if (op.op === "unmount") {
146
+ const document = documents.get(doc);
147
+ if (
148
+ protocols !== undefined &&
149
+ document !== undefined &&
150
+ !protocols.some((p) => p.name === document.protocol)
151
+ )
152
+ invalid("document protocol was not negotiated");
80
153
  if (!documents.delete(doc)) invalid("document is not mounted");
81
154
  if (doc === 0) changedPaths.push([]);
82
155
  } else if (op.op === "add" || op.op === "replace" || op.op === "remove") {
83
156
  const document = documents.get(doc);
157
+ if (
158
+ protocols !== undefined &&
159
+ document !== undefined &&
160
+ !protocols.some((p) => p.name === document.protocol)
161
+ )
162
+ invalid("document protocol was not negotiated");
84
163
  if (!document) invalid("document is not mounted");
85
164
  if (
86
165
  !Array.isArray(op.path) ||
@@ -98,7 +177,9 @@ export const applyDocumentOps = (
98
177
  invalid("operation requires a value");
99
178
  const { doc: _doc, meta, ...mutation } = op;
100
179
  if (meta !== undefined)
101
- mutation.value = reviveValue(mutation.value, meta);
180
+ mutation.value = reviveValue(mutation.value, meta, {
181
+ rejectUnknown: strict,
182
+ });
102
183
  const applied = applyOps(document.value, [mutation]);
103
184
  documents.set(doc, { ...document, value: applied.state });
104
185
  if (doc === 0) changedPaths.push(...applied.changedPaths);
@@ -107,3 +188,40 @@ export const applyDocumentOps = (
107
188
  if (mode === null) invalid("hello missing snapshot");
108
189
  return { mode, documents, changedPaths };
109
190
  };
191
+
192
+ export const mainDocument = (
193
+ state: StatewireDocuments.State,
194
+ protocol: string,
195
+ ) =>
196
+ [...state.documents.values()].find(
197
+ (document) => document.protocol === protocol && document.main,
198
+ );
199
+
200
+ export const resolveDocumentKey = (
201
+ key: readonly string[],
202
+ prefix?: readonly string[],
203
+ ) =>
204
+ key[0] === "."
205
+ ? prefix === undefined
206
+ ? undefined
207
+ : [...prefix, ...key.slice(1)]
208
+ : key;
209
+
210
+ export const findDocument = (
211
+ state: StatewireDocuments.State,
212
+ protocol: string,
213
+ key?: readonly string[],
214
+ prefix?: readonly string[],
215
+ ) => {
216
+ if (key === undefined) return mainDocument(state, protocol);
217
+ const resolved = resolveDocumentKey(key, prefix);
218
+ return [...state.documents.values()].find(
219
+ (document) =>
220
+ document.protocol === protocol &&
221
+ document.key !== null &&
222
+ (JSON.stringify(document.key) === JSON.stringify(key) ||
223
+ (resolved !== undefined &&
224
+ JSON.stringify(resolveDocumentKey(document.key, prefix)) ===
225
+ JSON.stringify(resolved))),
226
+ );
227
+ };