@evolu/common 8.14.0 → 8.15.1

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 (207) hide show
  1. package/dist/src/Crypto.d.ts +2 -1
  2. package/dist/src/Crypto.d.ts.map +1 -1
  3. package/dist/src/Redacted.d.ts +19 -39
  4. package/dist/src/Redacted.d.ts.map +1 -1
  5. package/dist/src/Redacted.js +18 -33
  6. package/dist/src/Type.d.ts +161 -0
  7. package/dist/src/Type.d.ts.map +1 -1
  8. package/dist/src/Type.js +152 -2
  9. package/dist/src/WebSocket.d.ts +16 -1
  10. package/dist/src/WebSocket.d.ts.map +1 -1
  11. package/dist/src/WebSocket.js +14 -2
  12. package/dist/src/intl/_en.d.ts +5 -1
  13. package/dist/src/intl/_en.d.ts.map +1 -1
  14. package/dist/src/intl/_en.js +4 -0
  15. package/dist/src/intl/ar.d.ts +5 -1
  16. package/dist/src/intl/ar.d.ts.map +1 -1
  17. package/dist/src/intl/ar.js +4 -0
  18. package/dist/src/intl/bn.d.ts +5 -1
  19. package/dist/src/intl/bn.d.ts.map +1 -1
  20. package/dist/src/intl/bn.js +4 -0
  21. package/dist/src/intl/ca.d.ts +5 -1
  22. package/dist/src/intl/ca.d.ts.map +1 -1
  23. package/dist/src/intl/ca.js +4 -0
  24. package/dist/src/intl/cs.d.ts +5 -1
  25. package/dist/src/intl/cs.d.ts.map +1 -1
  26. package/dist/src/intl/cs.js +4 -0
  27. package/dist/src/intl/da.d.ts +5 -1
  28. package/dist/src/intl/da.d.ts.map +1 -1
  29. package/dist/src/intl/da.js +4 -0
  30. package/dist/src/intl/de.d.ts +5 -1
  31. package/dist/src/intl/de.d.ts.map +1 -1
  32. package/dist/src/intl/de.js +4 -0
  33. package/dist/src/intl/el.d.ts +5 -1
  34. package/dist/src/intl/el.d.ts.map +1 -1
  35. package/dist/src/intl/el.js +4 -0
  36. package/dist/src/intl/es.d.ts +5 -1
  37. package/dist/src/intl/es.d.ts.map +1 -1
  38. package/dist/src/intl/es.js +4 -0
  39. package/dist/src/intl/fa.d.ts +5 -1
  40. package/dist/src/intl/fa.d.ts.map +1 -1
  41. package/dist/src/intl/fa.js +4 -0
  42. package/dist/src/intl/fi.d.ts +5 -1
  43. package/dist/src/intl/fi.d.ts.map +1 -1
  44. package/dist/src/intl/fi.js +4 -0
  45. package/dist/src/intl/fil.d.ts +5 -1
  46. package/dist/src/intl/fil.d.ts.map +1 -1
  47. package/dist/src/intl/fil.js +4 -0
  48. package/dist/src/intl/fr.d.ts +5 -1
  49. package/dist/src/intl/fr.d.ts.map +1 -1
  50. package/dist/src/intl/fr.js +4 -0
  51. package/dist/src/intl/he.d.ts +5 -1
  52. package/dist/src/intl/he.d.ts.map +1 -1
  53. package/dist/src/intl/he.js +4 -0
  54. package/dist/src/intl/hi.d.ts +5 -1
  55. package/dist/src/intl/hi.d.ts.map +1 -1
  56. package/dist/src/intl/hi.js +4 -0
  57. package/dist/src/intl/hr.d.ts +5 -1
  58. package/dist/src/intl/hr.d.ts.map +1 -1
  59. package/dist/src/intl/hr.js +4 -0
  60. package/dist/src/intl/hu.d.ts +3 -1
  61. package/dist/src/intl/hu.d.ts.map +1 -1
  62. package/dist/src/intl/hu.js +2 -0
  63. package/dist/src/intl/id.d.ts +5 -1
  64. package/dist/src/intl/id.d.ts.map +1 -1
  65. package/dist/src/intl/id.js +4 -0
  66. package/dist/src/intl/it.d.ts +5 -1
  67. package/dist/src/intl/it.d.ts.map +1 -1
  68. package/dist/src/intl/it.js +4 -0
  69. package/dist/src/intl/ja.d.ts +5 -1
  70. package/dist/src/intl/ja.d.ts.map +1 -1
  71. package/dist/src/intl/ja.js +4 -0
  72. package/dist/src/intl/ko.d.ts +5 -1
  73. package/dist/src/intl/ko.d.ts.map +1 -1
  74. package/dist/src/intl/ko.js +4 -0
  75. package/dist/src/intl/ml.d.ts +5 -1
  76. package/dist/src/intl/ml.d.ts.map +1 -1
  77. package/dist/src/intl/ml.js +4 -0
  78. package/dist/src/intl/mr.d.ts +5 -1
  79. package/dist/src/intl/mr.d.ts.map +1 -1
  80. package/dist/src/intl/mr.js +4 -0
  81. package/dist/src/intl/ms.d.ts +5 -1
  82. package/dist/src/intl/ms.d.ts.map +1 -1
  83. package/dist/src/intl/ms.js +4 -0
  84. package/dist/src/intl/nb.d.ts +3 -1
  85. package/dist/src/intl/nb.d.ts.map +1 -1
  86. package/dist/src/intl/nb.js +2 -0
  87. package/dist/src/intl/nl.d.ts +5 -1
  88. package/dist/src/intl/nl.d.ts.map +1 -1
  89. package/dist/src/intl/nl.js +4 -0
  90. package/dist/src/intl/pa.d.ts +5 -1
  91. package/dist/src/intl/pa.d.ts.map +1 -1
  92. package/dist/src/intl/pa.js +4 -0
  93. package/dist/src/intl/pl.d.ts +4 -0
  94. package/dist/src/intl/pl.d.ts.map +1 -1
  95. package/dist/src/intl/pl.js +4 -0
  96. package/dist/src/intl/pt-BR.d.ts +5 -1
  97. package/dist/src/intl/pt-BR.d.ts.map +1 -1
  98. package/dist/src/intl/pt-BR.js +4 -0
  99. package/dist/src/intl/pt.d.ts +5 -1
  100. package/dist/src/intl/pt.d.ts.map +1 -1
  101. package/dist/src/intl/pt.js +4 -0
  102. package/dist/src/intl/ro.d.ts +5 -1
  103. package/dist/src/intl/ro.d.ts.map +1 -1
  104. package/dist/src/intl/ro.js +4 -0
  105. package/dist/src/intl/sk.d.ts +5 -1
  106. package/dist/src/intl/sk.d.ts.map +1 -1
  107. package/dist/src/intl/sk.js +4 -0
  108. package/dist/src/intl/sl.d.ts +5 -1
  109. package/dist/src/intl/sl.d.ts.map +1 -1
  110. package/dist/src/intl/sl.js +4 -0
  111. package/dist/src/intl/sv.d.ts +5 -1
  112. package/dist/src/intl/sv.d.ts.map +1 -1
  113. package/dist/src/intl/sv.js +4 -0
  114. package/dist/src/intl/sw.d.ts +2 -0
  115. package/dist/src/intl/sw.d.ts.map +1 -1
  116. package/dist/src/intl/sw.js +2 -0
  117. package/dist/src/intl/ta.d.ts +5 -1
  118. package/dist/src/intl/ta.d.ts.map +1 -1
  119. package/dist/src/intl/ta.js +4 -0
  120. package/dist/src/intl/te.d.ts +5 -1
  121. package/dist/src/intl/te.d.ts.map +1 -1
  122. package/dist/src/intl/te.js +4 -0
  123. package/dist/src/intl/th.d.ts +5 -1
  124. package/dist/src/intl/th.d.ts.map +1 -1
  125. package/dist/src/intl/th.js +4 -0
  126. package/dist/src/intl/tr.d.ts +5 -1
  127. package/dist/src/intl/tr.d.ts.map +1 -1
  128. package/dist/src/intl/tr.js +4 -0
  129. package/dist/src/intl/uk.d.ts +5 -1
  130. package/dist/src/intl/uk.d.ts.map +1 -1
  131. package/dist/src/intl/uk.js +4 -0
  132. package/dist/src/intl/ur.d.ts +5 -1
  133. package/dist/src/intl/ur.d.ts.map +1 -1
  134. package/dist/src/intl/ur.js +4 -0
  135. package/dist/src/intl/vi.d.ts +5 -1
  136. package/dist/src/intl/vi.d.ts.map +1 -1
  137. package/dist/src/intl/vi.js +4 -0
  138. package/dist/src/intl/zh-CN.d.ts +5 -1
  139. package/dist/src/intl/zh-CN.d.ts.map +1 -1
  140. package/dist/src/intl/zh-CN.js +4 -0
  141. package/dist/src/intl/zh-TW.d.ts +5 -1
  142. package/dist/src/intl/zh-TW.d.ts.map +1 -1
  143. package/dist/src/intl/zh-TW.js +4 -0
  144. package/dist/src/local-first/Evolu.d.ts +4 -0
  145. package/dist/src/local-first/Evolu.d.ts.map +1 -1
  146. package/dist/src/local-first/Evolu.js +6 -1
  147. package/dist/src/local-first/Schema.d.ts +3 -0
  148. package/dist/src/local-first/Schema.d.ts.map +1 -1
  149. package/dist/src/local-first/Schema.js +9 -5
  150. package/dist/src/local-first/Shared.js +1 -3
  151. package/package.json +1 -1
  152. package/src/Crypto.ts +2 -1
  153. package/src/Redacted.test.ts +231 -0
  154. package/src/Redacted.ts +36 -47
  155. package/src/Type.test.ts +166 -0
  156. package/src/Type.ts +198 -2
  157. package/src/WebSocket.ts +33 -4
  158. package/src/intl/_en.ts +10 -0
  159. package/src/intl/ar.ts +8 -0
  160. package/src/intl/bn.ts +10 -0
  161. package/src/intl/ca.ts +10 -0
  162. package/src/intl/cs.ts +10 -0
  163. package/src/intl/da.ts +10 -0
  164. package/src/intl/de.ts +10 -0
  165. package/src/intl/el.ts +10 -0
  166. package/src/intl/es.ts +10 -0
  167. package/src/intl/fa.ts +10 -0
  168. package/src/intl/fi.ts +10 -0
  169. package/src/intl/fil.ts +10 -0
  170. package/src/intl/fr.ts +10 -0
  171. package/src/intl/he.ts +10 -0
  172. package/src/intl/hi.ts +10 -0
  173. package/src/intl/hr.ts +10 -0
  174. package/src/intl/hu.ts +6 -0
  175. package/src/intl/id.ts +10 -0
  176. package/src/intl/intl.test.ts +88 -0
  177. package/src/intl/it.ts +10 -0
  178. package/src/intl/ja.ts +10 -0
  179. package/src/intl/ko.ts +10 -0
  180. package/src/intl/ml.ts +10 -0
  181. package/src/intl/mr.ts +10 -0
  182. package/src/intl/ms.ts +8 -0
  183. package/src/intl/nb.ts +6 -0
  184. package/src/intl/nl.ts +10 -0
  185. package/src/intl/pa.ts +10 -0
  186. package/src/intl/pl.ts +12 -0
  187. package/src/intl/pt-BR.ts +10 -0
  188. package/src/intl/pt.ts +8 -0
  189. package/src/intl/ro.ts +10 -0
  190. package/src/intl/sk.ts +8 -0
  191. package/src/intl/sl.ts +10 -0
  192. package/src/intl/sv.ts +10 -0
  193. package/src/intl/sw.ts +4 -0
  194. package/src/intl/ta.ts +10 -0
  195. package/src/intl/te.ts +10 -0
  196. package/src/intl/th.ts +10 -0
  197. package/src/intl/tr.ts +10 -0
  198. package/src/intl/uk.ts +10 -0
  199. package/src/intl/ur.ts +8 -0
  200. package/src/intl/vi.ts +8 -0
  201. package/src/intl/zh-CN.ts +10 -0
  202. package/src/intl/zh-TW.ts +10 -0
  203. package/src/local-first/Evolu.test.ts +62 -8
  204. package/src/local-first/Evolu.ts +11 -1
  205. package/src/local-first/Schema.test.ts +80 -1
  206. package/src/local-first/Schema.ts +15 -5
  207. package/src/local-first/Shared.ts +1 -3
