@evolu/common 8.9.0 → 8.10.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 (233) hide show
  1. package/dist/src/Bytes.d.ts +647 -0
  2. package/dist/src/Bytes.d.ts.map +1 -0
  3. package/dist/src/{Binary.js → Bytes.js} +266 -16
  4. package/dist/src/Config.d.ts +142 -0
  5. package/dist/src/Config.d.ts.map +1 -0
  6. package/dist/src/Config.js +181 -0
  7. package/dist/src/Fs.d.ts +302 -0
  8. package/dist/src/Fs.d.ts.map +1 -0
  9. package/dist/src/Fs.js +111 -0
  10. package/dist/src/Number.d.ts +50 -7
  11. package/dist/src/Number.d.ts.map +1 -1
  12. package/dist/src/Number.js +47 -8
  13. package/dist/src/Object.d.ts +32 -0
  14. package/dist/src/Object.d.ts.map +1 -1
  15. package/dist/src/Object.js +41 -0
  16. package/dist/src/Sqlite.js +1 -1
  17. package/dist/src/Time.d.ts +74 -12
  18. package/dist/src/Time.d.ts.map +1 -1
  19. package/dist/src/Time.js +40 -2
  20. package/dist/src/Type.d.ts +1451 -79
  21. package/dist/src/Type.d.ts.map +1 -1
  22. package/dist/src/Type.js +1303 -96
  23. package/dist/src/index.d.ts +3 -1
  24. package/dist/src/index.d.ts.map +1 -1
  25. package/dist/src/index.js +3 -1
  26. package/dist/src/intl/_en.d.ts +24 -1
  27. package/dist/src/intl/_en.d.ts.map +1 -1
  28. package/dist/src/intl/_en.js +20 -0
  29. package/dist/src/intl/ar.d.ts +24 -1
  30. package/dist/src/intl/ar.d.ts.map +1 -1
  31. package/dist/src/intl/ar.js +20 -0
  32. package/dist/src/intl/bn.d.ts +24 -1
  33. package/dist/src/intl/bn.d.ts.map +1 -1
  34. package/dist/src/intl/bn.js +20 -0
  35. package/dist/src/intl/ca.d.ts +24 -1
  36. package/dist/src/intl/ca.d.ts.map +1 -1
  37. package/dist/src/intl/ca.js +20 -0
  38. package/dist/src/intl/cs.d.ts +24 -1
  39. package/dist/src/intl/cs.d.ts.map +1 -1
  40. package/dist/src/intl/cs.js +20 -0
  41. package/dist/src/intl/da.d.ts +24 -1
  42. package/dist/src/intl/da.d.ts.map +1 -1
  43. package/dist/src/intl/da.js +20 -0
  44. package/dist/src/intl/de.d.ts +24 -1
  45. package/dist/src/intl/de.d.ts.map +1 -1
  46. package/dist/src/intl/de.js +20 -0
  47. package/dist/src/intl/el.d.ts +24 -1
  48. package/dist/src/intl/el.d.ts.map +1 -1
  49. package/dist/src/intl/el.js +20 -0
  50. package/dist/src/intl/es.d.ts +24 -1
  51. package/dist/src/intl/es.d.ts.map +1 -1
  52. package/dist/src/intl/es.js +20 -0
  53. package/dist/src/intl/fa.d.ts +24 -1
  54. package/dist/src/intl/fa.d.ts.map +1 -1
  55. package/dist/src/intl/fa.js +20 -0
  56. package/dist/src/intl/fi.d.ts +24 -1
  57. package/dist/src/intl/fi.d.ts.map +1 -1
  58. package/dist/src/intl/fi.js +20 -0
  59. package/dist/src/intl/fil.d.ts +24 -1
  60. package/dist/src/intl/fil.d.ts.map +1 -1
  61. package/dist/src/intl/fil.js +20 -0
  62. package/dist/src/intl/fr.d.ts +24 -1
  63. package/dist/src/intl/fr.d.ts.map +1 -1
  64. package/dist/src/intl/fr.js +20 -0
  65. package/dist/src/intl/he.d.ts +24 -1
  66. package/dist/src/intl/he.d.ts.map +1 -1
  67. package/dist/src/intl/he.js +20 -0
  68. package/dist/src/intl/hi.d.ts +24 -1
  69. package/dist/src/intl/hi.d.ts.map +1 -1
  70. package/dist/src/intl/hi.js +20 -0
  71. package/dist/src/intl/hr.d.ts +24 -1
  72. package/dist/src/intl/hr.d.ts.map +1 -1
  73. package/dist/src/intl/hr.js +20 -0
  74. package/dist/src/intl/hu.d.ts +22 -1
  75. package/dist/src/intl/hu.d.ts.map +1 -1
  76. package/dist/src/intl/hu.js +18 -0
  77. package/dist/src/intl/id.d.ts +24 -1
  78. package/dist/src/intl/id.d.ts.map +1 -1
  79. package/dist/src/intl/id.js +20 -0
  80. package/dist/src/intl/it.d.ts +24 -1
  81. package/dist/src/intl/it.d.ts.map +1 -1
  82. package/dist/src/intl/it.js +20 -0
  83. package/dist/src/intl/ja.d.ts +24 -1
  84. package/dist/src/intl/ja.d.ts.map +1 -1
  85. package/dist/src/intl/ja.js +20 -0
  86. package/dist/src/intl/ko.d.ts +24 -1
  87. package/dist/src/intl/ko.d.ts.map +1 -1
  88. package/dist/src/intl/ko.js +20 -0
  89. package/dist/src/intl/ml.d.ts +24 -1
  90. package/dist/src/intl/ml.d.ts.map +1 -1
  91. package/dist/src/intl/ml.js +20 -0
  92. package/dist/src/intl/mr.d.ts +24 -1
  93. package/dist/src/intl/mr.d.ts.map +1 -1
  94. package/dist/src/intl/mr.js +20 -0
  95. package/dist/src/intl/ms.d.ts +24 -1
  96. package/dist/src/intl/ms.d.ts.map +1 -1
  97. package/dist/src/intl/ms.js +20 -0
  98. package/dist/src/intl/nb.d.ts +22 -1
  99. package/dist/src/intl/nb.d.ts.map +1 -1
  100. package/dist/src/intl/nb.js +18 -0
  101. package/dist/src/intl/nl.d.ts +24 -1
  102. package/dist/src/intl/nl.d.ts.map +1 -1
  103. package/dist/src/intl/nl.js +20 -0
  104. package/dist/src/intl/pa.d.ts +24 -1
  105. package/dist/src/intl/pa.d.ts.map +1 -1
  106. package/dist/src/intl/pa.js +20 -0
  107. package/dist/src/intl/pl.d.ts +23 -0
  108. package/dist/src/intl/pl.d.ts.map +1 -1
  109. package/dist/src/intl/pl.js +20 -0
  110. package/dist/src/intl/pt-BR.d.ts +24 -1
  111. package/dist/src/intl/pt-BR.d.ts.map +1 -1
  112. package/dist/src/intl/pt-BR.js +20 -0
  113. package/dist/src/intl/pt.d.ts +24 -1
  114. package/dist/src/intl/pt.d.ts.map +1 -1
  115. package/dist/src/intl/pt.js +20 -0
  116. package/dist/src/intl/ro.d.ts +24 -1
  117. package/dist/src/intl/ro.d.ts.map +1 -1
  118. package/dist/src/intl/ro.js +20 -0
  119. package/dist/src/intl/sk.d.ts +24 -1
  120. package/dist/src/intl/sk.d.ts.map +1 -1
  121. package/dist/src/intl/sk.js +20 -0
  122. package/dist/src/intl/sl.d.ts +24 -1
  123. package/dist/src/intl/sl.d.ts.map +1 -1
  124. package/dist/src/intl/sl.js +20 -0
  125. package/dist/src/intl/sv.d.ts +24 -1
  126. package/dist/src/intl/sv.d.ts.map +1 -1
  127. package/dist/src/intl/sv.js +20 -0
  128. package/dist/src/intl/sw.d.ts +21 -0
  129. package/dist/src/intl/sw.d.ts.map +1 -1
  130. package/dist/src/intl/sw.js +18 -0
  131. package/dist/src/intl/ta.d.ts +24 -1
  132. package/dist/src/intl/ta.d.ts.map +1 -1
  133. package/dist/src/intl/ta.js +20 -0
  134. package/dist/src/intl/te.d.ts +24 -1
  135. package/dist/src/intl/te.d.ts.map +1 -1
  136. package/dist/src/intl/te.js +20 -0
  137. package/dist/src/intl/th.d.ts +24 -1
  138. package/dist/src/intl/th.d.ts.map +1 -1
  139. package/dist/src/intl/th.js +20 -0
  140. package/dist/src/intl/tr.d.ts +24 -1
  141. package/dist/src/intl/tr.d.ts.map +1 -1
  142. package/dist/src/intl/tr.js +20 -0
  143. package/dist/src/intl/uk.d.ts +80 -57
  144. package/dist/src/intl/uk.d.ts.map +1 -1
  145. package/dist/src/intl/uk.js +174 -149
  146. package/dist/src/intl/ur.d.ts +24 -1
  147. package/dist/src/intl/ur.d.ts.map +1 -1
  148. package/dist/src/intl/ur.js +20 -0
  149. package/dist/src/intl/vi.d.ts +24 -1
  150. package/dist/src/intl/vi.d.ts.map +1 -1
  151. package/dist/src/intl/vi.js +20 -0
  152. package/dist/src/intl/zh-CN.d.ts +24 -1
  153. package/dist/src/intl/zh-CN.d.ts.map +1 -1
  154. package/dist/src/intl/zh-CN.js +20 -0
  155. package/dist/src/intl/zh-TW.d.ts +24 -1
  156. package/dist/src/intl/zh-TW.d.ts.map +1 -1
  157. package/dist/src/intl/zh-TW.js +20 -0
  158. package/dist/src/local-first/Protocol.d.ts +1 -1
  159. package/dist/src/local-first/Protocol.d.ts.map +1 -1
  160. package/dist/src/local-first/Protocol.js +1 -1
  161. package/dist/src/local-first/Schema.d.ts +10 -0
  162. package/dist/src/local-first/Schema.d.ts.map +1 -1
  163. package/dist/src/local-first/Storage.js +1 -1
  164. package/dist/src/local-first/Timestamp.js +1 -1
  165. package/package.json +1 -1
  166. package/src/{Binary.test.ts → Bytes.test.ts} +286 -1
  167. package/src/{Binary.ts → Bytes.ts} +652 -21
  168. package/src/Config.test.ts +672 -0
  169. package/src/Config.ts +410 -0
  170. package/src/Fs.test.ts +105 -0
  171. package/src/Fs.ts +414 -0
  172. package/src/Number.test.ts +82 -18
  173. package/src/Number.ts +76 -8
  174. package/src/Object.test.ts +115 -1
  175. package/src/Object.ts +44 -0
  176. package/src/Sqlite.ts +1 -1
  177. package/src/Time.test.ts +82 -11
  178. package/src/Time.ts +141 -16
  179. package/src/Type.test.ts +3114 -157
  180. package/src/Type.ts +2553 -196
  181. package/src/index.ts +3 -1
  182. package/src/intl/_en.ts +70 -0
  183. package/src/intl/ar.ts +71 -0
  184. package/src/intl/bn.ts +70 -0
  185. package/src/intl/ca.ts +70 -0
  186. package/src/intl/cs.ts +70 -0
  187. package/src/intl/da.ts +70 -0
  188. package/src/intl/de.ts +70 -0
  189. package/src/intl/el.ts +70 -0
  190. package/src/intl/es.ts +70 -0
  191. package/src/intl/fa.ts +70 -0
  192. package/src/intl/fi.ts +70 -0
  193. package/src/intl/fil.ts +70 -0
  194. package/src/intl/fr.ts +70 -0
  195. package/src/intl/he.ts +70 -0
  196. package/src/intl/hi.ts +70 -0
  197. package/src/intl/hr.ts +70 -0
  198. package/src/intl/hu.ts +69 -0
  199. package/src/intl/id.ts +70 -0
  200. package/src/intl/intl.test.ts +819 -1
  201. package/src/intl/it.ts +70 -0
  202. package/src/intl/ja.ts +70 -0
  203. package/src/intl/ko.ts +68 -0
  204. package/src/intl/ml.ts +70 -0
  205. package/src/intl/mr.ts +70 -0
  206. package/src/intl/ms.ts +71 -0
  207. package/src/intl/nb.ts +69 -0
  208. package/src/intl/nl.ts +70 -0
  209. package/src/intl/pa.ts +70 -0
  210. package/src/intl/pl.ts +63 -0
  211. package/src/intl/pt-BR.ts +70 -0
  212. package/src/intl/pt.ts +71 -0
  213. package/src/intl/ro.ts +70 -0
  214. package/src/intl/sk.ts +71 -0
  215. package/src/intl/sl.ts +70 -0
  216. package/src/intl/sv.ts +70 -0
  217. package/src/intl/sw.ts +62 -0
  218. package/src/intl/ta.ts +70 -0
  219. package/src/intl/te.ts +70 -0
  220. package/src/intl/th.ts +68 -0
  221. package/src/intl/tr.ts +70 -0
  222. package/src/intl/uk.ts +228 -155
  223. package/src/intl/ur.ts +70 -0
  224. package/src/intl/vi.ts +70 -0
  225. package/src/intl/zh-CN.ts +68 -0
  226. package/src/intl/zh-TW.ts +68 -0
  227. package/src/local-first/Protocol.test.ts +1 -1
  228. package/src/local-first/Protocol.ts +1 -1
  229. package/src/local-first/Schema.ts +11 -0
  230. package/src/local-first/Storage.ts +1 -1
  231. package/src/local-first/Timestamp.ts +1 -1
  232. package/dist/src/Binary.d.ts +0 -254
  233. package/dist/src/Binary.d.ts.map +0 -1
