@evolu/nodejs 3.1.0 → 4.0.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.
- package/README.md +10 -1
- package/dist/src/Fs.d.ts +63 -0
- package/dist/src/Fs.d.ts.map +1 -0
- package/dist/src/Fs.js +217 -0
- package/dist/src/Task.d.ts +51 -38
- package/dist/src/Task.d.ts.map +1 -1
- package/dist/src/Task.js +2 -2
- package/dist/src/TestBundle.d.ts.map +1 -1
- package/dist/src/TestBundle.js +9 -12
- package/dist/src/TestJSDoc.d.ts +4 -1
- package/dist/src/TestJSDoc.d.ts.map +1 -1
- package/dist/src/TestJSDoc.js +1 -0
- package/dist/src/TestOverviewReporter.d.ts +23 -0
- package/dist/src/TestOverviewReporter.d.ts.map +1 -0
- package/dist/src/TestOverviewReporter.js +66 -0
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +1 -0
- package/dist/src/local-first/Relay.d.ts +15 -27
- package/dist/src/local-first/Relay.d.ts.map +1 -1
- package/dist/src/local-first/Relay.js +10 -25
- package/package.json +17 -8
- package/src/Cli.test.ts +90 -0
- package/src/Fs.test.ts +1019 -0
- package/src/Fs.ts +300 -0
- package/src/Platform.test.ts +32 -0
- package/src/Task.ts +62 -45
- package/src/TestBundle.ts +11 -15
- package/src/TestJSDoc.ts +7 -1
- package/src/TestOverviewReporter.test.ts +219 -0
- package/src/TestOverviewReporter.ts +86 -0
- package/src/Time.test.ts +67 -0
- package/src/index.ts +1 -0
- package/src/local-first/Relay.ts +15 -26
package/src/Fs.ts
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File system operations using Node.js.
|
|
3
|
+
*
|
|
4
|
+
* @module
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
assert,
|
|
9
|
+
ByteLength,
|
|
10
|
+
disposable,
|
|
11
|
+
err,
|
|
12
|
+
ok,
|
|
13
|
+
tryAsync,
|
|
14
|
+
type Fs,
|
|
15
|
+
type FsEncoding,
|
|
16
|
+
type FsEntryType,
|
|
17
|
+
type FsError,
|
|
18
|
+
type FsErrorReason,
|
|
19
|
+
type FsMetadata,
|
|
20
|
+
type FsPath,
|
|
21
|
+
type Task,
|
|
22
|
+
} from "@evolu/common";
|
|
23
|
+
import { constants, type Stats } from "node:fs";
|
|
24
|
+
import {
|
|
25
|
+
access,
|
|
26
|
+
copyFile,
|
|
27
|
+
cp,
|
|
28
|
+
mkdir,
|
|
29
|
+
mkdtemp,
|
|
30
|
+
readdir,
|
|
31
|
+
readFile,
|
|
32
|
+
realpath,
|
|
33
|
+
rename,
|
|
34
|
+
rm,
|
|
35
|
+
stat,
|
|
36
|
+
writeFile,
|
|
37
|
+
} from "node:fs/promises";
|
|
38
|
+
import { tmpdir } from "node:os";
|
|
39
|
+
import { join, sep } from "node:path";
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Creates a {@link Fs} backed by `node:fs/promises`.
|
|
43
|
+
*
|
|
44
|
+
* `readFile` and `writeFile` pass the Run's abort signal to Node and propagate
|
|
45
|
+
* the Run's abort reason if Node rejects after cancellation. Cancellation can
|
|
46
|
+
* leave a write partially completed. Successful operations return their values
|
|
47
|
+
* even if an abort was requested. Other operations run to completion and return
|
|
48
|
+
* their results once started, so callers also receive created temporary
|
|
49
|
+
* directories and can dispose them.
|
|
50
|
+
*
|
|
51
|
+
* Temporary directory parents are resolved through the file system, preserving
|
|
52
|
+
* the meaning of symbolic links followed by `..`. Returned paths are absolute,
|
|
53
|
+
* so cleanup still targets the created directory after a change to the
|
|
54
|
+
* process's working directory.
|
|
55
|
+
*
|
|
56
|
+
* ### Example
|
|
57
|
+
*
|
|
58
|
+
* ```ts
|
|
59
|
+
* import {
|
|
60
|
+
* assertEqual,
|
|
61
|
+
* assertFalse,
|
|
62
|
+
* ok,
|
|
63
|
+
* type FsDep,
|
|
64
|
+
* type FsError,
|
|
65
|
+
* type Task,
|
|
66
|
+
* } from "@evolu/common";
|
|
67
|
+
* import { createNodeFs, runMain } from "@evolu/nodejs";
|
|
68
|
+
* import { join } from "node:path";
|
|
69
|
+
*
|
|
70
|
+
* const main: Task<void, FsError, FsDep> = async (run) => {
|
|
71
|
+
* const { fs } = run.deps;
|
|
72
|
+
* const temp = await run(fs.createTempDirectory({ prefix: "evolu-" }));
|
|
73
|
+
* if (!temp.ok) return temp;
|
|
74
|
+
*
|
|
75
|
+
* {
|
|
76
|
+
* await using directory = temp.value;
|
|
77
|
+
* const path = join(directory.path, "config.json");
|
|
78
|
+
*
|
|
79
|
+
* const result = await run(fs.writeFile(path, '{ "port": 4000 }'));
|
|
80
|
+
* if (!result.ok) return result;
|
|
81
|
+
*
|
|
82
|
+
* const text = await run(fs.readFile(path, "utf8"));
|
|
83
|
+
* if (!text.ok) return text;
|
|
84
|
+
* assertEqual(text.value, '{ "port": 4000 }');
|
|
85
|
+
* }
|
|
86
|
+
*
|
|
87
|
+
* const exists = await run(fs.exists(temp.value.path));
|
|
88
|
+
* if (!exists.ok) return exists;
|
|
89
|
+
* assertFalse(exists.value);
|
|
90
|
+
* return ok();
|
|
91
|
+
* };
|
|
92
|
+
*
|
|
93
|
+
* await runMain({ fs: createNodeFs() }, { mode: "command" })(main);
|
|
94
|
+
* ```
|
|
95
|
+
*/
|
|
96
|
+
export const createNodeFs = (): Fs => {
|
|
97
|
+
function readFileTask(path: FsPath): Task<Uint8Array, FsError>;
|
|
98
|
+
function readFileTask(
|
|
99
|
+
path: FsPath,
|
|
100
|
+
encoding: FsEncoding | { readonly encoding: FsEncoding },
|
|
101
|
+
): Task<string, FsError>;
|
|
102
|
+
function readFileTask(
|
|
103
|
+
path: FsPath,
|
|
104
|
+
encoding?: FsEncoding | { readonly encoding: FsEncoding },
|
|
105
|
+
): Task<Uint8Array | string, FsError> {
|
|
106
|
+
return async (run) => {
|
|
107
|
+
const { signal } = run;
|
|
108
|
+
const result = await tryAsync((): Promise<Uint8Array | string> =>
|
|
109
|
+
encoding === undefined
|
|
110
|
+
? readFile(path, { signal })
|
|
111
|
+
: readFile(path, { ...toEncodingOptions(encoding), signal }),
|
|
112
|
+
);
|
|
113
|
+
if (result.ok) return result;
|
|
114
|
+
signal.throwIfAborted();
|
|
115
|
+
return err(createFsError("readFile", path, result.error));
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
readFile: readFileTask,
|
|
121
|
+
|
|
122
|
+
writeFile: (path, data, options) => async (run) => {
|
|
123
|
+
const { signal } = run;
|
|
124
|
+
const result = await tryAsync(() =>
|
|
125
|
+
writeFile(path, data, { ...options, signal }),
|
|
126
|
+
);
|
|
127
|
+
if (result.ok) return result;
|
|
128
|
+
signal.throwIfAborted();
|
|
129
|
+
return err(createFsError("writeFile", path, result.error));
|
|
130
|
+
},
|
|
131
|
+
|
|
132
|
+
readDirectory: (path, options) => async () => {
|
|
133
|
+
const result = await tryAsync(() => readdir(path, options));
|
|
134
|
+
return result.ok
|
|
135
|
+
? result
|
|
136
|
+
: err(createFsError("readDirectory", path, result.error));
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
createDirectory: (path, options) => async () => {
|
|
140
|
+
const result = await tryAsync(async () => {
|
|
141
|
+
await mkdir(path, options);
|
|
142
|
+
});
|
|
143
|
+
return result.ok
|
|
144
|
+
? ok()
|
|
145
|
+
: err(createFsError("createDirectory", path, result.error));
|
|
146
|
+
},
|
|
147
|
+
|
|
148
|
+
copy: (source, destination, options) => async () => {
|
|
149
|
+
const result = await tryAsync(() =>
|
|
150
|
+
cp(source, destination, { ...options, recursive: true }),
|
|
151
|
+
);
|
|
152
|
+
return result.ok
|
|
153
|
+
? ok()
|
|
154
|
+
: err(createFsError("copy", source, result.error, destination));
|
|
155
|
+
},
|
|
156
|
+
|
|
157
|
+
copyFile:
|
|
158
|
+
(source, destination, { overwrite = false } = {}) =>
|
|
159
|
+
async () => {
|
|
160
|
+
const result = await tryAsync(() =>
|
|
161
|
+
copyFile(
|
|
162
|
+
source,
|
|
163
|
+
destination,
|
|
164
|
+
overwrite ? 0 : constants.COPYFILE_EXCL,
|
|
165
|
+
),
|
|
166
|
+
);
|
|
167
|
+
return result.ok
|
|
168
|
+
? ok()
|
|
169
|
+
: err(createFsError("copyFile", source, result.error, destination));
|
|
170
|
+
},
|
|
171
|
+
|
|
172
|
+
rename: (source, destination) => async () => {
|
|
173
|
+
const result = await tryAsync(() => rename(source, destination));
|
|
174
|
+
return result.ok
|
|
175
|
+
? ok()
|
|
176
|
+
: err(createFsError("rename", source, result.error, destination));
|
|
177
|
+
},
|
|
178
|
+
|
|
179
|
+
remove: (path, options) => async () => {
|
|
180
|
+
const result = await tryAsync(() => rm(path, options));
|
|
181
|
+
return result.ok
|
|
182
|
+
? ok()
|
|
183
|
+
: err(createFsError("remove", path, result.error));
|
|
184
|
+
},
|
|
185
|
+
|
|
186
|
+
getMetadata: (path) => async () => {
|
|
187
|
+
const result = await tryAsync(() => stat(path));
|
|
188
|
+
return result.ok
|
|
189
|
+
? ok(statsToFsMetadata(result.value))
|
|
190
|
+
: err(createFsError("getMetadata", path, result.error));
|
|
191
|
+
},
|
|
192
|
+
|
|
193
|
+
exists: (path) => async () => {
|
|
194
|
+
const result = await tryAsync(() => access(path));
|
|
195
|
+
if (result.ok) return ok(true);
|
|
196
|
+
const error = createFsError("exists", path, result.error);
|
|
197
|
+
return error.reason === "NotFound" ? ok(false) : err(error);
|
|
198
|
+
},
|
|
199
|
+
|
|
200
|
+
createTempDirectory:
|
|
201
|
+
({ directory, prefix = "" } = {}) =>
|
|
202
|
+
async () => {
|
|
203
|
+
const parent = directory ?? tmpdir();
|
|
204
|
+
const resolvedParent = await tryAsync(
|
|
205
|
+
() => realpath(parent || "."),
|
|
206
|
+
(error) => createFsError("createTempDirectory", parent, error),
|
|
207
|
+
);
|
|
208
|
+
if (!resolvedParent.ok) return resolvedParent;
|
|
209
|
+
|
|
210
|
+
// Keep the parent separator even when the name prefix is empty.
|
|
211
|
+
const pathPrefix = join(resolvedParent.value, sep) + prefix;
|
|
212
|
+
const result = await tryAsync(
|
|
213
|
+
() => mkdtemp(pathPrefix),
|
|
214
|
+
(error) => createFsError("createTempDirectory", pathPrefix, error),
|
|
215
|
+
);
|
|
216
|
+
if (!result.ok) return result;
|
|
217
|
+
|
|
218
|
+
const path = result.value;
|
|
219
|
+
const disposer = new AsyncDisposableStack();
|
|
220
|
+
disposer.defer(() => rm(path, { recursive: true, force: true }));
|
|
221
|
+
return ok(disposable({ path }, disposer));
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const toEncodingOptions = (
|
|
227
|
+
encoding: FsEncoding | { readonly encoding: FsEncoding },
|
|
228
|
+
): { readonly encoding: FsEncoding } =>
|
|
229
|
+
typeof encoding === "string" ? { encoding } : encoding;
|
|
230
|
+
|
|
231
|
+
const fsErrorReasonByCode: Readonly<Record<string, FsErrorReason>> = {
|
|
232
|
+
ENOENT: "NotFound",
|
|
233
|
+
EEXIST: "AlreadyExists",
|
|
234
|
+
EACCES: "PermissionDenied",
|
|
235
|
+
EPERM: "PermissionDenied",
|
|
236
|
+
EISDIR: "IsDirectory",
|
|
237
|
+
ERR_FS_EISDIR: "IsDirectory",
|
|
238
|
+
ERR_FS_CP_EEXIST: "AlreadyExists",
|
|
239
|
+
ERR_FS_CP_NON_DIR_TO_DIR: "IsDirectory",
|
|
240
|
+
ERR_FS_CP_DIR_TO_NON_DIR: "NotDirectory",
|
|
241
|
+
ENOTDIR: "NotDirectory",
|
|
242
|
+
ENOTEMPTY: "NotEmpty",
|
|
243
|
+
EBUSY: "Busy",
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const createFsError = (
|
|
247
|
+
method: string,
|
|
248
|
+
path: FsPath,
|
|
249
|
+
error: unknown,
|
|
250
|
+
destination?: FsPath,
|
|
251
|
+
): FsError => {
|
|
252
|
+
assert(error instanceof Error, "Node fs rejects with an Error.");
|
|
253
|
+
const { code, syscall } = error as NodeJS.ErrnoException;
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
type: "FsError",
|
|
257
|
+
reason: fsErrorReasonByCode[String(code)] ?? "Unknown",
|
|
258
|
+
path: typeof path === "string" ? path : path.href,
|
|
259
|
+
...(destination === undefined
|
|
260
|
+
? {}
|
|
261
|
+
: {
|
|
262
|
+
destination:
|
|
263
|
+
typeof destination === "string" ? destination : destination.href,
|
|
264
|
+
}),
|
|
265
|
+
syscall: syscall ?? method,
|
|
266
|
+
message: error.message,
|
|
267
|
+
};
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
const fsEntryTypeByMode: Readonly<Record<number, FsEntryType>> = {
|
|
271
|
+
[constants.S_IFREG]: "File",
|
|
272
|
+
[constants.S_IFDIR]: "Directory",
|
|
273
|
+
[constants.S_IFLNK]: "SymbolicLink",
|
|
274
|
+
[constants.S_IFBLK]: "BlockDevice",
|
|
275
|
+
[constants.S_IFCHR]: "CharacterDevice",
|
|
276
|
+
[constants.S_IFIFO]: "FIFO",
|
|
277
|
+
[constants.S_IFSOCK]: "Socket",
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
const statsToFsMetadata = (stats: Stats): FsMetadata => ({
|
|
281
|
+
type: fsEntryTypeByMode[stats.mode & constants.S_IFMT] ?? "Unknown",
|
|
282
|
+
dev: stats.dev,
|
|
283
|
+
ino: stats.ino,
|
|
284
|
+
mode: stats.mode,
|
|
285
|
+
nlink: stats.nlink,
|
|
286
|
+
uid: stats.uid,
|
|
287
|
+
gid: stats.gid,
|
|
288
|
+
rdev: stats.rdev,
|
|
289
|
+
size: ByteLength.orThrow(stats.size),
|
|
290
|
+
blksize: stats.blksize,
|
|
291
|
+
blocks: stats.blocks,
|
|
292
|
+
atimeMs: stats.atimeMs,
|
|
293
|
+
mtimeMs: stats.mtimeMs,
|
|
294
|
+
ctimeMs: stats.ctimeMs,
|
|
295
|
+
birthtimeMs: stats.birthtimeMs,
|
|
296
|
+
atime: stats.atime,
|
|
297
|
+
mtime: stats.mtime,
|
|
298
|
+
ctime: stats.ctime,
|
|
299
|
+
birthtime: stats.birthtime,
|
|
300
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import {
|
|
2
|
+
assertEqual,
|
|
3
|
+
assertSame,
|
|
4
|
+
assertType,
|
|
5
|
+
PositiveInt,
|
|
6
|
+
} from "@evolu/common";
|
|
7
|
+
import { mock, test } from "node:test";
|
|
8
|
+
import type {
|
|
9
|
+
AvailableParallelism,
|
|
10
|
+
AvailableParallelismDep,
|
|
11
|
+
} from "./Platform.ts";
|
|
12
|
+
|
|
13
|
+
const nodeAvailableParallelism = mock.fn<() => number>();
|
|
14
|
+
|
|
15
|
+
mock.module("node:os", {
|
|
16
|
+
// @ts-expect-error -- Node.js 24.20 replaces the deprecated namedExports option with exports, which @types/node 24.13 does not declare yet.
|
|
17
|
+
exports: { availableParallelism: nodeAvailableParallelism },
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const { availableParallelism } = await import("./Platform.ts");
|
|
21
|
+
|
|
22
|
+
test("availableParallelism returns the validated Node.js value", () => {
|
|
23
|
+
nodeAvailableParallelism.mock.mockImplementation(() => 128);
|
|
24
|
+
|
|
25
|
+
const parallelism = availableParallelism();
|
|
26
|
+
const deps = { availableParallelism } satisfies AvailableParallelismDep;
|
|
27
|
+
|
|
28
|
+
assertType<typeof availableParallelism, AvailableParallelism>();
|
|
29
|
+
assertType<typeof parallelism, PositiveInt>();
|
|
30
|
+
assertSame(deps.availableParallelism, availableParallelism);
|
|
31
|
+
assertEqual(parallelism, 128);
|
|
32
|
+
});
|
package/src/Task.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import {
|
|
8
8
|
createConsole,
|
|
9
9
|
createRun,
|
|
10
|
+
getOrThrow,
|
|
10
11
|
isDisposable,
|
|
11
12
|
ok,
|
|
12
13
|
waitForAbort,
|
|
@@ -18,6 +19,7 @@ import {
|
|
|
18
19
|
type Task,
|
|
19
20
|
type Typed,
|
|
20
21
|
} from "@evolu/common";
|
|
22
|
+
import type { createRelay } from "./local-first/Relay.ts";
|
|
21
23
|
|
|
22
24
|
/**
|
|
23
25
|
* An abort requested by a Node.js termination signal.
|
|
@@ -40,8 +42,8 @@ export interface RunMainOptions {
|
|
|
40
42
|
* How termination signals affect the process exit status.
|
|
41
43
|
*
|
|
42
44
|
* Services treat a gracefully handled signal as a successful shutdown.
|
|
43
|
-
* Commands use the conventional `128 + signal number` exit status unless
|
|
44
|
-
*
|
|
45
|
+
* Commands use the conventional `128 + signal number` exit status unless
|
|
46
|
+
* `process.exitCode` is already set.
|
|
45
47
|
*
|
|
46
48
|
* @default "service"
|
|
47
49
|
*/
|
|
@@ -49,75 +51,90 @@ export interface RunMainOptions {
|
|
|
49
51
|
}
|
|
50
52
|
|
|
51
53
|
/**
|
|
52
|
-
* Runs the main Task
|
|
54
|
+
* Runs the main {@link Task} of a Node.js command or service.
|
|
53
55
|
*
|
|
54
|
-
*
|
|
56
|
+
* A command does a finite job, such as importing data. A service handles
|
|
57
|
+
* ongoing work, such as accepting connections. Choose a mode for how the
|
|
58
|
+
* program reports an interruption:
|
|
55
59
|
*
|
|
56
|
-
* - `
|
|
57
|
-
*
|
|
58
|
-
*
|
|
60
|
+
* - `"command"`: A termination signal means the job was interrupted. After
|
|
61
|
+
* cleanup, use the conventional signal exit status, such as 130 for Ctrl-C,
|
|
62
|
+
* unless `process.exitCode` is already set.
|
|
63
|
+
* - `"service"` (default): A termination signal is an expected way to stop the
|
|
64
|
+
* service. Graceful shutdown does not set a failure exit status.
|
|
59
65
|
*
|
|
60
|
-
*
|
|
61
|
-
* the main Task
|
|
62
|
-
* immediately with its conventional signal status, abandoning cleanup. A signal
|
|
63
|
-
* received during final cleanup still applies signal shutdown behavior.
|
|
66
|
+
* Both modes create one root {@link Run} and wait for cleanup. The mode controls
|
|
67
|
+
* signal exit status; the main Task controls how long `runMain` waits.
|
|
64
68
|
*
|
|
65
|
-
*
|
|
66
|
-
* termination signal and is disposed during shutdown. A main Task returning
|
|
67
|
-
* `void` completes the program immediately. A Resource result transfers
|
|
68
|
-
* ownership of a live resource that must remain valid after its creating Task
|
|
69
|
-
* settles.
|
|
69
|
+
* ### Lifetime
|
|
70
70
|
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
71
|
+
* When the main Task returns `void`, `runMain` finishes after the Task and root
|
|
72
|
+
* Run cleanup. It does not force the Node.js process to exit.
|
|
73
|
+
*
|
|
74
|
+
* A service can return a live {@link Resource}, such as the relay created by
|
|
75
|
+
* {@link createRelay}. This transfers ownership to `runMain`, which waits for
|
|
76
|
+
* shutdown and then disposes the Resource. It must remain usable after the
|
|
77
|
+
* creating Task finishes; do not dispose it before returning it.
|
|
78
|
+
*
|
|
79
|
+
* Alternatively, the main Task can own its resources with `using` or `await
|
|
80
|
+
* using` while awaiting {@link waitForAbort} with `run(waitForAbort)`. Waiting
|
|
81
|
+
* for abort does not itself keep Node.js running: the service needs active
|
|
82
|
+
* work, such as a listening server.
|
|
83
|
+
*
|
|
84
|
+
* ### Shutdown and errors
|
|
85
|
+
*
|
|
86
|
+
* Handles `SIGINT` (Ctrl-C), `SIGTERM` (termination by the OS or a service
|
|
87
|
+
* host), and `SIGBREAK` (Ctrl-Break on Windows). The first signal logs shutdown
|
|
88
|
+
* progress, aborts the root Run, and waits for cleanup. A second signal exits
|
|
89
|
+
* immediately with its conventional signal status, abandoning cleanup. Signals
|
|
90
|
+
* are still handled during final cleanup.
|
|
91
|
+
*
|
|
92
|
+
* An error returned by the main Task is fatal. {@link getOrThrow} preserves it
|
|
93
|
+
* in `Error.cause`; the Run reports the failure and finishes cleanup. Every
|
|
94
|
+
* reported defect sets `process.exitCode` to 1, including an observer defect
|
|
95
|
+
* that does not abort the Run. The default reporter logs to the configured
|
|
75
96
|
* Evolu console.
|
|
76
97
|
*
|
|
77
98
|
* Escaped uncaught exceptions and unhandled rejections remain under Node.js
|
|
78
99
|
* native reporting and termination.
|
|
79
100
|
*
|
|
80
|
-
* ###
|
|
101
|
+
* ### Example
|
|
81
102
|
*
|
|
82
|
-
*
|
|
83
|
-
* const deps = { ...createRelayDeps(), console: createConsole() };
|
|
84
|
-
*
|
|
85
|
-
* await runMain(deps)(createRelay({ port: 4000 }));
|
|
86
|
-
* ```
|
|
87
|
-
*
|
|
88
|
-
* A Task returning `void` can keep a service alive explicitly when no Resource
|
|
89
|
-
* owns its lifetime:
|
|
103
|
+
* A command finishes when its main Task completes:
|
|
90
104
|
*
|
|
91
105
|
* ```ts
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
* return await run(waitForAbort);
|
|
95
|
-
* });
|
|
96
|
-
* ```
|
|
106
|
+
* import { assertTrue, ok, type Task } from "@evolu/common";
|
|
107
|
+
* import { runMain } from "@evolu/nodejs";
|
|
97
108
|
*
|
|
98
|
-
*
|
|
109
|
+
* let completed = false;
|
|
110
|
+
* const command: Task<void> = () => {
|
|
111
|
+
* completed = true;
|
|
112
|
+
* return ok();
|
|
113
|
+
* };
|
|
99
114
|
*
|
|
100
|
-
* ```ts
|
|
101
115
|
* await runMain(command, { mode: "command" });
|
|
116
|
+
* assertTrue(completed);
|
|
102
117
|
* ```
|
|
103
118
|
*
|
|
104
119
|
* @group Node.js Task
|
|
105
120
|
*/
|
|
106
|
-
export function runMain<T extends void | Resource>(
|
|
107
|
-
main: Task<T>,
|
|
121
|
+
export function runMain<T extends void | Resource, E = never>(
|
|
122
|
+
main: Task<T, E>,
|
|
108
123
|
options?: RunMainOptions,
|
|
109
124
|
): Promise<void>;
|
|
110
125
|
/** With custom dependencies. */
|
|
111
126
|
export function runMain<D extends object>(
|
|
112
127
|
deps: RunCustomDeps<D>,
|
|
113
128
|
options?: RunMainOptions,
|
|
114
|
-
): <T extends void | Resource>(main: Task<T,
|
|
115
|
-
export function runMain<T extends void | Resource, D extends object>(
|
|
116
|
-
mainOrDeps: Task<T> | RunCustomDeps<D>,
|
|
129
|
+
): <T extends void | Resource, E = never>(main: Task<T, E, D>) => Promise<void>;
|
|
130
|
+
export function runMain<T extends void | Resource, E, D extends object>(
|
|
131
|
+
mainOrDeps: Task<T, E> | RunCustomDeps<D>,
|
|
117
132
|
{ mode = "service" }: RunMainOptions = {},
|
|
118
133
|
):
|
|
119
134
|
| Promise<void>
|
|
120
|
-
| (<R extends void | Resource
|
|
135
|
+
| (<R extends void | Resource, E = never>(
|
|
136
|
+
main: Task<R, E, D>,
|
|
137
|
+
) => Promise<void>) {
|
|
121
138
|
return typeof mainOrDeps === "function"
|
|
122
139
|
? runMainInternal(mainOrDeps, {}, mode)
|
|
123
140
|
: (main) => runMainInternal(main, mainOrDeps, mode);
|
|
@@ -129,8 +146,8 @@ const commandExitCodeBySignal: Readonly<Record<NodeSignal, number>> = {
|
|
|
129
146
|
SIGBREAK: 149,
|
|
130
147
|
};
|
|
131
148
|
|
|
132
|
-
const runMainInternal = async <T extends void | Resource, D extends object>(
|
|
133
|
-
main: Task<T,
|
|
149
|
+
const runMainInternal = async <T extends void | Resource, E, D extends object>(
|
|
150
|
+
main: Task<T, E, D>,
|
|
134
151
|
deps: RunCustomDeps<D> & Partial<ConsoleDep & ReportDefectDep>,
|
|
135
152
|
mode: RunMainMode,
|
|
136
153
|
): Promise<void> => {
|
|
@@ -175,7 +192,7 @@ const runMainInternal = async <T extends void | Resource, D extends object>(
|
|
|
175
192
|
|
|
176
193
|
try {
|
|
177
194
|
await run(async (run) => {
|
|
178
|
-
const resource = await run
|
|
195
|
+
const resource = getOrThrow(await run(main));
|
|
179
196
|
if (!isDisposable(resource)) return ok();
|
|
180
197
|
|
|
181
198
|
await using _resource = resource;
|
package/src/TestBundle.ts
CHANGED
|
@@ -14,18 +14,20 @@
|
|
|
14
14
|
import {
|
|
15
15
|
allSettled,
|
|
16
16
|
assert,
|
|
17
|
+
assertLength,
|
|
18
|
+
assertSame,
|
|
17
19
|
assertType,
|
|
18
20
|
instanceOf,
|
|
19
21
|
createRun,
|
|
20
22
|
durationToMillis,
|
|
21
23
|
escapeRegExp,
|
|
22
|
-
filterArray,
|
|
23
24
|
isErr,
|
|
24
25
|
mapArray,
|
|
25
26
|
type NonEmptyReadonlyArray,
|
|
26
27
|
type PositiveDuration,
|
|
27
28
|
type ReadonlyRecord,
|
|
28
29
|
type Result,
|
|
30
|
+
partitionArray,
|
|
29
31
|
safelyStringifyUnknownValue,
|
|
30
32
|
String as StringType,
|
|
31
33
|
type Task,
|
|
@@ -301,7 +303,7 @@ export const testBundle = async ({
|
|
|
301
303
|
),
|
|
302
304
|
);
|
|
303
305
|
|
|
304
|
-
const failures =
|
|
306
|
+
const [failures, successes] = partitionArray(results, isErr);
|
|
305
307
|
if (failures.length > 0) {
|
|
306
308
|
const errors = mapArray(
|
|
307
309
|
failures,
|
|
@@ -331,8 +333,7 @@ export const testBundle = async ({
|
|
|
331
333
|
>();
|
|
332
334
|
for (const [caseName] of caseEntries) bundleEntriesByCase.set(caseName, []);
|
|
333
335
|
|
|
334
|
-
for (const result of
|
|
335
|
-
assert(result.ok, "Expected every bundle test to succeed.");
|
|
336
|
+
for (const result of successes) {
|
|
336
337
|
const { caseName, bundle } = result.value;
|
|
337
338
|
const entries = bundleEntriesByCase.get(caseName);
|
|
338
339
|
assert(entries, `Missing bundle test case "${caseName}".`);
|
|
@@ -438,16 +439,11 @@ const testViteBundler: TestBundler = {
|
|
|
438
439
|
>;
|
|
439
440
|
assert(Array.isArray(output), "Vite did not return build outputs.");
|
|
440
441
|
const outputs: ReadonlyArray<ViteOutput> = output;
|
|
441
|
-
|
|
442
|
-
const viteOutput = outputs
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
"Vite did not emit one JavaScript chunk.",
|
|
447
|
-
);
|
|
448
|
-
const chunk = viteOutput.output.at(0);
|
|
449
|
-
assert(chunk, "Vite did not emit a JavaScript chunk.");
|
|
450
|
-
assert(chunk.type === "chunk", "Vite did not emit a JavaScript chunk.");
|
|
442
|
+
assertLength(outputs, 1);
|
|
443
|
+
const viteOutput = outputs[0];
|
|
444
|
+
assertLength(viteOutput.output, 1);
|
|
445
|
+
const chunk = viteOutput.output[0];
|
|
446
|
+
assertSame(chunk.type, "chunk");
|
|
451
447
|
assertType(StringType, chunk.code);
|
|
452
448
|
|
|
453
449
|
return {
|
|
@@ -582,7 +578,7 @@ interface TestBundleWorkerError {
|
|
|
582
578
|
readonly stack: string;
|
|
583
579
|
}
|
|
584
580
|
|
|
585
|
-
const ErrorType = /*#__PURE__*/ instanceOf(
|
|
581
|
+
const ErrorType = /*#__PURE__*/ instanceOf(Error);
|
|
586
582
|
|
|
587
583
|
// TODO: Replace the evaluated CommonJS worker with an ESM module worker.
|
|
588
584
|
const testBundleWorkerSource = String.raw`
|
package/src/TestJSDoc.ts
CHANGED
|
@@ -31,7 +31,10 @@ import { stripVTControlCharacters } from "node:util";
|
|
|
31
31
|
export interface TestJSDocExamplesOptions {
|
|
32
32
|
/** Source files or glob patterns resolved from `cwd`. */
|
|
33
33
|
readonly include: string | ReadonlyArray<string>;
|
|
34
|
-
/**
|
|
34
|
+
/**
|
|
35
|
+
* Package imports redirected to absolute TypeScript entry paths. Type
|
|
36
|
+
* checking also applies these aliases to imports within those modules.
|
|
37
|
+
*/
|
|
35
38
|
readonly aliases?: ReadonlyRecord<string, string>;
|
|
36
39
|
/** Directory used for globbing and package resolution. */
|
|
37
40
|
readonly cwd?: string;
|
|
@@ -219,6 +222,9 @@ export const testJSDocExamples = async ({
|
|
|
219
222
|
noEmit: true,
|
|
220
223
|
noUnusedLocals: false,
|
|
221
224
|
noUnusedParameters: false,
|
|
225
|
+
paths: Object.fromEntries(
|
|
226
|
+
Object.entries(aliases).map(([name, target]) => [name, [target]]),
|
|
227
|
+
),
|
|
222
228
|
target: "es2022",
|
|
223
229
|
types: ["node"],
|
|
224
230
|
},
|