package/src/Redacted.ts CHANGED
@@ -6,7 +6,6 @@
6
6
 
7
7
  import { assert } from "./Assert.ts";
8
8
  import type { Brand } from "./Brand.ts";
9
- import type { Eq } from "./Eq.ts";
10
9
 
11
10
  /**
12
11
  * A wrapper type that prevents sensitive values from being accidentally exposed
@@ -18,13 +17,17 @@ import type { Eq } from "./Eq.ts";
18
17
  *
19
18
  * For type-level distinction between different secrets, use branded types.
20
19
  *
21
- * The actual value lives in a `WeakMap`, so it never appears as a property and
22
- * is automatically garbage collected when the wrapper is dropped. This is
23
- * better than a class with a private field because private fields are still
24
- * visible in devtools. Symbols can't be used because they don't support custom
25
- * `toString`.
20
+ * Redacted guards against accidental exposure, such as logs, error reports, and
21
+ * serialized payloads. It does not hide the value from a debugger: DevTools and
22
+ * heap snapshots can still reach it.
26
23
  *
27
- * Implements `Disposable` for automatic cleanup via the `using` syntax.
24
+ * A structured clone, such as a `postMessage` to a worker, does not copy the
25
+ * hidden value and arrives as an empty object. Reveal the value before posting
26
+ * it, and wrap it again on arrival if the receiver passes it to app code.
27
+ *
28
+ * Implements `Disposable`, so the `using` syntax detaches the value when the
29
+ * scope ends. Disposal does not overwrite the value or release other references
30
+ * to it.
28
31
  *
29
32
  * ### Example
30
33
  *
@@ -48,7 +51,6 @@ import type { Eq } from "./Eq.ts";
48
51
  * using redactedKey: RedactedApiKey = createRedacted(apiKey);
49
52
  * const fetchUser = (key: RedactedApiKey): ApiKey => revealRedacted(key);
50
53
  *
51
- * // oxlint-disable-next-line typescript/no-base-to-string -- Redacted intentionally implements a safe custom toString.
52
54
  * assertEqual(redactedKey.toString(), "<redacted>");
53
55
  * assertEqual(
54
56
  * JSON.stringify({ apiKey: redactedKey }),
@@ -64,18 +66,32 @@ import type { Eq } from "./Eq.ts";
64
66
  * using key = createRedacted(apiKey);
65
67
  * return key;
66
68
  * })();
67
- * // Leaving the `using` scope removes the value from memory.
69
+ * // Leaving the `using` scope detaches the value, so revealing it throws.
68
70
  * assertErr(trySync(() => revealRedacted(disposedKey)));
69
71
  * ```
70
72
  */