package/src/Fs.ts ADDED
@@ -0,0 +1,414 @@
1
+ /**
2
+ * File system operations for {@link Task}s.
3
+ *
4
+ * {@link Fs} reads, writes, copies, and renames files; lists and manages
5
+ * directories; and provides metadata and existence checks. Each operation
6
+ * returns a Task.
7
+ *
8
+ * Tasks can sequence file operations without synchronous I/O. Node.js's
9
+ * synchronous methods are intentionally omitted to avoid accidentally blocking
10
+ * the event loop.
11
+ *
12
+ * Inject {@link @evolu/nodejs!createNodeFs | createNodeFs} through
13
+ * {@link @evolu/nodejs!runMain | runMain} or {@link createRun}. Tasks declare
14
+ * {@link FsDep} and access the file system through `run.deps.fs`.
15
+ *
16
+ * {@link FsError} includes the path, a diagnostic message, and a
17
+ * {@link FsErrorReason} such as `NotFound` or `PermissionDenied`.
18
+ * {@link Fs.exists} returns `false` for `NotFound` and preserves other errors.
19
+ *
20
+ * ### Example
21
+ *
22
+ * ```ts
23
+ * import {
24
+ * assertEqual,
25
+ * ok,
26
+ * type FsDep,
27
+ * type FsError,
28
+ * type Task,
29
+ * } from "@evolu/common";
30
+ * import { createNodeFs, runMain } from "@evolu/nodejs";
31
+ * import { join } from "node:path";
32
+ *
33
+ * const main: Task<void, FsError, FsDep> = async (run) => {
34
+ * const { fs } = run.deps;
35
+ * const temp = await run(
36
+ * fs.createTempDirectory({ prefix: "evolu-fs-" }),
37
+ * );
38
+ * if (!temp.ok) return temp;
39
+ *
40
+ * await using directory = temp.value;
41
+ * const path = join(directory.path, "message.txt");
42
+ *
43
+ * const result = await run(fs.writeFile(path, "hello"));
44
+ * if (!result.ok) return result;
45
+ *
46
+ * const text = await run(fs.readFile(path, "utf8"));
47
+ * if (!text.ok) return text;
48
+ * assertEqual(text.value, "hello");
49
+ *
50
+ * return ok();
51
+ * };
52
+ *
53
+ * await runMain({ fs: createNodeFs() }, { mode: "command" })(main);
54
+ * ```
55
+ *
56
+ * @module
57
+ */
58
+
59
+ import type { ByteLength } from "./Bytes.ts";
60
+ import type { createRun, Task } from "./Task.ts";
61
+ import type { Typed } from "./Type.ts";
62
+
63
+ /** Asynchronous file system operations. */
64
+ export interface Fs {
65
+ /**
66
+ * Reads a whole file, as bytes by default or as a string with an encoding. An
67
+ * abort requests cancellation; pending operating system reads may still
68
+ * finish.
69
+ */
70
+ readonly readFile: FsReadFile;
71
+
72
+ /**
73
+ * Writes a file, creating it or truncating an existing file by default. An
74
+ * abort requests cancellation and can leave the file partially written or
75
+ * truncated.
76
+ */
77
+ readonly writeFile: (
78
+ path: FsPath,
79
+ data: string | Uint8Array,
80
+ options?: FsWriteFileOptions,
81
+ ) => Task<void, FsError>;
82
+
83
+ /**
84
+ * Lists entry names relative to `path`, in unspecified order. With
85
+ * `recursive`, includes nested entries with their relative paths.
86
+ */
87
+ readonly readDirectory: (
88
+ path: FsPath,
89
+ options?: FsReadDirectoryOptions,
90
+ ) => Task<ReadonlyArray<string>, FsError>;
91
+
92
+ /**
93
+ * Creates a directory. Without `recursive`, an existing directory fails with
94
+ * `AlreadyExists` and a missing parent with `NotFound`.
95
+ */
96
+ readonly createDirectory: (
97
+ path: FsPath,
98
+ options?: FsCreateDirectoryOptions,
99
+ ) => Task<void, FsError>;
100
+
101
+ /**
102
+ * Copies a file or directory tree using Node's recursive `cp` semantics. By
103
+ * default, existing directories are merged and files are replaced. Symbolic
104
+ * links follow Node's rules: `force: false` and `errorOnExist` do not
105
+ * guarantee that destination links are preserved.
106
+ *
107
+ * Copying is neither exclusive nor atomic, and a failure can leave a partial
108
+ * copy. Use {@link Fs.copyFile} for exclusive creation of a single file.
109
+ */
110
+ readonly copy: (
111
+ source: FsPath,
112
+ destination: FsPath,
113
+ options?: FsCopyOptions,
114
+ ) => Task<void, FsError>;
115
+
116
+ /**
117
+ * Copies a single file. An existing destination fails with `AlreadyExists`
118
+ * unless `overwrite` is enabled. Without overwrite, destination creation is
119
+ * exclusive even when other copies run concurrently. File contents are not
120
+ * published atomically; a failure can leave a partial copy.
121
+ */
122
+ readonly copyFile: (
123
+ source: FsPath,
124
+ destination: FsPath,
125
+ options?: FsCopyFileOptions,
126
+ ) => Task<void, FsError>;
127
+
128
+ /**
129
+ * Renames or moves a file or directory using the platform's rename semantics.
130
+ * An existing destination file can be replaced.
131
+ */
132
+ readonly rename: (source: FsPath, destination: FsPath) => Task<void, FsError>;
133
+
134
+ /**
135
+ * Removes a file, or a directory with `recursive`. Removing a directory
136
+ * without `recursive` fails with `IsDirectory`. With `force`, a missing path
137
+ * succeeds.
138
+ */
139
+ readonly remove: (
140
+ path: FsPath,
141
+ options?: FsRemoveOptions,
142
+ ) => Task<void, FsError>;
143
+
144
+ /** Reads file metadata, following symbolic links. */
145
+ readonly getMetadata: (path: FsPath) => Task<FsMetadata, FsError>;
146
+
147
+ /**
148
+ * Checks whether a path exists. `NotFound` produces `false`; other errors are
149
+ * returned. A `true` result does not establish read or write permission.
150
+ */
151
+ readonly exists: (path: FsPath) => Task<boolean, FsError>;
152
+
153
+ /**
154
+ * Creates a unique directory in the system temporary directory by default.
155
+ * Options can specify a parent directory and a name prefix. The returned
156
+ * resource removes the directory and its contents on disposal. Use `await
157
+ * using` for cleanup; disposal can throw if removal fails.
158
+ *
159
+ * Once started, this operation returns its result even if its Run aborts.
160
+ */
161
+ readonly createTempDirectory: (
162
+ options?: FsCreateTempDirectoryOptions,
163
+ ) => Task<FsTempDirectory, FsError>;
164
+ }
165
+
166
+ /** Dependency wrapper for {@link Fs}. */
167
+ export interface FsDep {
168
+ readonly fs: Fs;
169
+ }
170
+
171
+ /** A file system path, or a `file:` URL. */
172
+ export type FsPath = string | URL;
173
+
174
+ /** Supported text encodings. */
175
+ export type FsEncoding =
176
+ | "ascii"
177
+ | "utf8"
178
+ | "utf-8"
179
+ | "utf16le"
180
+ | "utf-16le"
181
+ | "ucs2"
182
+ | "ucs-2"
183
+ | "base64"
184
+ | "base64url"
185
+ | "latin1"
186
+ | "binary"
187
+ | "hex";
188
+
189
+ /** Supported file opening modes for {@link Fs.writeFile}. */
190
+ export type FsOpenFlag =
191
+ | "a"
192
+ | "ax"
193
+ | "a+"
194
+ | "ax+"
195
+ | "as"
196
+ | "as+"
197
+ | "r"
198
+ | "r+"
199
+ | "rs+"
200
+ | "w"
201
+ | "wx"
202
+ | "w+"
203
+ | "wx+";
204
+
205
+ /** Reads bytes by default, or text when an encoding is specified. */
206
+ export interface FsReadFile {
207
+ (path: FsPath): Task<Uint8Array, FsError>;
208
+ (
209
+ path: FsPath,
210
+ encoding: FsEncoding | { readonly encoding: FsEncoding },
211
+ ): Task<string, FsError>;
212
+ }
213
+
214
+ /** Options for {@link Fs.writeFile}. */
215
+ export interface FsWriteFileOptions {
216
+ /** Encoding of string data. Defaults to `utf8`. */
217
+ readonly encoding?: FsEncoding;
218
+ /** File mode of a created file. Defaults to `0o666`. */
219
+ readonly mode?: number;
220
+ /** Open flag. Defaults to `w`; use `wx` to fail when the file exists. */
221
+ readonly flag?: FsOpenFlag;
222
+ }
223
+
224
+ /** Options for {@link Fs.readDirectory}. */
225
+ export interface FsReadDirectoryOptions {
226
+ /** Includes entries from nested directories. Defaults to `false`. */
227
+ readonly recursive?: boolean;
228
+ }
229
+
230
+ /** Options for {@link Fs.createDirectory}. */
231
+ export interface FsCreateDirectoryOptions {
232
+ /** Creates missing parents and accepts an existing directory. */
233
+ readonly recursive?: boolean;
234
+ /** Directory mode. Defaults to `0o777`. */
235
+ readonly mode?: number;
236
+ }
237
+
238
+ /** Options for {@link Fs.copy}. */
239
+ export interface FsCopyOptions {
240
+ /**
241
+ * Node's `force` option. Replaces existing files; `false` skips them unless
242
+ * `errorOnExist` is enabled. Defaults to `true`. This does not protect
243
+ * destination symbolic links.
244
+ */
245
+ readonly force?: boolean;
246
+ /**
247
+ * Node's `errorOnExist` option. With `force: false`, existing files and
248
+ * directories fail with `AlreadyExists`. Defaults to `false`. Symbolic links
249
+ * retain Node's behavior and may still be replaced.
250
+ */
251
+ readonly errorOnExist?: boolean;
252
+ /** Preserves access and modification times. Defaults to `false`. */
253
+ readonly preserveTimestamps?: boolean;
254
+ }
255
+
256
+ /** Options for {@link Fs.copyFile}. */
257
+ export interface FsCopyFileOptions {
258
+ /** Replaces an existing destination file. Defaults to `false`. */
259
+ readonly overwrite?: boolean;
260
+ }
261
+
262
+ /** Options for {@link Fs.remove}. */
263
+ export interface FsRemoveOptions {
264
+ /** Removes directories and their contents. */
265
+ readonly recursive?: boolean;
266
+ /** Ignores a missing path. */
267
+ readonly force?: boolean;
268
+ /**
269
+ * Number of retries for `EBUSY`, `EMFILE`, `ENFILE`, `ENOTEMPTY`, or `EPERM`
270
+ * on Node.js. Applies only with `recursive: true`. Defaults to `0`.
271
+ */
272
+ readonly maxRetries?: number;
273
+ /**
274
+ * Base retry delay in milliseconds. Each retry waits one additional interval.
275
+ * Applies only with `recursive: true`. Defaults to `100`.
276
+ */
277
+ readonly retryDelay?: number;
278
+ }
279
+
280
+ /** File metadata as data, with Node's numeric and timestamp field names. */
281
+ export interface FsMetadata {
282
+ readonly type: FsEntryType;
283
+ readonly dev: number;
284
+ readonly ino: number;
285
+ readonly mode: number;
286
+ readonly nlink: number;
287
+ readonly uid: number;
288
+ readonly gid: number;
289
+ readonly rdev: number;
290
+ readonly size: ByteLength;
291
+ readonly blksize: number;
292
+ readonly blocks: number;
293
+ readonly atimeMs: number;
294
+ readonly mtimeMs: number;
295
+ readonly ctimeMs: number;
296
+ readonly birthtimeMs: number;
297
+ readonly atime: Date;
298
+ readonly mtime: Date;
299
+ readonly ctime: Date;
300
+ readonly birthtime: Date;
301
+ }
302
+
303
+ /** The kind of file system entry described by {@link FsMetadata}. */
304
+ export type FsEntryType =
305
+ | "File"
306
+ | "Directory"
307
+ | "SymbolicLink"
308
+ | "BlockDevice"
309
+ | "CharacterDevice"
310
+ | "FIFO"
311
+ | "Socket"
312
+ | "Unknown";
313
+
314
+ /** Options for {@link Fs.createTempDirectory}. */
315
+ export interface FsCreateTempDirectoryOptions {
316
+ /** Existing parent directory. Defaults to the system temporary directory. */
317
+ readonly directory?: string;
318
+ /** Prefix for the directory name. Defaults to an empty string. */
319
+ readonly prefix?: string;
320
+ }
321
+
322
+ /** A temporary directory removed, with its contents, on asynchronous disposal. */
323
+ export interface FsTempDirectory extends AsyncDisposable {
324
+ readonly path: string;
325
+ }
326
+
327
+ /** A failed file system operation. */
328
+ export interface FsError extends Typed<"FsError"> {
329
+ readonly reason: FsErrorReason;
330
+ /**
331
+ * The operation's path, or source for copying and renaming. URL inputs use
332
+ * their `href`. Temporary directories use the supplied parent if its
333
+ * resolution fails, or the resolved parent combined with the name prefix if
334
+ * creation fails.
335
+ */
336
+ readonly path: string;
337
+ /**
338
+ * Destination for copying and renaming, with URL inputs represented by
339
+ * `href`.
340
+ */
341
+ readonly destination?: string;
342
+ /** The failing system call, or the method name when the platform reports none. */
343
+ readonly syscall: string;
344
+ /** The platform's diagnostic message. */
345
+ readonly message: string;
346
+ }
347
+
348
+ /** Why a file system operation failed, mapped from the platform's error code. */
349
+ export type FsErrorReason =
350
+ | "NotFound"
351
+ | "AlreadyExists"
352
+ | "PermissionDenied"
353
+ | "IsDirectory"
354
+ | "NotDirectory"
355
+ | "NotEmpty"
356
+ | "Busy"
357
+ | "Unknown";
358
+
359
+ /**
360
+ * Creates a test {@link Fs} with the supplied operation overrides.
361
+ *
362
+ * Unconfigured operations throw a defect naming the method when their Task
363
+ * runs. Constructing a Task does not execute it. This helper performs no file
364
+ * system I/O; overrides provide the behavior needed by each test.
365
+ *
366
+ * ### Example
367
+ *
368
+ * ```ts
369
+ * import {
370
+ * assertEqual,
371
+ * assertOk,
372
+ * ok,
373
+ * testCreateFs,
374
+ * testCreateRun,
375
+ * type FsDep,
376
+ * type FsError,
377
+ * type Task,
378
+ * } from "@evolu/common";
379
+ *
380
+ * const saveMessage: Task<void, FsError, FsDep> = (run) =>
381
+ * run(run.deps.fs.writeFile("message.txt", "hello"));
382
+ *
383
+ * await using run = testCreateRun({
384
+ * fs: testCreateFs({
385
+ * writeFile: (path, data) => () => {
386
+ * assertEqual(path, "message.txt");
387
+ * assertEqual(data, "hello");
388
+ * return ok();
389
+ * },
390
+ * }),
391
+ * });
392
+ *
393
+ * assertOk(await run(saveMessage));
394
+ * ```
395
+ */
396
+ export const testCreateFs = (overrides: Partial<Fs> = {}): Fs => ({
397
+ readFile: createUnexpectedFsOperation("readFile"),
398
+ writeFile: createUnexpectedFsOperation("writeFile"),
399
+ readDirectory: createUnexpectedFsOperation("readDirectory"),
400
+ createDirectory: createUnexpectedFsOperation("createDirectory"),
401
+ copy: createUnexpectedFsOperation("copy"),
402
+ copyFile: createUnexpectedFsOperation("copyFile"),
403
+ rename: createUnexpectedFsOperation("rename"),
404
+ remove: createUnexpectedFsOperation("remove"),
405
+ getMetadata: createUnexpectedFsOperation("getMetadata"),
406
+ exists: createUnexpectedFsOperation("exists"),
407
+ createTempDirectory: createUnexpectedFsOperation("createTempDirectory"),
408
+ ...overrides,
409
+ });
410
+
411
+ const createUnexpectedFsOperation =
412
+ (method: keyof Fs) => (): Task<never> => () => {
413
+ throw new Error(`Unexpected Fs.${method} call`);
414
+ };
@@ -1,6 +1,7 @@
1
- import { test } from "node:test";
1
+ import { describe, test } from "node:test";
2
2
  import {
3
3
  assertEqual,
4
+ assertErr,
4
5
  assertFalse,
5
6
  assertOk,
6
7
  assertSame,
@@ -26,7 +27,16 @@ import {
26
27
  percentageToRatio,
27
28
  } from "./Number.ts";
28
29
  import { err, ok } from "./Result.ts";
29
- import { assertType, NonNegativeInt, PositiveInt, Ratio } from "./Type.ts";
30
+ import {
31
+ localizeTypes,
32
+ object,
33
+ typeErrorToIssues,
34
+ type InferErrors,
35
+ assertType,
36
+ NonNegativeInt,
37
+ PositiveInt,
38
+ Ratio,
39
+ } from "./Type.ts";
30
40
 
31
41
  test("bounded integer literal types", () => {
32
42
  assertType<1 extends Int1To99 ? true : false, true>();
@@ -53,22 +63,76 @@ test("bounded integer literal types", () => {
53
63
  assertType<PositiveInt extends Int1To100OrPositiveInt ? true : false, true>();
54
64
  });
55
65
 
56
- test("Percentage accepts canonical literals or Ratio", () => {
57
- assertType<"0%" extends PercentageLiteral ? true : false, true>();
58
- assertType<"25%" extends PercentageLiteral ? true : false, true>();
59
- assertType<"12.5%" extends PercentageLiteral ? true : false, true>();
60
- assertType<"100%" extends PercentageLiteral ? true : false, true>();
61
- assertType<"01%" extends PercentageLiteral ? true : false, false>();
62
- assertType<"10.0%" extends PercentageLiteral ? true : false, false>();
63
- assertType<"100.1%" extends PercentageLiteral ? true : false, false>();
64
- assertType<Ratio extends Percentage ? true : false, true>();
65
- assertTrue(PercentageLiteral.is("0%"));
66
- assertTrue(PercentageLiteral.is("25%"));
67
- assertTrue(PercentageLiteral.is("12.5%"));
68
- assertTrue(PercentageLiteral.is("100%"));
69
- assertFalse(PercentageLiteral.is("01%"));
70
- assertFalse(PercentageLiteral.is("10.0%"));
71
- assertFalse(PercentageLiteral.is("100.1%"));
66
+ describe("PercentageLiteral", () => {
67
+ test("reports a dedicated error with the original validation failure", () => {
68
+ for (const value of ["101%", "", 1, null, undefined]) {
69
+ const result = PercentageLiteral.fromUnknown(value);
70
+ assertErr(result);
71
+ assertType<typeof result.error.type, "PercentageLiteral">();
72
+ assertSame(result.error.value, value);
73
+ assertEqual(result.error.cause.type, "Union");
74
+ assertEqual(result.error.cause.errors.length, 1);
75
+ }
76
+ const result = PercentageLiteral.fromUnknown("101%", { errors: "all" });
77
+ assertErr(result);
78
+ assertEqual(result.error.cause.errors.length, 4);
79
+ assertEqual(
80
+ PercentageLiteral.formatError(result.error),
81
+ 'The value "101%" is not a percentage literal. Use a value such as "50%" or "12.5%".',
82
+ );
83
+ assertType<
84
+ InferErrors<typeof PercentageLiteral>["type"],
85
+ "PercentageLiteral"
86
+ >();
87
+ assertOk(PercentageLiteral.from("12.5%"), "12.5%");
88
+ assertEqual(PercentageLiteral.to("12.5%"), "12.5%");
89
+ // @ts-expect-error PercentageLiteral Input rejects "101%".
90
+ const _invalidInput: typeof PercentageLiteral.Input = "101%";
91
+ });
92
+
93
+ test("localizes the named error after composition and preserves its path", async () => {
94
+ const { first } = localizeTypes(
95
+ { Value: PercentageLiteral },
96
+ { first: { PercentageLiteral: () => "First message." } },
97
+ );
98
+ const { second } = localizeTypes(
99
+ { Settings: object({ value: first.Value }) },
100
+ {
101
+ second: {
102
+ Object: () => "Object.",
103
+ PercentageLiteral: () => "Second message.",
104
+ },
105
+ },
106
+ );
107
+ const result = second.Settings.fromUnknown({ value: "101%" });
108
+ assertErr(result);
109
+ assertEqual(second.Settings.formatError(result.error), "Second message.");
110
+ assertEqual(typeErrorToIssues(second.Settings, result.error), [
111
+ { path: ["value"], message: "Second message." },
112
+ ]);
113
+ assertEqual(
114
+ await second.Settings["~standard"].validate({ value: "101%" }),
115
+ { issues: [{ path: ["value"], message: "Second message." }] },
116
+ );
117
+ });
118
+
119
+ test("accepts canonical literals or Ratio", () => {
120
+ assertType<"0%" extends PercentageLiteral ? true : false, true>();
121
+ assertType<"25%" extends PercentageLiteral ? true : false, true>();
122
+ assertType<"12.5%" extends PercentageLiteral ? true : false, true>();
123
+ assertType<"100%" extends PercentageLiteral ? true : false, true>();
124
+ assertType<"01%" extends PercentageLiteral ? true : false, false>();
125
+ assertType<"10.0%" extends PercentageLiteral ? true : false, false>();
126
+ assertType<"100.1%" extends PercentageLiteral ? true : false, false>();
127
+ assertType<Ratio extends Percentage ? true : false, true>();
128
+ assertTrue(PercentageLiteral.is("0%"));
129
+ assertTrue(PercentageLiteral.is("25%"));
130
+ assertTrue(PercentageLiteral.is("12.5%"));
131
+ assertTrue(PercentageLiteral.is("100%"));
132
+ assertFalse(PercentageLiteral.is("01%"));
133
+ assertFalse(PercentageLiteral.is("10.0%"));
134
+ assertFalse(PercentageLiteral.is("100.1%"));
135
+ });
72
136
  });
73
137
 
74
138
  test("percentageToRatio converts percentage literals and preserves Ratio", () => {
package/src/Number.ts CHANGED
@@ -7,10 +7,15 @@
7
7
  import type { NonEmptyReadonlyArray } from "./Array.ts";
8
8
  import { assertNonEmptyReadonlyArray } from "./Assert.ts";
9
9
  import type { IsBranded } from "./Brand.ts";
10
+ import { safelyStringifyUnknownValue } from "./String.ts";
10
11
  import type { Result } from "./Result.ts";
11
12
  import { err, ok } from "./Result.ts";
12
13
  import {
13
14
  brand,
15
+ createTypeWithError,
16
+ type Type,
17
+ type TypeError,
18
+ type UnionError,
14
19
  Digit1To9,
15
20
  Digit1To99,
16
21
  lessThanOrEqualTo,
@@ -98,13 +103,10 @@ export type Int1To100OrPositiveInt = Int1To100 | PositiveInt;
98
103
  */
99
104
  export type Percentage = PercentageLiteral | Ratio;
100
105
 
101
- /**
102
- * Percentage literal from `"0%"` to `"100%"`.
103
- *
104
- * Decimal literals support one decimal place. Use {@link Ratio} for computed
105
- * values or greater precision.
106
- */
107
- export const PercentageLiteral = /*#__PURE__*/ union(
106
+ export type PercentageLiteral =
107
+ "0%" | "100%" | `${Digit1To99}%` | `${"0" | Digit1To99}.${Digit1To9}%`;
108
+
109
+ const percentageLiteralSyntax = /*#__PURE__*/ union(
108
110
  "0%",
109
111
  "100%",
110
112
  /*#__PURE__*/ templateLiteral(Digit1To99, "%"),
@@ -115,7 +117,73 @@ export const PercentageLiteral = /*#__PURE__*/ union(
115
117
  "%",
116
118
  ),
117
119
  );
118
- export type PercentageLiteral = typeof PercentageLiteral.Output;
120
+
121
+ /**
122
+ * Percentage literal Type with compile-time and runtime validation.
123
+ *
124
+ * Supported formats:
125
+ *
126
+ * - Integers: `0%`, `50%`, `100%` (0-100)
127
+ * - One decimal place: `0.1%`, `12.5%`, `99.9%` (0.1-99.9)
128
+ *
129
+ * The decimal digit is never zero, so `12.0%` must be written as `"12%"`, and
130
+ * `100%` has no decimal form. For computed values or more precision, use
131
+ * {@link Ratio} directly.
132
+ *
133
+ * See {@link Percentage} for a type that also accepts {@link Ratio}. Use
134
+ * {@link percentageToRatio} to convert.
135
+ *
136
+ * Invalid values produce a {@link PercentageLiteralError}.
137
+ *
138
+ * ### Example
139
+ *
140
+ * ```ts
141
+ * import {
142
+ * assertFalse,
143
+ * assertOk,
144
+ * assertType,
145
+ * PercentageLiteral,
146
+ * } from "@evolu/common";
147
+ *
148
+ * // The TypeScript type accepts valid spellings and rejects the rest.
149
+ * const literal: PercentageLiteral = "12.5%";
150
+ * assertType<
151
+ * Extract<PercentageLiteral, "101%" | "12.0%" | "12.55%">,
152
+ * never
153
+ * >();
154
+ *
155
+ * // The runtime Type validates the same grammar.
156
+ * assertOk(PercentageLiteral.fromUnknown(literal), "12.5%");
157
+ * assertFalse(PercentageLiteral.is("101%"));
158
+ * ```
159
+ */
160
+ export const PercentageLiteral: Type<
161
+ "PercentageLiteral",
162
+ PercentageLiteral,
163
+ PercentageLiteral,
164
+ PercentageLiteralError
165
+ > = /*#__PURE__*/ createTypeWithError(
166
+ "PercentageLiteral",
167
+ percentageLiteralSyntax,
168
+ (cause, value): PercentageLiteralError => ({
169
+ type: "PercentageLiteral",
170
+ value,
171
+ cause,
172
+ }),
173
+ (error) =>
174
+ `The value ${safelyStringifyUnknownValue(error.value)} is not a percentage literal. Use a value such as "50%" or "12.5%".`,
175
+ );
176
+
177
+ /** Error returned when {@link PercentageLiteral} rejects a value. */
178
+ export interface PercentageLiteralError extends TypeError<"PercentageLiteral"> {
179
+ readonly value: unknown;
180
+ /**
181
+ * The underlying union failure, retained for diagnostics.
182
+ *
183
+ * With `{ errors: "all" }`, includes every failed alternative.
184
+ */
185
+ readonly cause: UnionError;
186
+ }
119
187
 
120
188
  /** Converts a {@link Percentage} to its numeric {@link Ratio}. */
121
189
  export const percentageToRatio = (percentage: Percentage): Ratio =>