statewire 0.17.0 → 0.17.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.
@@ -0,0 +1,185 @@
1
+ import { isUnsafeKey, walkStatePath } from "./util/statePath";
2
+
3
+ export namespace StatewireDocument {
4
+ export type Value =
5
+ | null
6
+ | boolean
7
+ | number
8
+ | string
9
+ | Value[]
10
+ | { [key: string]: Value };
11
+ export type MetaEntry = { path: (string | number)[]; type: "date" };
12
+ export type Encoded = { value: Value; meta?: MetaEntry[] };
13
+ }
14
+
15
+ const invalid = (message: string): never => {
16
+ throw new Error(`statewire: protocol error: invalid document: ${message}`);
17
+ };
18
+
19
+ const isObject = (value: unknown): value is Record<string, unknown> =>
20
+ typeof value === "object" &&
21
+ value !== null &&
22
+ (Object.getPrototypeOf(value) === Object.prototype ||
23
+ Object.getPrototypeOf(value) === null);
24
+
25
+ const copyValue = (
26
+ value: unknown,
27
+ dates: boolean,
28
+ meta: StatewireDocument.MetaEntry[],
29
+ path: (string | number)[] = [],
30
+ ancestors = new Set<object>(),
31
+ ): StatewireDocument.Value => {
32
+ if (value === null || typeof value === "boolean" || typeof value === "string")
33
+ return value;
34
+ if (typeof value === "number") {
35
+ if (
36
+ !Number.isFinite(value) ||
37
+ (Number.isInteger(value) && !Number.isSafeInteger(value))
38
+ )
39
+ invalid(
40
+ `number at ${JSON.stringify(path)} must be finite and within the safe integer range`,
41
+ );
42
+ return value;
43
+ }
44
+ if (dates && value instanceof Date) {
45
+ if (
46
+ !Number.isFinite(value.getTime()) ||
47
+ value.getUTCFullYear() < 1 ||
48
+ value.getUTCFullYear() > 9999
49
+ )
50
+ invalid(
51
+ `date at ${JSON.stringify(path)} must have a year from 0001 to 9999`,
52
+ );
53
+ meta.push({ path, type: "date" });
54
+ return value.toISOString();
55
+ }
56
+ if (!Array.isArray(value) && !isObject(value))
57
+ return invalid(`unsupported value at ${JSON.stringify(path)}`);
58
+ if (ancestors.has(value)) invalid(`cycle at ${JSON.stringify(path)}`);
59
+ ancestors.add(value);
60
+ const descriptors = Object.getOwnPropertyDescriptors(value);
61
+ for (const key of Reflect.ownKeys(descriptors)) {
62
+ if (Array.isArray(value) && key === "length") continue;
63
+ if (typeof key !== "string")
64
+ invalid(`symbol key at ${JSON.stringify(path)}`);
65
+ const descriptor = descriptors[key as string]!;
66
+ if (!("value" in descriptor) || !descriptor.enumerable)
67
+ invalid(
68
+ `property at ${JSON.stringify([...path, key])} must be enumerable data`,
69
+ );
70
+ if (isUnsafeKey(key as string, path.at(-1)))
71
+ invalid(`unsafe key at ${JSON.stringify([...path, key])}`);
72
+ if (
73
+ Array.isArray(value) &&
74
+ (!/^(0|[1-9]\d*)$/.test(key as string) || Number(key) >= value.length)
75
+ )
76
+ invalid(
77
+ `unsupported array property at ${JSON.stringify([...path, key])}`,
78
+ );
79
+ }
80
+ let result: StatewireDocument.Value;
81
+ if (Array.isArray(value)) {
82
+ result = Array.from({ length: value.length }, (_, index) => {
83
+ if (!Object.hasOwn(value, index))
84
+ invalid(`sparse array at ${JSON.stringify(path)}`);
85
+ return copyValue(
86
+ descriptors[index]!.value,
87
+ dates,
88
+ meta,
89
+ [...path, index],
90
+ ancestors,
91
+ );
92
+ });
93
+ } else {
94
+ result = Object.fromEntries(
95
+ Object.keys(descriptors).map((key) => [
96
+ key,
97
+ copyValue(
98
+ descriptors[key]!.value,
99
+ dates,
100
+ meta,
101
+ [...path, key],
102
+ ancestors,
103
+ ),
104
+ ]),
105
+ );
106
+ }
107
+ ancestors.delete(value);
108
+ return result;
109
+ };
110
+
111
+ /** Encode a document value as detached JSON data with date metadata. */
112
+ export const encodeDocument = (value: unknown): StatewireDocument.Encoded => {
113
+ const meta: StatewireDocument.MetaEntry[] = [];
114
+ const encoded = copyValue(value, true, meta);
115
+ return meta.length ? { value: encoded, meta } : { value: encoded };
116
+ };
117
+
118
+ /** Validate and decode a document into detached values with native dates. */
119
+ export const decodeDocument = (encoded: unknown): unknown => {
120
+ const document = copyValue(encoded, false, []);
121
+ if (
122
+ !isObject(document) ||
123
+ !Object.hasOwn(document, "value") ||
124
+ Object.keys(document).some((key) => key !== "value" && key !== "meta")
125
+ )
126
+ return invalid("expected an object with value and optional meta");
127
+ const value: unknown = document.value;
128
+ if (!Object.hasOwn(document, "meta")) return value;
129
+ if (!Array.isArray(document.meta)) return invalid("meta must be an array");
130
+ return reviveDocumentValue(value, document.meta, {
131
+ rejectUnknown: true,
132
+ canonical: true,
133
+ });
134
+ };
135
+
136
+ export const reviveDocumentValue = (
137
+ value: unknown,
138
+ meta: unknown,
139
+ { rejectUnknown = false, canonical = false } = {},
140
+ ): unknown => {
141
+ if (!Array.isArray(meta)) return invalid("meta must be an array");
142
+ const seen = new Set<string>();
143
+ for (const entry of meta) {
144
+ if (
145
+ !isObject(entry) ||
146
+ !Array.isArray(entry.path) ||
147
+ (canonical &&
148
+ Object.keys(entry).some((key) => key !== "path" && key !== "type")) ||
149
+ !entry.path.every(
150
+ (segment) =>
151
+ typeof segment === "string" ||
152
+ (typeof segment === "number" &&
153
+ Number.isSafeInteger(segment) &&
154
+ segment >= 0),
155
+ )
156
+ )
157
+ return invalid("meta entry requires a path array and type date");
158
+ if (entry.type !== "date") {
159
+ if (rejectUnknown) invalid(`unknown meta type: ${String(entry.type)}`);
160
+ continue;
161
+ }
162
+ const path = entry.path as (string | number)[];
163
+ const identity = JSON.stringify(path);
164
+ if (seen.has(identity)) invalid(`duplicate meta path ${identity}`);
165
+ seen.add(identity);
166
+ const walked = walkStatePath(value, path, "resolve");
167
+ if (!walked || typeof walked.leaf !== "string")
168
+ return invalid(`meta path ${identity} must resolve to a string`);
169
+ const date = new Date(walked.leaf);
170
+ if (
171
+ canonical
172
+ ? !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(walked.leaf) ||
173
+ !Number.isFinite(date.getTime()) ||
174
+ date.getUTCFullYear() < 1 ||
175
+ date.toISOString() !== walked.leaf
176
+ : !/(?:T| )\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})$/i.test(
177
+ walked.leaf,
178
+ ) || !Number.isFinite(date.getTime())
179
+ )
180
+ return invalid(`invalid date at meta path ${identity}`);
181
+ if (walked.parent === undefined) value = date;
182
+ else (walked.parent as Record<string | number, unknown>)[walked.key] = date;
183
+ }
184
+ return value;
185
+ };
@@ -1,3 +1,4 @@
1
+ import { encodeDocument } from "../document";
1
2
  import {
2
3
  DEFAULT_PROTOCOL,
3
4
  WIRE_VERSION,
@@ -15,7 +16,7 @@ import { StatewireSendError } from "../types";
15
16
  import type { Statewire } from "../types";
16
17
  import type { StatewireHost } from "../host";
17
18
  import type { StatewireHostInternal } from "../host-internal";
18
- import { encodeValue, reviveValue } from "../wire/meta";
19
+ import { reviveValue } from "../wire/meta";
19
20
  import {
20
21
  COMMAND_CAPACITY_RETRY_MS,
21
22
  MAX_COMMANDS_PER_FRAME,
@@ -25,7 +26,7 @@ import {
25
26
  MAX_INFLIGHT_COMMANDS_PER_HOST,
26
27
  } from "../wire/limits";
27
28
  import { createMacrotaskPoster } from "../util/macrotask";
28
- import type { StatewireMetaEntry } from "../wire/meta";
29
+ import type { StatewireDocument } from "../document";
29
30
  import type { StatewirePath } from "../wire/ops";
30
31
  import type { StatewireCmd, StatewireEnvelope } from "../client/connection";
31
32
  import { frames as serveFrames, stream as serveStream } from "./http";
@@ -79,7 +80,7 @@ type WireOp = {
79
80
  op: string;
80
81
  path?: StatewirePath;
81
82
  value?: unknown;
82
- meta?: StatewireMetaEntry[];
83
+ meta?: StatewireDocument.MetaEntry[];
83
84
  };
84
85
 
85
86
  type Admission = {
@@ -242,9 +243,9 @@ const frameIsOversized = (raw: string): boolean =>
242
243
 
243
244
  const encodePayload = (
244
245
  payload: unknown,
245
- ): { payload?: unknown; meta?: StatewireMetaEntry[] } => {
246
+ ): { payload?: unknown; meta?: StatewireDocument.MetaEntry[] } => {
246
247
  if (payload === undefined) return {};
247
- const { value, meta } = encodeValue(payload);
248
+ const { value, meta } = encodeDocument(payload);
248
249
  return { payload: value, ...(meta !== undefined && { meta }) };
249
250
  };
250
251
 
@@ -374,7 +375,7 @@ const recordOp = (
374
375
  },
375
376
  ) => {
376
377
  if (op.op === "replace" || op.op === "add") {
377
- const { value, meta } = encodeValue(op.value);
378
+ const { value, meta } = encodeDocument(op.value);
378
379
  h.pendingOps.push({ ...op, value, ...(meta !== undefined && { meta }) });
379
380
  } else {
380
381
  h.pendingOps.push(op);
@@ -1290,7 +1291,7 @@ export const register = (
1290
1291
  gcClients(h);
1291
1292
  notifyClientRecords(h);
1292
1293
  ensureHeartbeat(h);
1293
- const { value, meta } = encodeValue(h.stateHost!.snapshot());
1294
+ const { value, meta } = encodeDocument(h.stateHost!.snapshot());
1294
1295
  const hello: StatewireEnvelope = {
1295
1296
  ops: [
1296
1297
  {
package/src/index.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  export type { Statewire } from "./types";
2
+ export { encodeDocument, decodeDocument } from "./document";
3
+ export type { StatewireDocument } from "./document";
2
4
  export { StatewireClient, useStatewire, StatewireResource } from "./statewire";
3
5
  export { StatewireHttp } from "./transports/sse";
4
6
  export { StatewireWebsocket } from "./transports/ws";
package/src/statewire.ts CHANGED
@@ -436,7 +436,12 @@ export namespace StatewireClient {
436
436
  clientId: string;
437
437
  nextSeq: number;
438
438
  ctxNextSeq: number;
439
- context?: { ops: unknown[]; pending: { seq: number; ops: unknown[] }[] };
439
+ context?: {
440
+ ops: unknown[];
441
+ pending: { seq: number; ops: unknown[] }[];
442
+ /** Recover the original wire's unpersisted single-document context after the next hello. */
443
+ recover?: "version-1";
444
+ };
440
445
  commands: readonly SessionCommand[];
441
446
  /** Terminal verdicts parked for the sharer that minted the seq (shared storage only). */
442
447
  verdicts?: readonly SessionVerdict[];
package/src/wire/meta.ts CHANGED
@@ -1,60 +1,10 @@
1
- import { walkStatePath } from "../util/statePath";
1
+ import {
2
+ encodeDocument,
3
+ reviveDocumentValue as reviveValue,
4
+ } from "../document";
5
+ import type { StatewireDocument } from "../document";
2
6
 
3
- export type StatewireMetaEntry = {
4
- path: (string | number)[];
5
- type: string;
6
- };
7
-
8
- const fault = (message: string): never => {
9
- throw new Error(`statewire: protocol error: ${message}`);
10
- };
11
-
12
- const isPath = (path: unknown): path is (string | number)[] =>
13
- Array.isArray(path) &&
14
- path.every(
15
- (seg) =>
16
- typeof seg === "string" ||
17
- (typeof seg === "number" && Number.isInteger(seg)),
18
- );
19
-
20
- /** Revive meta-typed locations inside a wire value (in place). Clients skip
21
- * unknown types; hosts pass `rejectUnknown` to fault on them instead. */
22
- export const reviveValue = (
23
- value: unknown,
24
- meta: unknown,
25
- { rejectUnknown = false } = {},
26
- ): unknown => {
27
- if (!Array.isArray(meta)) return fault("meta must be an array");
28
- for (const entry of meta as { path?: unknown; type?: unknown }[]) {
29
- const path =
30
- typeof entry === "object" && entry !== null ? entry.path : null;
31
- if (!isPath(path)) return fault("meta entry must carry a path array");
32
- if (entry.type !== "date") {
33
- if (rejectUnknown)
34
- return fault(`unknown meta type: ${String(entry.type)}`);
35
- continue;
36
- }
37
- const walked = walkStatePath(value, path, "resolve");
38
- if (walked === undefined) {
39
- return fault(`meta path ${path.join(".")} does not resolve`);
40
- }
41
- if (typeof walked.leaf !== "string") {
42
- return fault(`meta path ${path.join(".")} must name a string`);
43
- }
44
- const date = new Date(walked.leaf);
45
- if (
46
- !/(?:T| )\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})$/i.test(
47
- walked.leaf,
48
- ) ||
49
- Number.isNaN(date.getTime())
50
- ) {
51
- return fault(`invalid date at meta path ${path.join(".")}`);
52
- }
53
- if (walked.parent === undefined) return date;
54
- (walked.parent as Record<string | number, unknown>)[walked.key] = date;
55
- }
56
- return value;
57
- };
7
+ export { reviveDocumentValue as reviveValue } from "../document";
58
8
 
59
9
  export const reviveOps = (ops: unknown[]): unknown[] =>
60
10
  ops.map((op) => {
@@ -71,44 +21,11 @@ export const reviveResponse = <T extends { payload?: unknown; meta?: unknown }>(
71
21
  return { ...rest, payload: reviveValue(rest.payload, meta) } as T;
72
22
  };
73
23
 
74
- const walkDates = (
75
- node: unknown,
76
- path: (string | number)[],
77
- meta: StatewireMetaEntry[],
78
- ): unknown => {
79
- if (node instanceof Date) {
80
- meta.push({ path, type: "date" });
81
- return node.toISOString();
82
- }
83
- if (Array.isArray(node)) {
84
- return node.map((item, i) => walkDates(item, [...path, i], meta));
85
- }
86
- if (typeof node === "object" && node !== null) {
87
- return Object.fromEntries(
88
- Object.keys(node).map((key) => [
89
- key,
90
- walkDates((node as Record<string, unknown>)[key], [...path, key], meta),
91
- ]),
92
- );
93
- }
94
- return node;
95
- };
96
-
97
- /** Encode Date instances inside command params as canonical ISO strings,
98
- * recording their paths (starting at the param index) in meta. */
99
24
  export const encodeParams = (
100
25
  params: readonly unknown[],
101
- ): { params: unknown[]; meta?: StatewireMetaEntry[] } => {
102
- const meta: StatewireMetaEntry[] = [];
103
- const encoded = params.map((param, i) => walkDates(param, [i], meta));
104
- return meta.length > 0 ? { params: encoded, meta } : { params: encoded };
105
- };
106
-
107
- /** Encode Date instances inside a wire value, recording their paths in meta. */
108
- export const encodeValue = (
109
- value: unknown,
110
- ): { value: unknown; meta?: StatewireMetaEntry[] } => {
111
- const meta: StatewireMetaEntry[] = [];
112
- const encoded = walkDates(value, [], meta);
113
- return meta.length > 0 ? { value: encoded, meta } : { value: encoded };
26
+ ): { params: unknown[]; meta?: StatewireDocument.MetaEntry[] } => {
27
+ const { value, meta } = encodeDocument(params);
28
+ return meta
29
+ ? { params: value as unknown[], meta }
30
+ : { params: value as unknown[] };
114
31
  };