71
73
  export interface Redacted<A> extends Brand<"Redacted">, Disposable {
72
- /** The inner type. Useful for inference via `typeof redacted.Type`. */
74
+ /**
75
+ * The inner type. This is a type-only phantom property. Use it through
76
+ * `typeof redacted.Type`; it does not exist at runtime.
77
+ */
73
78
  readonly Type: A;
79
+ readonly toString: () => "<redacted>";
80
+ readonly toJSON: () => "<redacted>";
74
81
  }
75
82
 
76
83
  /** Creates a {@link Redacted} wrapper for a sensitive value. */
77
84
  export const createRedacted = <A>(value: A): Redacted<A> => {
78
- const redacted = Object.create(proto) as Redacted<A>;
85
+ // Symbol.dispose is read here rather than in proto because installPolyfills
86
+ // runs after imported modules are evaluated. An arrow function also keeps a
87
+ // detached dispose method working.
88
+ const redacted = Object.create(proto, {
89
+ [Symbol.dispose]: {
90
+ value: () => {
91
+ registry.delete(redacted);
92
+ },
93
+ },
94
+ }) as Redacted<A>;
79
95
  registry.set(redacted, value);
80
96
  return redacted;
81
97
  };
@@ -84,11 +100,15 @@ const proto = {
84
100
  toString: () => redactedString,
85
101
  toJSON: () => redactedString,
86
102
  [Symbol.for("nodejs.util.inspect.custom")]: () => redactedString,
87
- [Symbol.dispose](this: Redacted<unknown>) {
88
- registry.delete(this);
89
- },
90
103
  };
91
104
  const redactedString = "<redacted>";
105
+
106
+ // The value lives in a WeakMap, so it is never a property: previews,
107
+ // enumeration, and serialization cannot show it, and it is garbage collected
108
+ // with the wrapper. A private field would show when DevTools expands the
109
+ // object. The wrapper is an object, not a symbol, because a symbol cannot
110
+ // customize toString or toJSON. DevTools can still reach the registry through
111
+ // the scopes of the wrapper's functions.
92
112
  const registry = new WeakMap<Redacted<unknown>, unknown>();
93
113
 
94
114
  /**
@@ -97,6 +117,8 @@ const registry = new WeakMap<Redacted<unknown>, unknown>();
97
117
  * This is a separate function rather than a method on {@link Redacted} to make
98
118
  * access visually explicit and easy to grep in code reviews. Accessing
99
119
  * sensitive values should feel intentional, not convenient.
120
+ *
121
+ * Throws when the wrapper was disposed or is a structured clone.
100
122
  */
101
123
  export const revealRedacted = <A>(redacted: Redacted<A>): A => {
102
124
  assert(registry.has(redacted), "Redacted value was not in registry");
@@ -108,36 +130,3 @@ export const isRedacted = (value: unknown): value is Redacted<unknown> =>
108
130
  typeof value === "object" &&
109
131
  value !== null &&
110
132
  Object.getPrototypeOf(value) === proto;
111
-
112
- /**
113
- * Creates an {@link Eq} for {@link Redacted} values based on an equality function
114
- * for the underlying type.
115
- *
116
- * ### Example
117
- *
118
- * ```ts
119
- * import {
120
- * assertFalse,
121
- * assertTrue,
122
- * createEqRedacted,
123
- * createRedacted,
124
- * eqString,
125
- * type Brand,
126
- * } from "@evolu/common";
127
- *
128
- * type ApiKey = string & Brand<"ApiKey">;
129
- * const eqRedactedApiKey = createEqRedacted<ApiKey>(eqString);
130
- *
131
- * // Apply brands only after validation or at another trusted boundary.
132
- * using a = createRedacted("x" as ApiKey);
133
- * using b = createRedacted("x" as ApiKey);
134
- * using c = createRedacted("y" as ApiKey);
135
- *
136
- * assertTrue(eqRedactedApiKey(a, b));
137
- * assertFalse(eqRedactedApiKey(a, c));
138
- * ```
139
- */
140
- export const createEqRedacted =
141
- <A>(eq: Eq<A>): Eq<Redacted<A>> =>
142
- (x, y) =>
143
- eq(revealRedacted(x), revealRedacted(y));
package/src/Type.test.ts CHANGED
@@ -103,6 +103,7 @@ import {
103
103
  Digit1To59,
104
104
  Digit1To99,
105
105
  discriminatedUnion,
106
+ Email,
106
107
  EvoluType,
107
108
  finite,
108
109
  FiniteNumber,
@@ -121,6 +122,7 @@ import {
121
122
  idBytesTypeValueLength,
122
123
  id,
123
124
  idToIdBytes,
125
+ idToUuid,
124
126
  json,
125
127
  Json,
126
128
  JsonArray,
@@ -216,6 +218,8 @@ import {
216
218
  UnknownResult,
217
219
  uint8ArrayToBase64Url,
218
220
  UrlSafeString,
221
+ Uuid,
222
+ uuidToId,
219
223
  zeroNonNegativeInt,
220
224
  type AnyType,
221
225
  type ArrayElementIssue,
@@ -239,6 +243,7 @@ import {
239
243
  type DiscriminatedUnionMemberError,
240
244
  type DiscriminatedUnionMemberIssue,
241
245
  type DiscriminatedUnionType,
246
+ type EmailError,
242
247
  type EvoluTypeError,
243
248
  type ExtractTyped,
244
249
  type FiniteError,
@@ -354,6 +359,7 @@ import {
354
359
  type UnionInputType,
355
360
  type UnionMemberError,
356
361
  type UnionType,
362
+ type UuidError,
357
363
  type ValidationOptions,
358
364
  type ValidateLiteral,
359
365
  type ValidateOutput,
@@ -545,6 +551,7 @@ describe("Type", () => {
545
551
  Digit1To51,
546
552
  Digit1To59,
547
553
  Digit1To99,
554
+ Email,
548
555
  EvoluType,
549
556
  FiniteNumber,
550
557
  Function,
@@ -601,6 +608,7 @@ describe("Type", () => {
601
608
  UnknownNextResult,
602
609
  UnknownResult,
603
610
  UrlSafeString,
611
+ Uuid,
604
612
  } as const satisfies Readonly<Record<ExportedTypeKey, TypeNode>>;
605
613
 
606
614
  const IntrospectionRoot = createType(
@@ -679,6 +687,7 @@ describe("Type", () => {
679
687
  "DateIsoFromDate",
680
688
  "DecimalString",
681
689
  "DiscriminatedUnion",
690
+ "Email",
682
691
  "EvoluType",
683
692
  "Finite",
684
693
  "Function",
@@ -753,6 +762,7 @@ describe("Type", () => {
753
762
  "Unknown",
754
763
  "Uppercased",
755
764
  "UrlSafeString",
765
+ "Uuid",
756
766
  ] as const;
757
767
 
758
768
  assertType<
@@ -10013,6 +10023,122 @@ describe("BrandFactory", () => {
10013
10023
  });
10014
10024
  });
10015
10025
 
10026
+ describe("Email", () => {
10027
+ // Single-address cases from the WebKit and Chromium
10028
+ // ValidityState-typeMismatch-email.js tests, without the cases that
10029
+ // test the browser's whitespace sanitization.
10030
+ it("accepts valid email addresses as defined by the HTML Standard", () => {
10031
+ for (const value of [
10032
+ "something@something.com",
10033
+ "someone@localhost.localdomain",
10034
+ "someone@127.0.0.1",
10035
+ "a@b.b",
10036
+ "a/b@domain.com",
10037
+ "{}@domain.com",
10038
+ "m*'!%@something.sa",
10039
+ "tu!!7n7.ad##0!!!@company.ca",
10040
+ "%@com.com",
10041
+ "!#$%&'*+/=?^_`{|}~.-@com.com",
10042
+ ".wooly@example.com",
10043
+ "wo..oly@example.com",
10044
+ "someone@do-ma-in.com",
10045
+ "somebody@example",
10046
+ "Ada@Example.COM",
10047
+ "a@xn--maana-pta.com",
10048
+ `${"a".repeat(64)}@p.com`,
10049
+ `a@${"p".repeat(63)}.${"c".repeat(63)}`,
10050
+ ]) {
10051
+ assertEqual(Email.fromUnknown(value), ok(value));
10052
+ }
10053
+ assertType<typeof Email.Output, string & Brand<"Email">>();
10054
+ });
10055
+
10056
+ it("rejects invalid email addresses", () => {
10057
+ for (const value of [
10058
+ "invalid:email@example.com",
10059
+ "@somewhere.com",
10060
+ "example.com",
10061
+ "@@example.com",
10062
+ "a space@example.com",
10063
+ "something@ex..ample.com",
10064
+ "a\b@c",
10065
+ "someone@somewhere.com.",
10066
+ '""test\blah""@example.com',
10067
+ '"testblah"@example.com',
10068
+ "someone@somewhere.com@",
10069
+ "someone@somewhere_com",
10070
+ "someone@some:where.com",
10071
+ ".",
10072
+ "F/s/f/a@feo+re.com",
10073
+ "some+long+email+address@some+host-weird-/looking.com",
10074
+ "a @p.com",
10075
+ "a\t@p.com",
10076
+ "a\u000B@p.com",
10077
+ "a\u000C@p.com",
10078
+ "a @p.com",
10079
+ "a @p.com",
10080
+ "ddjk-s-jk@asl-.com",
10081
+ "someone@do-.com",
10082
+ "somebody@-.com",
10083
+ `a@${"p".repeat(64)}.com`,
10084
+ `a@p.${"c".repeat(64)}`,
10085
+ " a@p.com",
10086
+ "a@p.com\n",
10087
+ "a@[127.0.0.1]",
10088
+ "mañana@example.com",
10089
+ "a@mañana.com",
10090
+ "",
10091
+ ]) {
10092
+ assertEqual(
10093
+ Email.fromUnknown(value),
10094
+ err({ type: "Email", value }),
10095
+ );
10096
+ }
10097
+ assertEqual(
10098
+ Email.formatError({ type: "Email", value: "example.com" }),
10099
+ 'The value "example.com" is not a valid email address.',
10100
+ );
10101
+ assertType<typeof Email.Error, EmailError>();
10102
+ });
10103
+ });
10104
+
10105
+ describe("Uuid", () => {
10106
+ it("accepts canonical lowercase UUIDs of every version and variant", () => {
10107
+ for (const value of [
10108
+ "20354d7a-e4fe-47af-8ff6-187bca92f3f9",
10109
+ "0190a6f4-8c3e-7b2a-9d41-5e6f7a8b9c0d",
10110
+ "00000000-0000-0000-0000-000000000000",
10111
+ "ffffffff-ffff-ffff-ffff-ffffffffffff",
10112
+ "01234567-89ab-0def-0123-456789abcdef",
10113
+ ]) {
10114
+ assertEqual(Uuid.fromUnknown(value), ok(value));
10115
+ }
10116
+ assertType<typeof Uuid.Output, string & Brand<"Uuid">>();
10117
+ });
10118
+
10119
+ it("rejects non-canonical text", () => {
10120
+ for (const value of [
10121
+ "20354D7A-E4FE-47AF-8FF6-187BCA92F3F9",
10122
+ "20354d7a-e4fe-47af-8ff6-187bca92f3F9",
10123
+ "{20354d7a-e4fe-47af-8ff6-187bca92f3f9}",
10124
+ "urn:uuid:20354d7a-e4fe-47af-8ff6-187bca92f3f9",
10125
+ "20354d7ae4fe47af8ff6187bca92f3f9",
10126
+ "20354d7a-e4fe-47af-8ff6-187bca92f3f",
10127
+ "20354d7a-e4fe-47af-8ff6-187bca92f3f9 ",
10128
+ "g0354d7a-e4fe-47af-8ff6-187bca92f3f9",
10129
+ "invalid",
10130
+ "",
10131
+ ]) {
10132
+ assertEqual(Uuid.fromUnknown(value), err({ type: "Uuid", value }));
10133
+ }
10134
+ assertEqual(
10135
+ Uuid.formatError({ type: "Uuid", value: "invalid" }),
10136
+ 'The value "invalid" is not a canonical lowercase UUID.',
10137
+ );
10138
+ assertType<typeof Uuid.Error, UuidError>();
10139
+ });
10140
+ });
10141
+
10016
10142
  describe("SimplePassword", () => {
10017
10143
  it("requires trimmed text containing between 8 and 64 characters", () => {
10018
10144
  assertEqual(
@@ -10195,6 +10321,46 @@ describe("BrandFactory", () => {
10195
10321
  assertEqual(idBytesTypeValueLength, 16);
10196
10322
  assertSame(idBytesToId(bytes), value);
10197
10323
  });
10324
+
10325
+ it("converts to and from a Uuid with the same bytes", () => {
10326
+ const deps = testCreateDeps();
10327
+ const uuid = Uuid.orThrow("0190a6f4-8c3e-7b2a-9d41-5e6f7a8b9c0d");
10328
+ const value = uuidToId(uuid);
10329
+
10330
+ assertEqual(value, "AZCm9Iw-eyqdQV5veoucDQ");
10331
+ assertEqualBytes(
10332
+ idToIdBytes(value),
10333
+ new globalThis.Uint8Array([
10334
+ 0x01, 0x90, 0xa6, 0xf4, 0x8c, 0x3e, 0x7b, 0x2a, 0x9d, 0x41, 0x5e,
10335
+ 0x6f, 0x7a, 0x8b, 0x9c, 0x0d,
10336
+ ]),
10337
+ );
10338
+ assertSame(idToUuid(value), uuid);
10339
+
10340
+ for (const id of [
10341
+ createId(deps),
10342
+ createIdAsUuidv7(deps),
10343
+ createIdFromString("uuid"),
10344
+ uuidToId(Uuid.orThrow("00000000-0000-0000-0000-000000000000")),
10345
+ uuidToId(Uuid.orThrow("ffffffff-ffff-ffff-ffff-ffffffffffff")),
10346
+ ]) {
10347
+ const converted = idToUuid(id);
10348
+ assertTrue(Uuid.is(converted));
10349
+ assertSame(uuidToId(converted), id);
10350
+ }
10351
+ assertEqual(idToUuid(createIdAsUuidv7(deps))[14], "7");
10352
+
10353
+ const todoId = uuidToId<"Todo">(uuid);
10354
+ assertType<typeof value, Id>();
10355
+ assertType<typeof todoId, Id & Brand<"Todo">>();
10356
+ assertType<ReturnType<typeof idToUuid>, Uuid>();
10357
+ void (() => {
10358
+ // @ts-expect-error A union would assign multiple brands to one Id.
10359
+ uuidToId<"Todo" | "User">(uuid);
10360
+ // @ts-expect-error uuidToId accepts only a validated Uuid.
10361
+ uuidToId("0190a6f4-8c3e-7b2a-9d41-5e6f7a8b9c0d");
10362
+ });
10363
+ });
10198
10364
  });
10199
10365
 
10200
10366
  describe("Int64String", () => {
package/src/Type.ts CHANGED
@@ -194,7 +194,7 @@
194
194
  * ```
195
195
  *
196
196
  * Evolu includes dozens of predefined Types and Type factories. Use Types such
197
- * as {@link Age}, {@link PositiveInt}, {@link DateIso},
197
+ * as {@link Age}, {@link PositiveInt}, {@link DateIso}, {@link Email}, {@link Uuid},
198
198
  * {@link NonEmptyTrimmedString100}, {@link Base64Url}, and {@link Json} directly.
199
199
  * Build domain Types with factories such as {@link brand}, {@link typed},
200
200
  * {@link minLength}, {@link maxLength}, {@link array}, {@link object},
@@ -448,7 +448,7 @@
448
448
  *
449
449
  * @module
450
450
  */
451
- import { utf8ToBytes } from "@noble/ciphers/utils.js";
451
+ import { bytesToHex, hexToBytes, utf8ToBytes } from "@noble/ciphers/utils.js";
452
452
  import { sha256 } from "@noble/hashes/sha2.js";
453
453
  import * as bip39 from "@scure/bip39";
454
454
  import { wordlist } from "@scure/bip39/wordlists/english.js";
@@ -7598,6 +7598,74 @@ export const Name = /*#__PURE__*/ brand(
7598
7598
  );
7599
7599
  export type Name = typeof Name.Output;
7600
7600
 
7601
+ /**
7602
+ * Error returned when a string is not a valid {@link Email}.
7603
+ *
7604
+ * @group String
7605
+ */
7606
+ export interface EmailError extends TypeError<"Email"> {
7607
+ readonly value: string;
7608
+ }
7609
+
7610
+ /**
7611
+ * Email address as defined by the WHATWG HTML Standard.
7612
+ *
7613
+ * Email accepts a [valid email
7614
+ * address](https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address),
7615
+ * the syntax browsers enforce for `<input type="email">`. The HTML Standard
7616
+ * deliberately departs from RFC 5322: it rejects quoted local parts, comments,
7617
+ * IP address literals, and whitespace, and it accepts only ASCII. Use the ASCII
7618
+ * (`xn--`) form of internationalized domains.
7619
+ *
7620
+ * Email does not normalize. The local part is case-sensitive, so different
7621
+ * Email strings can still reach the same mailbox, and only a delivered message
7622
+ * proves that an address exists.
7623
+ *
7624
+ * The HTML Standard limits each domain label to 63 characters but sets no total
7625
+ * length. SMTP limits an address to 254 characters; compose
7626
+ * `maxLength(254)(Email)` when that limit matters.
7627
+ *
7628
+ * ### Example
7629
+ *
7630
+ * ```ts
7631
+ * import {
7632
+ * assertEqual,
7633
+ * assertErr,
7634
+ * assertOk,
7635
+ * Email,
7636
+ * maxLength,
7637
+ * } from "@evolu/common";
7638
+ *
7639
+ * assertOk(Email.fromUnknown("ada@example.com"), "ada@example.com");
7640
+ * assertOk(Email.fromUnknown("Ada@Example.com"), "Ada@Example.com");
7641
+ *
7642
+ * const invalid = Email.fromUnknown("Ada <ada@example.com>");
7643
+ * assertErr(invalid);
7644
+ * assertEqual(invalid.error, {
7645
+ * type: "Email",
7646
+ * value: "Ada <ada@example.com>",
7647
+ * });
7648
+ *
7649
+ * const SmtpEmail = maxLength(254)(Email);
7650
+ * assertErr(SmtpEmail.fromUnknown(`${"a".repeat(251)}@b.c`));
7651
+ * ```
7652
+ *
7653
+ * @group String
7654
+ */
7655
+ export const Email = /*#__PURE__*/ brand(
7656
+ "Email",
7657
+ String,
7658
+ (value) =>
7659
+ /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/u.test(
7660
+ value,
7661
+ )
7662
+ ? ok()
7663
+ : err<EmailError>({ type: "Email", value }),
7664
+ (error) =>
7665
+ `The value ${safelyStringifyUnknownValue(error.value)} is not a valid email address.`,
7666
+ );
7667
+ export type Email = typeof Email.Output;
7668
+
7601
7669
  /**
7602
7670
  * Stable valid {@link Name} for tests and internal fixtures.
7603
7671
  *
@@ -7967,6 +8035,134 @@ export const idToIdBytes = (value: Id): IdBytes =>
7967
8035
  export const idBytesToId = (value: IdBytes): Id =>
7968
8036
  uint8ArrayToBase64Url(value) as unknown as Id;
7969
8037
 
8038
+ /**
8039
+ * Error returned when a string is not a canonical {@link Uuid}.
8040
+ *
8041
+ * @group String
8042
+ */
8043
+ export interface UuidError extends TypeError<"Uuid"> {
8044
+ readonly value: string;
8045
+ }
8046
+
8047
+ /**
8048
+ * UUID in its canonical lowercase text form, as defined by [RFC
8049
+ * 9562](https://www.rfc-editor.org/rfc/rfc9562).
8050
+ *
8051
+ * Uuid accepts 32 lowercase hexadecimal digits in the 8-4-4-4-12 layout, as
8052
+ * produced by `crypto.randomUUID()`. It accepts every version and variant,
8053
+ * including the Nil and Max UUIDs, so every 128-bit value has exactly one
8054
+ * Uuid.
8055
+ *
8056
+ * RFC 9562 reads UUIDs in any case. Uuid requires lowercase so that equal UUIDs
8057
+ * are equal strings; lowercase text from other sources before validating it.
8058
+ *
8059
+ * Convert a Uuid to an {@link Id} with {@link uuidToId} and back with
8060
+ * {@link idToUuid}.
8061
+ *
8062
+ * ### Example
8063
+ *
8064
+ * ```ts
8065
+ * import { assertEqual, assertErr, assertOk, Uuid } from "@evolu/common";
8066
+ *
8067
+ * const value = "0190a6f4-8c3e-7b2a-9d41-5e6f7a8b9c0d";
8068
+ * assertOk(Uuid.fromUnknown(value), value);
8069
+ *
8070
+ * const uppercase = value.toUpperCase();
8071
+ * const invalid = Uuid.fromUnknown(uppercase);
8072
+ * assertErr(invalid);
8073
+ * assertEqual(invalid.error, { type: "Uuid", value: uppercase });
8074
+ * assertOk(Uuid.fromUnknown(uppercase.toLowerCase()), value);
8075
+ * ```
8076
+ *
8077
+ * @group String
8078
+ */
8079
+ export const Uuid = /*#__PURE__*/ brand(
8080
+ "Uuid",
8081
+ String,
8082
+ (value) =>
8083
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u.test(
8084
+ value,
8085
+ )
8086
+ ? ok()
8087
+ : err<UuidError>({ type: "Uuid", value }),
8088
+ (error) =>
8089
+ `The value ${safelyStringifyUnknownValue(error.value)} is not a canonical lowercase UUID.`,
8090
+ );
8091
+ export type Uuid = typeof Uuid.Output;
8092
+
8093
+ /**
8094
+ * Converts a {@link Uuid} to the {@link Id} with the same 16 bytes.
8095
+ *
8096
+ * Use this to store records whose external keys are UUIDs. Unlike
8097
+ * {@link createIdFromString}, the mapping is reversible with {@link idToUuid}, so
8098
+ * the original UUID does not need its own column. A time-based UUID, such as
8099
+ * version 1, 6, or 7, keeps its creation time in the Id.
8100
+ *
8101
+ * ### Example
8102
+ *
8103
+ * ```ts
8104
+ * import {
8105
+ * assertEqual,
8106
+ * assertType,
8107
+ * idToUuid,
8108
+ * Uuid,
8109
+ * uuidToId,
8110
+ * type Brand,
8111
+ * type Id,
8112
+ * } from "@evolu/common";
8113
+ *
8114
+ * const uuid = Uuid.orThrow("0190a6f4-8c3e-7b2a-9d41-5e6f7a8b9c0d");
8115
+ * const todoId = uuidToId<"Todo">(uuid);
8116
+ *
8117
+ * assertEqual(todoId, "AZCm9Iw-eyqdQV5veoucDQ");
8118
+ * assertType<typeof todoId, Id & Brand<"Todo">>();
8119
+ * assertEqual(idToUuid(todoId), uuid);
8120
+ * ```
8121
+ *
8122
+ * @group String
8123
+ */
8124
+ export const uuidToId = <B extends string = never>(
8125
+ value: Uuid,
8126
+ ..._validation: IdBrandValidation<B>
8127
+ ): CreatedId<B> =>
8128
+ idBytesToId(hexToBytes(value.replaceAll("-", "")) as IdBytes) as CreatedId<B>;
8129
+
8130
+ /**
8131
+ * Converts an {@link Id} to the {@link Uuid} with the same 16 bytes.
8132
+ *
8133
+ * The result is a Uuid of any version or variant, because Ids created by
8134
+ * {@link createId} are random 128-bit values. Ids created by
8135
+ * {@link createIdAsUuidv7} convert to version 7 UUIDs.
8136
+ *
8137
+ * ### Example
8138
+ *
8139
+ * ```ts
8140
+ * import {
8141
+ * assertEqual,
8142
+ * createIdAsUuidv7,
8143
+ * createRandomBytes,
8144
+ * createTime,
8145
+ * idToUuid,
8146
+ * uuidToId,
8147
+ * } from "@evolu/common";
8148
+ *
8149
+ * const id = createIdAsUuidv7({
8150
+ * randomBytes: createRandomBytes(),
8151
+ * time: createTime(),
8152
+ * });
8153
+ * const uuid = idToUuid(id);
8154
+ *
8155
+ * assertEqual(uuid[14], "7");
8156
+ * assertEqual(uuidToId(uuid), id);
8157
+ * ```
8158
+ *
8159
+ * @group String
8160
+ */
8161
+ export const idToUuid = (value: Id): Uuid => {
8162
+ const hex = bytesToHex(idToIdBytes(value));
8163
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}` as Uuid;
8164
+ };
8165
+
7970
8166
  /**
7971
8167
  * Error returned when a string is not a canonical {@link Int64String}.
7972
8168
  *
package/src/WebSocket.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  import { assert } from "./Assert.ts";
8
8
  import { constTrue } from "./Function.ts";
9
9
  import type { Result } from "./Result.ts";
10
- import { err, ok } from "./Result.ts";
10
+ import { err, ok, trySync } from "./Result.ts";
11
11
  import type { Schedule } from "./Schedule.ts";
12
12
  import { exponential, jitter, maxDelay } from "./Schedule.ts";
13
13
  import type { RetryError, Task } from "./Task.ts";
@@ -266,10 +266,24 @@ export interface WebSocketOptions {
266
266
  * @group Errors
267
267
  */
268
268
  export type WebSocketError =
269
+ | WebSocketCreateError
269
270
  | WebSocketConnectError
270
271
  | WebSocketConnectionError
271
272
  | RetryError<WebSocketRetryError>;
272
273
 
274
+ /**
275
+ * An error that occurs when the platform's WebSocket constructor throws, for
276
+ * example for a URL it rejects, a `ws:` URL on an `https:` page, or a Content
277
+ * Security Policy that blocks the URL. These fail the same way on every
278
+ * attempt, so the {@link WebSocket} stops connecting. The thrown value is not
279
+ * always an `Error`.
280
+ *
281
+ * @group Errors
282
+ */
283
+ export interface WebSocketCreateError extends Typed<"WebSocketCreateError"> {
284
+ readonly error: unknown;
285
+ }
286
+
273
287
  /**
274
288
  * An error that occurs when a connection cannot be established due to a network
275
289
  * error. Fires before `onclose`.
@@ -344,6 +358,9 @@ const defaultHealthyConnectionDuration = /*#__PURE__*/ durationToMillis("30s");
344
358
  /**
345
359
  * Create a new {@link WebSocket}.
346
360
  *
361
+ * When the platform's WebSocket constructor throws, `onError` receives a
362
+ * {@link WebSocketCreateError} and the WebSocket stops connecting.
363
+ *
347
364
  * @group Core
348
365
  */
349
366
  export const createWebSocket: CreateWebSocket =
@@ -403,10 +420,22 @@ export const createWebSocket: CreateWebSocket =
403
420
  closeSocket();
404
421
  resolveConnect = resolve;
405
422
 
406
- socket = new WebSocketConstructor(
407
- url,
408
- String.is(protocols) ? protocols : protocols && [...protocols],
423
+ const created = trySync(
424
+ () =>
425
+ new WebSocketConstructor(
426
+ url,
427
+ String.is(protocols) ? protocols : protocols && [...protocols],
428
+ ),
409
429
  );
430
+ // A throw that escaped this Task would panic the Run, which in Evolu's
431
+ // SharedWorker stops sync for every tab.
432
+ if (!created.ok) {
433
+ resolveConnect = null;
434
+ onError?.({ type: "WebSocketCreateError", error: created.error });
435
+ resolve(ok());
436
+ return;
437
+ }
438
+ socket = created.value;
410
439
 
411
440
  if (binaryType) socket.binaryType = binaryType;
412
441