@akterjs/effect 0.1.2 → 0.1.3
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/dist/contract/computer/computer-limit.errors.d.mts +3 -3
- package/dist/contract/computer/computer-limit.errors.mjs +3 -3
- package/dist/contract/computer/files/computer-files.schema.d.mts +16 -1
- package/dist/contract/computer/files/computer-files.schema.mjs +17 -1
- package/dist/host/command/batch/command-batch.sql.d.mts +6 -3
- package/dist/host/command/batch/command-batch.sql.mjs +10 -7
- package/dist/host/computer/local-computer-files.layer.d.mts +10 -1
- package/dist/host/computer/local-computer-files.layer.mjs +50 -7
- package/dist/host/computer/local-computer-records.layer.d.mts +2 -9
- package/dist/host/computer/local-computer-records.layer.mjs +2 -23
- package/dist/host/computer/local-computers.adapter.mjs +4 -11
- package/dist/host/fork/fork-computers.adapter.mjs +7 -18
- package/dist/serve/live/live-tables.sql.d.mts +1 -1
- package/dist/serve/live/live-tables.sql.mjs +2 -5
- package/dist/serve/rpc/computer/computer-copy.layer.mjs +43 -20
- package/package.json +1 -1
- package/src/contract/computer/computer-limit.errors.ts +3 -3
- package/src/contract/computer/files/computer-files.schema.ts +13 -1
- package/src/host/command/batch/command-batch.sql.ts +22 -15
- package/src/host/computer/local-computer-files.layer.ts +66 -11
- package/src/host/computer/local-computer-records.layer.ts +3 -56
- package/src/host/computer/local-computers.adapter.ts +2 -5
- package/src/host/fork/fork-computers.adapter.ts +5 -28
- package/src/serve/live/live-tables.sql.ts +2 -4
- package/src/serve/rpc/computer/computer-copy.layer.ts +73 -24
|
@@ -7,9 +7,9 @@ declare const ComputerLimitError_base: Schema.Class<ComputerLimitError, Schema.T
|
|
|
7
7
|
readonly brand: unique symbol;
|
|
8
8
|
}>;
|
|
9
9
|
/**
|
|
10
|
-
* A new computer
|
|
11
|
-
*
|
|
12
|
-
* one is
|
|
10
|
+
* A new computer was refused for capacity, so none was made: `account`, the sandboxes the platform's provider account
|
|
11
|
+
* may run at once, with the most it holds. Try again once one is free; nothing waits for room. `store`, the computers
|
|
12
|
+
* one app environment may keep, is no longer set by Akter Cloud or the local engine; older engines may still send it.
|
|
13
13
|
*/
|
|
14
14
|
export declare class ComputerLimitError extends ComputerLimitError_base {}
|
|
15
15
|
export {};
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { Schema } from "effect";
|
|
2
2
|
// src/contract/computer/computer-limit.errors.ts
|
|
3
3
|
/**
|
|
4
|
-
* A new computer
|
|
5
|
-
*
|
|
6
|
-
* one is
|
|
4
|
+
* A new computer was refused for capacity, so none was made: `account`, the sandboxes the platform's provider account
|
|
5
|
+
* may run at once, with the most it holds. Try again once one is free; nothing waits for room. `store`, the computers
|
|
6
|
+
* one app environment may keep, is no longer set by Akter Cloud or the local engine; older engines may still send it.
|
|
7
7
|
*/
|
|
8
8
|
var ComputerLimitError = class extends Schema.TaggedError()("ComputerLimitError", {
|
|
9
9
|
computer: Schema.String,
|
|
@@ -1,14 +1,29 @@
|
|
|
1
1
|
import { Schema } from "effect";
|
|
2
|
-
/**
|
|
2
|
+
/** The most one call carries across the host/control link. Paths are relative to the computer's files directory. */
|
|
3
3
|
declare const computerFileBytes = 1048576;
|
|
4
|
+
/**
|
|
5
|
+
* A file of any size moves in pieces: `ReadAt` answers at most `computerFileBytes` from `offset`, fewer at the end of
|
|
6
|
+
* the file, and `WriteAt` at offset 0 replaces the file, and at a later offset cuts it to that length and appends, so
|
|
7
|
+
* a resent piece is written once. `Read` and `Write` move a whole file of at most `computerFileBytes` in one call, as
|
|
8
|
+
* engines before pieces did; they stay so a host and a control plane of different releases still copy small files.
|
|
9
|
+
*/
|
|
4
10
|
declare const ComputerFileRequest: Schema.TaggedUnion<{
|
|
5
11
|
readonly Read: Schema.TaggedStruct<"Read", {
|
|
6
12
|
readonly path: Schema.String;
|
|
7
13
|
}>;
|
|
14
|
+
readonly ReadAt: Schema.TaggedStruct<"ReadAt", {
|
|
15
|
+
readonly offset: Schema.Int;
|
|
16
|
+
readonly path: Schema.String;
|
|
17
|
+
}>;
|
|
8
18
|
readonly Write: Schema.TaggedStruct<"Write", {
|
|
9
19
|
readonly content: Schema.Uint8ArrayFromBase64;
|
|
10
20
|
readonly path: Schema.String;
|
|
11
21
|
}>;
|
|
22
|
+
readonly WriteAt: Schema.TaggedStruct<"WriteAt", {
|
|
23
|
+
readonly content: Schema.Uint8ArrayFromBase64;
|
|
24
|
+
readonly offset: Schema.Int;
|
|
25
|
+
readonly path: Schema.String;
|
|
26
|
+
}>;
|
|
12
27
|
}>;
|
|
13
28
|
type ComputerFileRequest = typeof ComputerFileRequest.Type;
|
|
14
29
|
declare const ComputerCopy: Schema.Struct<{
|
|
@@ -1,12 +1,28 @@
|
|
|
1
1
|
import { Schema } from "effect";
|
|
2
2
|
// src/contract/computer/files/computer-files.schema.ts
|
|
3
|
-
/**
|
|
3
|
+
/** The most one call carries across the host/control link. Paths are relative to the computer's files directory. */
|
|
4
4
|
const computerFileBytes = 1048576;
|
|
5
|
+
const Offset = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0));
|
|
6
|
+
/**
|
|
7
|
+
* A file of any size moves in pieces: `ReadAt` answers at most `computerFileBytes` from `offset`, fewer at the end of
|
|
8
|
+
* the file, and `WriteAt` at offset 0 replaces the file, and at a later offset cuts it to that length and appends, so
|
|
9
|
+
* a resent piece is written once. `Read` and `Write` move a whole file of at most `computerFileBytes` in one call, as
|
|
10
|
+
* engines before pieces did; they stay so a host and a control plane of different releases still copy small files.
|
|
11
|
+
*/
|
|
5
12
|
const ComputerFileRequest = Schema.TaggedUnion({
|
|
6
13
|
Read: { path: Schema.String },
|
|
14
|
+
ReadAt: {
|
|
15
|
+
offset: Offset,
|
|
16
|
+
path: Schema.String
|
|
17
|
+
},
|
|
7
18
|
Write: {
|
|
8
19
|
content: Schema.Uint8ArrayFromBase64,
|
|
9
20
|
path: Schema.String
|
|
21
|
+
},
|
|
22
|
+
WriteAt: {
|
|
23
|
+
content: Schema.Uint8ArrayFromBase64,
|
|
24
|
+
offset: Offset,
|
|
25
|
+
path: Schema.String
|
|
10
26
|
}
|
|
11
27
|
});
|
|
12
28
|
const ComputerCopy = Schema.Struct({
|
|
@@ -15,7 +15,7 @@ interface Journal {
|
|
|
15
15
|
readonly known: HashSet.HashSet<string>;
|
|
16
16
|
readonly nextSeq: number;
|
|
17
17
|
readonly recorded: HashMap.HashMap<string, RecordedCommand>;
|
|
18
|
-
/** The seq each command
|
|
18
|
+
/** The seq of each command the journal answers for and that has one: read at the start, or given in this batch. */
|
|
19
19
|
readonly seqs: HashMap.HashMap<string, number>;
|
|
20
20
|
readonly state: Option.Option<string>;
|
|
21
21
|
}
|
|
@@ -37,8 +37,11 @@ declare const CommandBatchJournal: {
|
|
|
37
37
|
readonly recorded: (sql: SqlClient.SqlClient, commandId: string, read: Effect.Effect<Option.Option<RecordedCommand>, SqlError.SqlError>) => Effect.Effect<Option.Option<RecordedCommand>, SqlError.SqlError>;
|
|
38
38
|
/** Notes a state the running command saved. */
|
|
39
39
|
readonly saved: (value: string) => Effect.Effect<void>;
|
|
40
|
-
/**
|
|
41
|
-
|
|
40
|
+
/**
|
|
41
|
+
* The seq `commandId` was journaled with, from the batch journal when it answers for the id, so a command's check
|
|
42
|
+
* whether it already ran costs no round trip of its own.
|
|
43
|
+
*/
|
|
44
|
+
readonly seq: (sql: SqlClient.SqlClient, commandId: string, read: Effect.Effect<Option.Option<number>, SqlError.SqlError>) => Effect.Effect<Option.Option<number>, SqlError.SqlError>;
|
|
42
45
|
/** The actor's current state, from the batch journal when there is one. */
|
|
43
46
|
readonly state: (sql: SqlClient.SqlClient, read: Effect.Effect<Option.Option<string>, SqlError.SqlError>) => Effect.Effect<Option.Option<string>, SqlError.SqlError>;
|
|
44
47
|
/** The client for the running command's journal and state writes: the batch's held client, or `sql` itself. */
|
|
@@ -4,7 +4,7 @@ const CommandBatch = Context.Reference("@akterjs/effect/host/command/batch/comma
|
|
|
4
4
|
const firstSeq = 1;
|
|
5
5
|
const nextOf = 1;
|
|
6
6
|
const IdList = Schema.fromJsonString(Schema.Array(Schema.String));
|
|
7
|
-
const journalRows = (sql, ids) => sql`SELECT command_id AS "commandId", command, exit, principal FROM akter_commands WHERE command_id IN (SELECT value FROM json_each(${ids}))`;
|
|
7
|
+
const journalRows = (sql, ids) => sql`SELECT command_id AS "commandId", command, exit, principal, seq FROM akter_commands WHERE command_id IN (SELECT value FROM json_each(${ids}))`;
|
|
8
8
|
const readJournal = Effect.fnUntraced(function* readJournal(sql, commandIds) {
|
|
9
9
|
const ids = yield* Effect.orDie(Schema.encodeEffect(IdList)(commandIds));
|
|
10
10
|
const [states, rows, last] = yield* Effect.all([
|
|
@@ -18,8 +18,8 @@ const readJournal = Effect.fnUntraced(function* readJournal(sql, commandIds) {
|
|
|
18
18
|
onNone: () => firstSeq,
|
|
19
19
|
onSome: (row) => row.seq + nextOf
|
|
20
20
|
}),
|
|
21
|
-
recorded: HashMap.fromIterable(Array.map(rows, ({ commandId, ...recorded }) => [commandId, recorded])),
|
|
22
|
-
seqs: HashMap.
|
|
21
|
+
recorded: HashMap.fromIterable(Array.map(rows, ({ commandId, seq: _seq, ...recorded }) => [commandId, recorded])),
|
|
22
|
+
seqs: HashMap.fromIterable(Array.map(rows, ({ commandId, seq }) => [commandId, seq])),
|
|
23
23
|
state: Option.map(Array.head(states), (row) => row.value)
|
|
24
24
|
};
|
|
25
25
|
});
|
|
@@ -66,10 +66,13 @@ const CommandBatchJournal = {
|
|
|
66
66
|
...journal,
|
|
67
67
|
state: Option.some(value)
|
|
68
68
|
})),
|
|
69
|
-
/**
|
|
70
|
-
seq
|
|
71
|
-
|
|
72
|
-
|
|
69
|
+
/**
|
|
70
|
+
* The seq `commandId` was journaled with, from the batch journal when it answers for the id, so a command's check
|
|
71
|
+
* whether it already ran costs no round trip of its own.
|
|
72
|
+
*/
|
|
73
|
+
seq: (sql, commandId, read) => Effect.flatMap(journalOf(sql), (opened) => Option.match(Option.filter(opened, ([, journal]) => answers(journal, commandId)), {
|
|
74
|
+
onNone: () => read,
|
|
75
|
+
onSome: ([, journal]) => Effect.succeed(HashMap.get(journal.seqs, commandId))
|
|
73
76
|
})),
|
|
74
77
|
/** The actor's current state, from the batch journal when there is one. */
|
|
75
78
|
state: (sql, read) => Effect.flatMap(journalOf(sql), Option.match({
|
|
@@ -3,9 +3,18 @@ import { Effect, FileSystem, Path } from "effect";
|
|
|
3
3
|
declare const localComputerFile: (place: Place, request: {
|
|
4
4
|
readonly _tag: "Read";
|
|
5
5
|
readonly path: string;
|
|
6
|
+
} | {
|
|
7
|
+
readonly _tag: "ReadAt";
|
|
8
|
+
readonly offset: number;
|
|
9
|
+
readonly path: string;
|
|
6
10
|
} | {
|
|
7
11
|
readonly _tag: "Write";
|
|
8
12
|
readonly content: Uint8Array<ArrayBufferLike>;
|
|
9
13
|
readonly path: string;
|
|
10
|
-
}
|
|
14
|
+
} | {
|
|
15
|
+
readonly _tag: "WriteAt";
|
|
16
|
+
readonly content: Uint8Array<ArrayBufferLike>;
|
|
17
|
+
readonly offset: number;
|
|
18
|
+
readonly path: string;
|
|
19
|
+
}) => Effect.Effect<Uint8Array<ArrayBufferLike>, Failing, FileSystem.FileSystem | Path.Path>;
|
|
11
20
|
export { localComputerFile };
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { InvalidRequestError } from "../../contract/client/invalid-request.errors.mjs";
|
|
2
|
-
import { computerFileBytes } from "../../contract/computer/files/computer-files.schema.mjs";
|
|
2
|
+
import { ComputerFileRequest, computerFileBytes } from "../../contract/computer/files/computer-files.schema.mjs";
|
|
3
3
|
import { filePathOf } from "../../contract/file/file-path.pure.mjs";
|
|
4
4
|
import { notHeld, present, recordFor } from "./local-computer-records.layer.mjs";
|
|
5
5
|
import { Array, Effect, FileSystem, Path, Predicate, Stream, String } from "effect";
|
|
6
6
|
// src/host/computer/local-computer-files.layer.ts
|
|
7
7
|
const extraByte = 1;
|
|
8
|
+
const fileStart = 0;
|
|
8
9
|
const checkedPath = Effect.fnUntraced(function* checkedPath(disk, target, actor) {
|
|
9
10
|
const canonical = yield* (yield* FileSystem.FileSystem).realPath(target).pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(target)));
|
|
10
11
|
if (!String.startsWith(`${disk}/`)(canonical)) return yield* InvalidRequestError.make({
|
|
@@ -27,6 +28,14 @@ const targetOf = Effect.fnUntraced(function* targetOf(place, relative, writing)
|
|
|
27
28
|
return target;
|
|
28
29
|
}));
|
|
29
30
|
});
|
|
31
|
+
const joined = (chunks) => {
|
|
32
|
+
const bytes = new Uint8Array(Array.reduce(chunks, fileStart, (total, chunk) => total + chunk.byteLength));
|
|
33
|
+
Array.reduce(chunks, fileStart, (offset, chunk) => {
|
|
34
|
+
bytes.set(chunk, offset);
|
|
35
|
+
return offset + chunk.byteLength;
|
|
36
|
+
});
|
|
37
|
+
return bytes;
|
|
38
|
+
};
|
|
30
39
|
const readFile = Effect.fnUntraced(function* readFile(target, actor) {
|
|
31
40
|
const fs = yield* FileSystem.FileSystem;
|
|
32
41
|
const stat = yield* fs.stat(target);
|
|
@@ -35,13 +44,26 @@ const readFile = Effect.fnUntraced(function* readFile(target, actor) {
|
|
|
35
44
|
message: "a computer file must be a regular file of at most 1 MiB"
|
|
36
45
|
});
|
|
37
46
|
const chunks = yield* Stream.runCollect(fs.stream(target, { bytesToRead: computerFileBytes + extraByte }));
|
|
38
|
-
const content =
|
|
47
|
+
const content = joined(chunks);
|
|
39
48
|
if (content.byteLength > 1048576) return yield* InvalidRequestError.make({
|
|
40
49
|
actor,
|
|
41
50
|
message: "a computer file holds at most 1 MiB"
|
|
42
51
|
});
|
|
43
52
|
return content;
|
|
44
53
|
});
|
|
54
|
+
/** At most one call's bytes from `offset`; fewer at the end of the file. */
|
|
55
|
+
const readPiece = Effect.fnUntraced(function* readPiece(target, offset, actor) {
|
|
56
|
+
const fs = yield* FileSystem.FileSystem;
|
|
57
|
+
if ((yield* fs.stat(target)).type !== "File") return yield* InvalidRequestError.make({
|
|
58
|
+
actor,
|
|
59
|
+
message: "a computer file must be a regular file"
|
|
60
|
+
});
|
|
61
|
+
const chunks = yield* Stream.runCollect(fs.stream(target, {
|
|
62
|
+
bytesToRead: computerFileBytes,
|
|
63
|
+
offset
|
|
64
|
+
}));
|
|
65
|
+
return joined(chunks);
|
|
66
|
+
});
|
|
45
67
|
const writeFile = Effect.fnUntraced(function* writeFile(target, content) {
|
|
46
68
|
const fs = yield* FileSystem.FileSystem;
|
|
47
69
|
const path = yield* Path.Path;
|
|
@@ -50,6 +72,18 @@ const writeFile = Effect.fnUntraced(function* writeFile(target, content) {
|
|
|
50
72
|
yield* fs.rename(temporary, target);
|
|
51
73
|
return /* @__PURE__ */ new Uint8Array();
|
|
52
74
|
}, Effect.scoped);
|
|
75
|
+
/** At offset 0 the piece replaces the file; later, the file is cut to `offset` and the piece appended. */
|
|
76
|
+
const writePiece = Effect.fnUntraced(function* writePiece(target, { actor, content, offset }) {
|
|
77
|
+
if (offset === fileStart) return yield* writeFile(target, content);
|
|
78
|
+
const fs = yield* FileSystem.FileSystem;
|
|
79
|
+
if ((yield* fs.stat(target)).size < BigInt(offset)) return yield* InvalidRequestError.make({
|
|
80
|
+
actor,
|
|
81
|
+
message: "a piece must follow the bytes already written"
|
|
82
|
+
});
|
|
83
|
+
yield* fs.truncate(target, offset);
|
|
84
|
+
yield* fs.writeFile(target, content, { flag: "a" });
|
|
85
|
+
return /* @__PURE__ */ new Uint8Array();
|
|
86
|
+
});
|
|
53
87
|
const localComputerFile = Effect.fnUntraced(function* localComputerFile(place, request) {
|
|
54
88
|
const record = yield* recordFor(place);
|
|
55
89
|
yield* notHeld(record);
|
|
@@ -58,12 +92,21 @@ const localComputerFile = Effect.fnUntraced(function* localComputerFile(place, r
|
|
|
58
92
|
actor: place.actor,
|
|
59
93
|
message: failure.message
|
|
60
94
|
}));
|
|
61
|
-
|
|
95
|
+
const writing = Predicate.isTagged(request, "Write") || Predicate.isTagged(request, "WriteAt");
|
|
96
|
+
if (writing && request.content.byteLength > 1048576) return yield* InvalidRequestError.make({
|
|
62
97
|
actor: place.actor,
|
|
63
|
-
message: "a computer file
|
|
98
|
+
message: "a computer file call carries at most 1 MiB"
|
|
99
|
+
});
|
|
100
|
+
const target = yield* targetOf(place, relative, writing);
|
|
101
|
+
return yield* ComputerFileRequest.match(request, {
|
|
102
|
+
Read: () => readFile(target, place.actor),
|
|
103
|
+
ReadAt: ({ offset }) => readPiece(target, offset, place.actor),
|
|
104
|
+
Write: ({ content }) => writeFile(target, content),
|
|
105
|
+
WriteAt: ({ content, offset }) => writePiece(target, {
|
|
106
|
+
actor: place.actor,
|
|
107
|
+
content,
|
|
108
|
+
offset
|
|
109
|
+
})
|
|
64
110
|
});
|
|
65
|
-
const target = yield* targetOf(place, relative, Predicate.isTagged(request, "Write"));
|
|
66
|
-
if (Predicate.isTagged(request, "Write")) return yield* writeFile(target, request.content);
|
|
67
|
-
return yield* readFile(target, place.actor);
|
|
68
111
|
});
|
|
69
112
|
export { localComputerFile };
|
|
@@ -1,15 +1,13 @@
|
|
|
1
1
|
import { ComputerClientFailure } from "../../contract/computer/computer-client-failure.contract.mjs";
|
|
2
2
|
import { ComputerAddress } from "../../contract/computer/computer-client.contract.mjs";
|
|
3
3
|
import { LocalComputerRecord } from "./local-computer.schema.mjs";
|
|
4
|
-
import { Effect, FileSystem, Option, Path, Schema
|
|
4
|
+
import { Effect, FileSystem, Option, Path, Schema } from "effect";
|
|
5
5
|
/** What the disk answers when it fails: the file system's own error. */
|
|
6
6
|
type DiskError = Effect.Error<ReturnType<FileSystem.FileSystem["exists"]>>;
|
|
7
7
|
type Failing = ComputerClientFailure | DiskError | Schema.SchemaError;
|
|
8
8
|
type Recorded<Value> = Effect.Effect<Value, Failing, FileSystem.FileSystem>;
|
|
9
9
|
/** Where the local computers of one store live. */
|
|
10
10
|
interface Store {
|
|
11
|
-
/** One permit: a new computer is counted and made one at a time, so two at once can't pass the limit. */
|
|
12
|
-
readonly admission: Semaphore.Semaphore;
|
|
13
11
|
readonly path: Path.Path;
|
|
14
12
|
readonly root: string;
|
|
15
13
|
}
|
|
@@ -182,9 +180,4 @@ declare const movedTo: (place: Place, record: {
|
|
|
182
180
|
};
|
|
183
181
|
readonly state: "paused" | "running" | "stopped";
|
|
184
182
|
}, Failing, FileSystem.FileSystem>;
|
|
185
|
-
|
|
186
|
-
declare const admitted: ({ computer, store }: {
|
|
187
|
-
readonly computer: string;
|
|
188
|
-
readonly store: Store;
|
|
189
|
-
}) => <Value, Services>(making: Effect.Effect<Value, Failing, Services>) => Effect.Effect<Value, Failing, Services | FileSystem.FileSystem>;
|
|
190
|
-
export { type DiskError, type Failing, type Place, type Recorded, type Store, admitted, computerOf, findRecord, keySegment, listed, movedTo, notHeld, placeOf, present, recordFor, saveRecord };
|
|
183
|
+
export { type DiskError, type Failing, type Place, type Recorded, type Store, computerOf, findRecord, keySegment, listed, movedTo, notHeld, placeOf, present, recordFor, saveRecord };
|
|
@@ -1,12 +1,9 @@
|
|
|
1
1
|
import { ComputerHeldError } from "../../contract/computer/computer-held.errors.mjs";
|
|
2
|
-
import { ComputerLimitError } from "../../contract/computer/computer-limit.errors.mjs";
|
|
3
2
|
import { ComputerLostError } from "../../contract/computer/computer-lost.errors.mjs";
|
|
4
3
|
import { ComputerNotFoundError } from "../../contract/computer/computer-not-found.errors.mjs";
|
|
5
4
|
import { RecordJson } from "./local-computer.schema.mjs";
|
|
6
|
-
import {
|
|
5
|
+
import { Effect, FileSystem, Option, Schema, String } from "effect";
|
|
7
6
|
// src/host/computer/local-computer-records.layer.ts
|
|
8
|
-
/** The computers one store keeps at most, the hosted engine's default store ceiling, so a dev app meets it locally. */
|
|
9
|
-
const storeCeiling = 4;
|
|
10
7
|
const recordFile = "computer.json";
|
|
11
8
|
const dotsOnly = /^\.*$/u;
|
|
12
9
|
const encodeRecord = Schema.encodeEffect(RecordJson);
|
|
@@ -78,22 +75,4 @@ const movedTo = Effect.fnUntraced(function* movedTo(place, record, state) {
|
|
|
78
75
|
state
|
|
79
76
|
});
|
|
80
77
|
});
|
|
81
|
-
|
|
82
|
-
const keptIn = Effect.fnUntraced(function* keptIn({ path, root }) {
|
|
83
|
-
const fs = yield* FileSystem.FileSystem;
|
|
84
|
-
const under = (directory) => Effect.map(listed(directory), Array.map((entry) => path.join(directory, entry)));
|
|
85
|
-
const actors = yield* under(root);
|
|
86
|
-
const keys = yield* Effect.forEach(actors, under);
|
|
87
|
-
const computers = yield* Effect.forEach(Array.flatten(keys), under);
|
|
88
|
-
return yield* Effect.filter(Array.flatten(computers), (computer) => fs.exists(path.join(computer, recordFile)));
|
|
89
|
-
});
|
|
90
|
-
/** Makes a new computer only while the store has room, counting and making under one permit. */
|
|
91
|
-
const admitted = ({ computer, store }) => (making) => store.admission.withPermit(Effect.gen(function* admitComputer() {
|
|
92
|
-
if ((yield* keptIn(store)).length >= storeCeiling) return yield* ComputerLimitError.make({
|
|
93
|
-
computer,
|
|
94
|
-
limit: "store",
|
|
95
|
-
maximum: storeCeiling
|
|
96
|
-
});
|
|
97
|
-
return yield* making;
|
|
98
|
-
}));
|
|
99
|
-
export { admitted, computerOf, findRecord, keySegment, listed, movedTo, notHeld, placeOf, present, recordFor, saveRecord };
|
|
78
|
+
export { computerOf, findRecord, keySegment, listed, movedTo, notHeld, placeOf, present, recordFor, saveRecord };
|
|
@@ -3,7 +3,7 @@ import { ComputerHeldError } from "../../contract/computer/computer-held.errors.
|
|
|
3
3
|
import { ComputersUnavailableError } from "../../contract/computer/computers-unavailable.errors.mjs";
|
|
4
4
|
import "../../contract/computer/computer.schema.mjs";
|
|
5
5
|
import { localLog } from "./local-computer-log.layer.mjs";
|
|
6
|
-
import {
|
|
6
|
+
import { computerOf, findRecord, keySegment, listed, movedTo, notHeld, placeOf, present, recordFor, saveRecord } from "./local-computer-records.layer.mjs";
|
|
7
7
|
import { runStep } from "./local-step.layer.mjs";
|
|
8
8
|
import { localComputerFile } from "./local-computer-files.layer.mjs";
|
|
9
9
|
import { Array, Clock, Context, Effect, FileSystem, MutableHashMap, Option, Path, Semaphore, String as String$1 } from "effect";
|
|
@@ -38,10 +38,7 @@ const made = Effect.fnUntraced(function* made(local, place, spec) {
|
|
|
38
38
|
});
|
|
39
39
|
/** An existing computer keeps its image and size and takes the new spec's egress and secrets. */
|
|
40
40
|
const ensureIn = (local, place, spec) => Effect.flatMap(findRecord(place), (found) => Option.match(found, {
|
|
41
|
-
onNone: () =>
|
|
42
|
-
computer: spec.name,
|
|
43
|
-
store: local
|
|
44
|
-
})(made(local, place, spec)),
|
|
41
|
+
onNone: () => made(local, place, spec),
|
|
45
42
|
onSome: (record) => Effect.gen(function* ensureExisting() {
|
|
46
43
|
yield* notHeld(record);
|
|
47
44
|
yield* present(place);
|
|
@@ -164,13 +161,10 @@ const forkIn = (local, place, fork) => Effect.gen(function* forkComputer() {
|
|
|
164
161
|
const existing = yield* findRecord(target);
|
|
165
162
|
if (Option.isSome(existing)) return yield* computerOf(target, existing.value);
|
|
166
163
|
yield* present(place);
|
|
167
|
-
const forked = yield*
|
|
168
|
-
computer: name,
|
|
169
|
-
store: local
|
|
170
|
-
})(copied(place, target, {
|
|
164
|
+
const forked = yield* copied(place, target, {
|
|
171
165
|
...record.spec,
|
|
172
166
|
name
|
|
173
|
-
})
|
|
167
|
+
});
|
|
174
168
|
yield* local.log.note(place, "info", `forked into ${fork.into.actor}/${fork.into.key}${heldNote(fork.name, "as")}`);
|
|
175
169
|
return yield* computerOf(target, forked);
|
|
176
170
|
});
|
|
@@ -219,7 +213,6 @@ const clientFor = (local, answered) => (address) => {
|
|
|
219
213
|
const localAt = Effect.fnUntraced(function* localAt(root) {
|
|
220
214
|
const path = yield* Path.Path;
|
|
221
215
|
return {
|
|
222
|
-
admission: yield* Semaphore.make(onePermit),
|
|
223
216
|
locks: MutableHashMap.empty(),
|
|
224
217
|
log: localLog(path),
|
|
225
218
|
path,
|
|
@@ -1,18 +1,14 @@
|
|
|
1
1
|
import { ActorUnavailableError } from "../../contract/client/actor-unavailable.errors.mjs";
|
|
2
|
-
import { InvalidRequestError } from "../../contract/client/invalid-request.errors.mjs";
|
|
3
2
|
import { ActorComputerClients } from "../../contract/computer/computer-client.adapter.mjs";
|
|
4
3
|
import { localLog } from "../computer/local-computer-log.layer.mjs";
|
|
5
|
-
import {
|
|
6
|
-
import { Array, Clock, Context, Effect, FileSystem, Layer, Match, Option, Path,
|
|
4
|
+
import { findRecord, listed, placeOf, saveRecord } from "../computer/local-computer-records.layer.mjs";
|
|
5
|
+
import { Array, Clock, Context, Effect, FileSystem, Layer, Match, Option, Path, String } from "effect";
|
|
7
6
|
// src/host/fork/fork-computers.adapter.ts
|
|
8
7
|
const markPrefix = "@fork-";
|
|
9
|
-
const oneAtATime = 1;
|
|
10
8
|
const markOf = (fork) => `${markPrefix}${encodeURIComponent(fork)}`;
|
|
11
9
|
const storeOf = Effect.fnUntraced(function* storeOf(root) {
|
|
12
|
-
const path = yield* Path.Path;
|
|
13
10
|
return {
|
|
14
|
-
|
|
15
|
-
path,
|
|
11
|
+
path: yield* Path.Path,
|
|
16
12
|
root
|
|
17
13
|
};
|
|
18
14
|
});
|
|
@@ -22,10 +18,6 @@ const failedWith = (actor) => (failure) => Match.value(failure).pipe(Match.tags(
|
|
|
22
18
|
actor,
|
|
23
19
|
message: `${actor} can't fork while its computer ${computer} is held by ${hold}, a fork that didn't finish. Retry that fork, or resume the computer with the hold ${hold}, to release it.`
|
|
24
20
|
}),
|
|
25
|
-
ComputerLimitError: ({ maximum }) => InvalidRequestError.make({
|
|
26
|
-
actor,
|
|
27
|
-
message: `${actor} can't fork: copying its computers would pass the store's limit of ${String(maximum)} computers. Remove a computer, then fork again.`
|
|
28
|
-
}),
|
|
29
21
|
InvalidRequestError: (invalid) => invalid
|
|
30
22
|
}), Match.orElse(() => ActorUnavailableError.make({
|
|
31
23
|
actor,
|
|
@@ -36,7 +28,7 @@ const foundIn = (place) => Effect.map(findRecord(place), Option.map(() => ({ pla
|
|
|
36
28
|
/** The parent's computers as their directories record them, by name. */
|
|
37
29
|
const parentComputers = Effect.fnUntraced(function* parentComputers(store, parent) {
|
|
38
30
|
const names = yield* listed(placeOf(store)(parent, "").actor);
|
|
39
|
-
const places = Array.map(Array.sort(names, String
|
|
31
|
+
const places = Array.map(Array.sort(names, String.Order), (name) => placeOf(store)(parent, name));
|
|
40
32
|
const found = yield* Effect.forEach(places, foundIn);
|
|
41
33
|
return Array.getSomes(found);
|
|
42
34
|
});
|
|
@@ -81,10 +73,7 @@ const copied = (store, { child, fork, parent }) => ({ name }) => {
|
|
|
81
73
|
const source = placeOf(store)(parent, name);
|
|
82
74
|
const target = placeOf(store)(child, name);
|
|
83
75
|
const note = `copied from ${parent.actor}/${parent.key} by the fork ${fork}, stopped`;
|
|
84
|
-
return Effect.andThen(
|
|
85
|
-
computer: name,
|
|
86
|
-
store
|
|
87
|
-
})(copyOne(source, target)), localLog(store.path).note(target, "info", note));
|
|
76
|
+
return Effect.andThen(copyOne(source, target), localLog(store.path).note(target, "info", note));
|
|
88
77
|
};
|
|
89
78
|
const alreadyKept = ({ child, parent }) => ActorUnavailableError.make({
|
|
90
79
|
actor: parent.actor,
|
|
@@ -142,7 +131,7 @@ const discardComputers = (forking) => answered(forking.parent.actor)(Effect.gen(
|
|
|
142
131
|
const store = yield* storeOf(forking.root);
|
|
143
132
|
const directory = placeOf(store)(forking.child, "").actor;
|
|
144
133
|
const names = yield* listed(directory);
|
|
145
|
-
if (Array.some(names, String
|
|
134
|
+
if (Array.some(names, String.startsWith(markPrefix))) yield* fs.remove(directory, {
|
|
146
135
|
force: true,
|
|
147
136
|
recursive: true
|
|
148
137
|
});
|
|
@@ -151,7 +140,7 @@ const discardComputers = (forking) => answered(forking.parent.actor)(Effect.gen(
|
|
|
151
140
|
const sweptPath = (path, root, stored) => (entry) => {
|
|
152
141
|
const mark = path.join(root, entry);
|
|
153
142
|
const owner = path.dirname(mark);
|
|
154
|
-
if (stored.has(String
|
|
143
|
+
if (stored.has(String.replaceAll(path.sep, "/")(path.relative(root, owner)))) return mark;
|
|
155
144
|
return owner;
|
|
156
145
|
};
|
|
157
146
|
/**
|
|
@@ -25,7 +25,7 @@ interface CommandAt {
|
|
|
25
25
|
readonly commandId: string;
|
|
26
26
|
readonly reader: SqlClient.SqlClient;
|
|
27
27
|
}
|
|
28
|
-
/** A command
|
|
28
|
+
/** A command the running engine batch's journal answers for has its seq there; any other is read. */
|
|
29
29
|
declare const commandVersion: (at: CommandAt) => Sql<Option.Option<number>>;
|
|
30
30
|
/**
|
|
31
31
|
* When a command recorded in the journal ran, in epoch milliseconds. One the running engine batch recorded, which is
|
|
@@ -53,11 +53,8 @@ const committedVersion = (reader) => Effect.map(reader`SELECT COALESCE(MAX(seq),
|
|
|
53
53
|
onSome: (row) => row.version
|
|
54
54
|
}));
|
|
55
55
|
const recordedVersion = ({ commandId, reader }) => Effect.map(reader`SELECT seq FROM akter_commands WHERE command_id = ${commandId}`, (rows) => Option.map(Array.head(rows), (row) => row.seq));
|
|
56
|
-
/** A command
|
|
57
|
-
const commandVersion = (at) =>
|
|
58
|
-
onNone: () => recordedVersion(at),
|
|
59
|
-
onSome: () => Effect.succeed(seq)
|
|
60
|
-
}));
|
|
56
|
+
/** A command the running engine batch's journal answers for has its seq there; any other is read. */
|
|
57
|
+
const commandVersion = (at) => CommandBatchJournal.seq(at.reader, at.commandId, recordedVersion(at));
|
|
61
58
|
/**
|
|
62
59
|
* When a command recorded in the journal ran, in epoch milliseconds. One the running engine batch recorded, which is
|
|
63
60
|
* not in the journal yet, has none: it ran moments ago.
|
|
@@ -1,36 +1,59 @@
|
|
|
1
1
|
import { CurrentCaller } from "../../../contract/caller/current-caller.service.mjs";
|
|
2
2
|
import { InvalidRequestError } from "../../../contract/client/invalid-request.errors.mjs";
|
|
3
|
-
import { ComputerFileRequest } from "../../../contract/computer/files/computer-files.schema.mjs";
|
|
3
|
+
import { ComputerFileRequest, computerFileBytes } from "../../../contract/computer/files/computer-files.schema.mjs";
|
|
4
4
|
import { filePathOf } from "../../../contract/file/file-path.pure.mjs";
|
|
5
5
|
import { hostFileClient } from "../../../runtime/local-file-clients.layer.mjs";
|
|
6
6
|
import { ownerOf } from "../file/file-rpc-access.pure.mjs";
|
|
7
|
-
import { Boolean, Effect, Predicate, Stream } from "effect";
|
|
7
|
+
import { Array, Boolean, Effect, Option, Predicate, Ref, Stream } from "effect";
|
|
8
8
|
// src/serve/rpc/computer/computer-copy.layer.ts
|
|
9
9
|
const invalid = (actor, message) => InvalidRequestError.make({
|
|
10
10
|
actor,
|
|
11
11
|
message
|
|
12
12
|
});
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
const nothing = 0;
|
|
14
|
+
const next = 1;
|
|
15
|
+
const concatenated = (before, after) => {
|
|
16
|
+
const joined = new Uint8Array(before.byteLength + after.byteLength);
|
|
16
17
|
joined.set(before);
|
|
17
|
-
joined.set(
|
|
18
|
-
return
|
|
19
|
-
}
|
|
20
|
-
|
|
18
|
+
joined.set(after, before.byteLength);
|
|
19
|
+
return joined;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* The file's bytes as the pieces one call carries: whole pieces of `computerFileBytes`, then what is left, and one
|
|
23
|
+
* empty piece for an empty file, so writing it still makes the file.
|
|
24
|
+
*/
|
|
25
|
+
const piecesOf = (source) => Stream.mapAccum(source, () => ({
|
|
26
|
+
pending: /* @__PURE__ */ new Uint8Array(),
|
|
27
|
+
sent: false
|
|
28
|
+
}), ({ pending, sent }, chunk) => {
|
|
29
|
+
const bytes = concatenated(pending, chunk);
|
|
30
|
+
const whole = Math.floor(bytes.byteLength / computerFileBytes);
|
|
31
|
+
const pieces = Boolean.match(whole > nothing, {
|
|
32
|
+
onFalse: () => [],
|
|
33
|
+
onTrue: () => Array.makeBy(whole, (index) => bytes.subarray(index * computerFileBytes, (index + next) * computerFileBytes))
|
|
34
|
+
});
|
|
35
|
+
return [{
|
|
36
|
+
pending: bytes.subarray(whole * computerFileBytes),
|
|
37
|
+
sent: sent || whole > nothing
|
|
38
|
+
}, pieces];
|
|
39
|
+
}, { onHalt: ({ pending, sent }) => Array.filter([pending], (rest) => rest.byteLength > nothing || !sent) });
|
|
40
|
+
/** The computer file's pieces from its start, until one comes back short. */
|
|
41
|
+
const readPieces = (read) => Stream.paginate(nothing, (offset) => Effect.map(read(offset), (piece) => [Array.filter([piece], (bytes) => bytes.byteLength > nothing), Option.liftPredicate(offset + piece.byteLength, () => piece.byteLength === computerFileBytes)]));
|
|
42
|
+
const transfer = Effect.fnUntraced(function* transfer({ client, request }, files) {
|
|
21
43
|
const computerPath = yield* filePathOf(request.computerPath);
|
|
22
|
-
if (request.direction === "toComputer") {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
44
|
+
if (request.direction === "toComputer") return { bytes: yield* Stream.runFoldEffect(piecesOf(files.download(request.path)), () => nothing, (offset, content) => Effect.as(client.files(request.computer, ComputerFileRequest.cases.WriteAt.make({
|
|
45
|
+
content,
|
|
46
|
+
offset,
|
|
47
|
+
path: computerPath
|
|
48
|
+
})), offset + content.byteLength)) };
|
|
49
|
+
const copied = yield* Ref.make(nothing);
|
|
50
|
+
const pieces = readPieces((offset) => client.files(request.computer, ComputerFileRequest.cases.ReadAt.make({
|
|
51
|
+
offset,
|
|
52
|
+
path: computerPath
|
|
53
|
+
}))).pipe(Stream.tap((piece) => Ref.update(copied, (bytes) => bytes + piece.byteLength)));
|
|
54
|
+
const receipt = yield* files.upload(pieces);
|
|
32
55
|
return {
|
|
33
|
-
bytes:
|
|
56
|
+
bytes: yield* Ref.get(copied),
|
|
34
57
|
receipt
|
|
35
58
|
};
|
|
36
59
|
});
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { Schema } from "effect";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* A new computer
|
|
5
|
-
*
|
|
6
|
-
* one is
|
|
4
|
+
* A new computer was refused for capacity, so none was made: `account`, the sandboxes the platform's provider account
|
|
5
|
+
* may run at once, with the most it holds. Try again once one is free; nothing waits for room. `store`, the computers
|
|
6
|
+
* one app environment may keep, is no longer set by Akter Cloud or the local engine; older engines may still send it.
|
|
7
7
|
*/
|
|
8
8
|
export class ComputerLimitError extends Schema.TaggedError<ComputerLimitError, { readonly brand: unique symbol }>()(
|
|
9
9
|
"ComputerLimitError",
|
|
@@ -1,11 +1,23 @@
|
|
|
1
1
|
import { Schema } from "effect";
|
|
2
2
|
|
|
3
|
-
/**
|
|
3
|
+
/** The most one call carries across the host/control link. Paths are relative to the computer's files directory. */
|
|
4
4
|
const computerFileBytes = 1_048_576;
|
|
5
5
|
|
|
6
|
+
const fileStart = 0;
|
|
7
|
+
|
|
8
|
+
const Offset = Schema.Int.check(Schema.isGreaterThanOrEqualTo(fileStart));
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A file of any size moves in pieces: `ReadAt` answers at most `computerFileBytes` from `offset`, fewer at the end of
|
|
12
|
+
* the file, and `WriteAt` at offset 0 replaces the file, and at a later offset cuts it to that length and appends, so
|
|
13
|
+
* a resent piece is written once. `Read` and `Write` move a whole file of at most `computerFileBytes` in one call, as
|
|
14
|
+
* engines before pieces did; they stay so a host and a control plane of different releases still copy small files.
|
|
15
|
+
*/
|
|
6
16
|
const ComputerFileRequest = Schema.TaggedUnion({
|
|
7
17
|
Read: { path: Schema.String },
|
|
18
|
+
ReadAt: { offset: Offset, path: Schema.String },
|
|
8
19
|
Write: { content: Schema.Uint8ArrayFromBase64, path: Schema.String },
|
|
20
|
+
WriteAt: { content: Schema.Uint8ArrayFromBase64, offset: Offset, path: Schema.String },
|
|
9
21
|
});
|
|
10
22
|
|
|
11
23
|
type ComputerFileRequest = typeof ComputerFileRequest.Type;
|
|
@@ -17,7 +17,7 @@ interface Journal {
|
|
|
17
17
|
readonly known: HashSet.HashSet<string>;
|
|
18
18
|
readonly nextSeq: number;
|
|
19
19
|
readonly recorded: HashMap.HashMap<string, RecordedCommand>;
|
|
20
|
-
/** The seq each command
|
|
20
|
+
/** The seq of each command the journal answers for and that has one: read at the start, or given in this batch. */
|
|
21
21
|
readonly seqs: HashMap.HashMap<string, number>;
|
|
22
22
|
readonly state: Option.Option<string>;
|
|
23
23
|
}
|
|
@@ -36,6 +36,7 @@ interface JournalRead {
|
|
|
36
36
|
readonly commandId: string;
|
|
37
37
|
readonly exit: string;
|
|
38
38
|
readonly principal: string;
|
|
39
|
+
readonly seq: number;
|
|
39
40
|
}
|
|
40
41
|
|
|
41
42
|
const CommandBatch = Context.Reference<Option.Option<Batch>>(
|
|
@@ -51,7 +52,7 @@ const nextOf = 1;
|
|
|
51
52
|
const IdList = Schema.fromJsonString(Schema.Array(Schema.String));
|
|
52
53
|
|
|
53
54
|
const journalRows = (sql: SqlClient.SqlClient, ids: string): Effect.Effect<readonly JournalRead[], SqlError.SqlError> =>
|
|
54
|
-
sql<JournalRead>`SELECT command_id AS "commandId", command, exit, principal FROM akter_commands WHERE command_id IN (SELECT value FROM json_each(${ids}))`;
|
|
55
|
+
sql<JournalRead>`SELECT command_id AS "commandId", command, exit, principal, seq FROM akter_commands WHERE command_id IN (SELECT value FROM json_each(${ids}))`;
|
|
55
56
|
|
|
56
57
|
const readJournal = Effect.fnUntraced(function* readJournal(sql: SqlClient.SqlClient, commandIds: readonly string[]) {
|
|
57
58
|
const ids = yield* Effect.orDie(Schema.encodeEffect(IdList)(commandIds));
|
|
@@ -66,9 +67,9 @@ const readJournal = Effect.fnUntraced(function* readJournal(sql: SqlClient.SqlCl
|
|
|
66
67
|
known: HashSet.fromIterable(commandIds),
|
|
67
68
|
nextSeq: Option.match(Arr.head(last), { onNone: () => firstSeq, onSome: (row) => row.seq + nextOf }),
|
|
68
69
|
recorded: HashMap.fromIterable(
|
|
69
|
-
Arr.map(rows, ({ commandId, ...recorded }): [string, RecordedCommand] => [commandId, recorded]),
|
|
70
|
+
Arr.map(rows, ({ commandId, seq: _seq, ...recorded }): [string, RecordedCommand] => [commandId, recorded]),
|
|
70
71
|
),
|
|
71
|
-
seqs: HashMap.
|
|
72
|
+
seqs: HashMap.fromIterable(Arr.map(rows, ({ commandId, seq }): [string, number] => [commandId, seq])),
|
|
72
73
|
state: Option.map(Arr.head(states), (row) => row.value),
|
|
73
74
|
} satisfies Journal;
|
|
74
75
|
});
|
|
@@ -153,17 +154,23 @@ const CommandBatchJournal = {
|
|
|
153
154
|
),
|
|
154
155
|
/** Notes a state the running command saved. */
|
|
155
156
|
saved: (value: string): Effect.Effect<void> => update((journal) => ({ ...journal, state: Option.some(value) })),
|
|
156
|
-
/**
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
157
|
+
/**
|
|
158
|
+
* The seq `commandId` was journaled with, from the batch journal when it answers for the id, so a command's check
|
|
159
|
+
* whether it already ran costs no round trip of its own.
|
|
160
|
+
*/
|
|
161
|
+
seq: (
|
|
162
|
+
sql: SqlClient.SqlClient,
|
|
163
|
+
commandId: string,
|
|
164
|
+
read: Effect.Effect<Option.Option<number>, SqlError.SqlError>,
|
|
165
|
+
): Effect.Effect<Option.Option<number>, SqlError.SqlError> =>
|
|
166
|
+
Effect.flatMap(journalOf(sql), (opened) =>
|
|
167
|
+
Option.match(
|
|
168
|
+
Option.filter(opened, ([, journal]) => answers(journal, commandId)),
|
|
169
|
+
{
|
|
170
|
+
onNone: () => read,
|
|
171
|
+
onSome: ([, journal]) => Effect.succeed(HashMap.get(journal.seqs, commandId)),
|
|
172
|
+
},
|
|
173
|
+
),
|
|
167
174
|
),
|
|
168
175
|
/** The actor's current state, from the batch journal when there is one. */
|
|
169
176
|
state: (
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import { Array as Arr, Effect, FileSystem, Path, Predicate, Stream, String as Str } from "effect";
|
|
2
2
|
|
|
3
3
|
import { InvalidRequestError } from "#src/contract/client/invalid-request.errors";
|
|
4
|
-
import { computerFileBytes } from "#src/contract/computer/files/computer-files.schema";
|
|
5
|
-
import type { ComputerFileRequest } from "#src/contract/computer/files/computer-files.schema";
|
|
4
|
+
import { ComputerFileRequest, computerFileBytes } from "#src/contract/computer/files/computer-files.schema";
|
|
6
5
|
import { filePathOf } from "#src/contract/file/file-path.pure";
|
|
7
6
|
import { notHeld, present, recordFor } from "./local-computer-records.layer.js";
|
|
8
7
|
import type { Place } from "./local-computer-records.layer.js";
|
|
9
8
|
|
|
10
9
|
const extraByte = 1;
|
|
11
10
|
|
|
11
|
+
const fileStart = 0;
|
|
12
|
+
|
|
12
13
|
const checkedPath = Effect.fnUntraced(function* checkedPath(disk: string, target: string, actor: string) {
|
|
13
14
|
const fs = yield* FileSystem.FileSystem;
|
|
14
15
|
|
|
@@ -45,6 +46,18 @@ const targetOf = Effect.fnUntraced(function* targetOf(place: Place, relative: st
|
|
|
45
46
|
);
|
|
46
47
|
});
|
|
47
48
|
|
|
49
|
+
const joined = (chunks: readonly Uint8Array[]): Uint8Array => {
|
|
50
|
+
const bytes = new Uint8Array(Arr.reduce(chunks, fileStart, (total, chunk) => total + chunk.byteLength));
|
|
51
|
+
|
|
52
|
+
Arr.reduce(chunks, fileStart, (offset, chunk) => {
|
|
53
|
+
bytes.set(chunk, offset);
|
|
54
|
+
|
|
55
|
+
return offset + chunk.byteLength;
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
return bytes;
|
|
59
|
+
};
|
|
60
|
+
|
|
48
61
|
const readFile = Effect.fnUntraced(function* readFile(target: string, actor: string) {
|
|
49
62
|
const fs = yield* FileSystem.FileSystem;
|
|
50
63
|
const stat = yield* fs.stat(target);
|
|
@@ -57,7 +70,7 @@ const readFile = Effect.fnUntraced(function* readFile(target: string, actor: str
|
|
|
57
70
|
}
|
|
58
71
|
|
|
59
72
|
const chunks = yield* Stream.runCollect(fs.stream(target, { bytesToRead: computerFileBytes + extraByte }));
|
|
60
|
-
const content =
|
|
73
|
+
const content = joined(chunks);
|
|
61
74
|
|
|
62
75
|
if (content.byteLength > computerFileBytes) {
|
|
63
76
|
return yield* InvalidRequestError.make({ actor, message: "a computer file holds at most 1 MiB" });
|
|
@@ -66,6 +79,20 @@ const readFile = Effect.fnUntraced(function* readFile(target: string, actor: str
|
|
|
66
79
|
return content;
|
|
67
80
|
});
|
|
68
81
|
|
|
82
|
+
/** At most one call's bytes from `offset`; fewer at the end of the file. */
|
|
83
|
+
const readPiece = Effect.fnUntraced(function* readPiece(target: string, offset: number, actor: string) {
|
|
84
|
+
const fs = yield* FileSystem.FileSystem;
|
|
85
|
+
const stat = yield* fs.stat(target);
|
|
86
|
+
|
|
87
|
+
if (stat.type !== "File") {
|
|
88
|
+
return yield* InvalidRequestError.make({ actor, message: "a computer file must be a regular file" });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const chunks = yield* Stream.runCollect(fs.stream(target, { bytesToRead: computerFileBytes, offset }));
|
|
92
|
+
|
|
93
|
+
return joined(chunks);
|
|
94
|
+
});
|
|
95
|
+
|
|
69
96
|
const writeFile = Effect.fnUntraced(function* writeFile(target: string, content: Uint8Array) {
|
|
70
97
|
const fs = yield* FileSystem.FileSystem;
|
|
71
98
|
const path = yield* Path.Path;
|
|
@@ -77,6 +104,28 @@ const writeFile = Effect.fnUntraced(function* writeFile(target: string, content:
|
|
|
77
104
|
return new Uint8Array();
|
|
78
105
|
}, Effect.scoped);
|
|
79
106
|
|
|
107
|
+
/** At offset 0 the piece replaces the file; later, the file is cut to `offset` and the piece appended. */
|
|
108
|
+
const writePiece = Effect.fnUntraced(function* writePiece(
|
|
109
|
+
target: string,
|
|
110
|
+
{ actor, content, offset }: { readonly actor: string; readonly content: Uint8Array; readonly offset: number },
|
|
111
|
+
) {
|
|
112
|
+
if (offset === fileStart) {
|
|
113
|
+
return yield* writeFile(target, content);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const fs = yield* FileSystem.FileSystem;
|
|
117
|
+
const stat = yield* fs.stat(target);
|
|
118
|
+
|
|
119
|
+
if (stat.size < BigInt(offset)) {
|
|
120
|
+
return yield* InvalidRequestError.make({ actor, message: "a piece must follow the bytes already written" });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
yield* fs.truncate(target, offset);
|
|
124
|
+
yield* fs.writeFile(target, content, { flag: "a" });
|
|
125
|
+
|
|
126
|
+
return new Uint8Array();
|
|
127
|
+
});
|
|
128
|
+
|
|
80
129
|
const localComputerFile = Effect.fnUntraced(function* localComputerFile(place: Place, request: ComputerFileRequest) {
|
|
81
130
|
const record = yield* recordFor(place);
|
|
82
131
|
|
|
@@ -87,17 +136,23 @@ const localComputerFile = Effect.fnUntraced(function* localComputerFile(place: P
|
|
|
87
136
|
InvalidRequestError.make({ actor: place.actor, message: failure.message }),
|
|
88
137
|
);
|
|
89
138
|
|
|
90
|
-
|
|
91
|
-
return yield* InvalidRequestError.make({ actor: place.actor, message: "a computer file holds at most 1 MiB" });
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
const target = yield* targetOf(place, relative, Predicate.isTagged(request, "Write"));
|
|
139
|
+
const writing = Predicate.isTagged(request, "Write") || Predicate.isTagged(request, "WriteAt");
|
|
95
140
|
|
|
96
|
-
if (
|
|
97
|
-
return yield*
|
|
141
|
+
if (writing && request.content.byteLength > computerFileBytes) {
|
|
142
|
+
return yield* InvalidRequestError.make({
|
|
143
|
+
actor: place.actor,
|
|
144
|
+
message: "a computer file call carries at most 1 MiB",
|
|
145
|
+
});
|
|
98
146
|
}
|
|
99
147
|
|
|
100
|
-
|
|
148
|
+
const target = yield* targetOf(place, relative, writing);
|
|
149
|
+
|
|
150
|
+
return yield* ComputerFileRequest.match(request, {
|
|
151
|
+
Read: () => readFile(target, place.actor),
|
|
152
|
+
ReadAt: ({ offset }) => readPiece(target, offset, place.actor),
|
|
153
|
+
Write: ({ content }) => writeFile(target, content),
|
|
154
|
+
WriteAt: ({ content, offset }) => writePiece(target, { actor: place.actor, content, offset }),
|
|
155
|
+
});
|
|
101
156
|
});
|
|
102
157
|
|
|
103
158
|
export { localComputerFile };
|
|
@@ -1,18 +1,14 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import type { Path
|
|
1
|
+
import { Effect, FileSystem, Option, Schema, String as Str } from "effect";
|
|
2
|
+
import type { Path } from "effect";
|
|
3
3
|
|
|
4
4
|
import type { ComputerAddress, ComputerClientFailure } from "#src/contract/computer/computer-client.contract";
|
|
5
5
|
import { ComputerHeldError } from "#src/contract/computer/computer-held.errors";
|
|
6
|
-
import { ComputerLimitError } from "#src/contract/computer/computer-limit.errors";
|
|
7
6
|
import { ComputerLostError } from "#src/contract/computer/computer-lost.errors";
|
|
8
7
|
import { ComputerNotFoundError } from "#src/contract/computer/computer-not-found.errors";
|
|
9
8
|
import type { Computer, ComputerState } from "#src/contract/computer/computer.schema";
|
|
10
9
|
import { RecordJson } from "./local-computer.schema.js";
|
|
11
10
|
import type { LocalComputerRecord, LocalState } from "./local-computer.schema.js";
|
|
12
11
|
|
|
13
|
-
/** The computers one store keeps at most, the hosted engine's default store ceiling, so a dev app meets it locally. */
|
|
14
|
-
const storeCeiling = 4;
|
|
15
|
-
|
|
16
12
|
const recordFile = "computer.json";
|
|
17
13
|
|
|
18
14
|
const dotsOnly = /^\.*$/u;
|
|
@@ -30,8 +26,6 @@ type Recorded<Value> = Effect.Effect<Value, Failing, FileSystem.FileSystem>;
|
|
|
30
26
|
|
|
31
27
|
/** Where the local computers of one store live. */
|
|
32
28
|
interface Store {
|
|
33
|
-
/** One permit: a new computer is counted and made one at a time, so two at once can't pass the limit. */
|
|
34
|
-
readonly admission: Semaphore.Semaphore;
|
|
35
29
|
readonly path: Path.Path;
|
|
36
30
|
readonly root: string;
|
|
37
31
|
}
|
|
@@ -153,53 +147,6 @@ const movedTo = Effect.fnUntraced(function* movedTo(place: Place, record: LocalC
|
|
|
153
147
|
return yield* saveRecord(place, { ...record, state });
|
|
154
148
|
});
|
|
155
149
|
|
|
156
|
-
|
|
157
|
-
const keptIn = Effect.fnUntraced(function* keptIn({ path, root }: Store) {
|
|
158
|
-
const fs = yield* FileSystem.FileSystem;
|
|
159
|
-
|
|
160
|
-
const under = (directory: string): Effect.Effect<readonly string[], DiskError, FileSystem.FileSystem> =>
|
|
161
|
-
Effect.map(
|
|
162
|
-
listed(directory),
|
|
163
|
-
Arr.map((entry) => path.join(directory, entry)),
|
|
164
|
-
);
|
|
165
|
-
|
|
166
|
-
const actors = yield* under(root);
|
|
167
|
-
const keys = yield* Effect.forEach(actors, under);
|
|
168
|
-
const computers = yield* Effect.forEach(Arr.flatten(keys), under);
|
|
169
|
-
|
|
170
|
-
return yield* Effect.filter(Arr.flatten(computers), (computer) => fs.exists(path.join(computer, recordFile)));
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
/** Makes a new computer only while the store has room, counting and making under one permit. */
|
|
174
|
-
const admitted =
|
|
175
|
-
({ computer, store }: { readonly computer: string; readonly store: Store }) =>
|
|
176
|
-
<Value, Services>(
|
|
177
|
-
making: Effect.Effect<Value, Failing, Services>,
|
|
178
|
-
): Effect.Effect<Value, Failing, Services | FileSystem.FileSystem> =>
|
|
179
|
-
store.admission.withPermit(
|
|
180
|
-
Effect.gen(function* admitComputer() {
|
|
181
|
-
const computers = yield* keptIn(store);
|
|
182
|
-
|
|
183
|
-
if (computers.length >= storeCeiling) {
|
|
184
|
-
return yield* ComputerLimitError.make({ computer, limit: "store", maximum: storeCeiling });
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
return yield* making;
|
|
188
|
-
}),
|
|
189
|
-
);
|
|
190
|
-
|
|
191
|
-
export {
|
|
192
|
-
admitted,
|
|
193
|
-
computerOf,
|
|
194
|
-
findRecord,
|
|
195
|
-
keySegment,
|
|
196
|
-
listed,
|
|
197
|
-
movedTo,
|
|
198
|
-
notHeld,
|
|
199
|
-
placeOf,
|
|
200
|
-
present,
|
|
201
|
-
recordFor,
|
|
202
|
-
saveRecord,
|
|
203
|
-
};
|
|
150
|
+
export { computerOf, findRecord, keySegment, listed, movedTo, notHeld, placeOf, present, recordFor, saveRecord };
|
|
204
151
|
|
|
205
152
|
export type { DiskError, Failing, Place, Recorded, Store };
|
|
@@ -26,7 +26,6 @@ import type { Computer, ComputerSpec, ExecRequest, ExecResult } from "#src/contr
|
|
|
26
26
|
import { localLog } from "./local-computer-log.layer.js";
|
|
27
27
|
import type { LocalLog } from "./local-computer-log.layer.js";
|
|
28
28
|
import {
|
|
29
|
-
admitted,
|
|
30
29
|
computerOf,
|
|
31
30
|
findRecord,
|
|
32
31
|
keySegment,
|
|
@@ -91,7 +90,7 @@ const made = Effect.fnUntraced(function* made(local: Local, place: Place, spec:
|
|
|
91
90
|
const ensureIn = (local: Local, place: Place, spec: ComputerSpec): Answer<Computer> =>
|
|
92
91
|
Effect.flatMap(findRecord(place), (found) =>
|
|
93
92
|
Option.match(found, {
|
|
94
|
-
onNone: () =>
|
|
93
|
+
onNone: () => made(local, place, spec),
|
|
95
94
|
onSome: (record) =>
|
|
96
95
|
Effect.gen(function* ensureExisting() {
|
|
97
96
|
yield* notHeld(record);
|
|
@@ -255,7 +254,7 @@ const forkIn = (local: Local, place: Place, fork: ComputerFork): Answer<Computer
|
|
|
255
254
|
|
|
256
255
|
yield* present(place);
|
|
257
256
|
|
|
258
|
-
const forked = yield*
|
|
257
|
+
const forked = yield* copied(place, target, { ...record.spec, name });
|
|
259
258
|
|
|
260
259
|
yield* local.log.note(place, "info", `forked into ${fork.into.actor}/${fork.into.key}${heldNote(fork.name, "as")}`);
|
|
261
260
|
|
|
@@ -338,10 +337,8 @@ const clientFor =
|
|
|
338
337
|
*/
|
|
339
338
|
const localAt = Effect.fnUntraced(function* localAt(root: string) {
|
|
340
339
|
const path = yield* Path.Path;
|
|
341
|
-
const admission = yield* Semaphore.make(onePermit);
|
|
342
340
|
|
|
343
341
|
return {
|
|
344
|
-
admission,
|
|
345
342
|
locks: MutableHashMap.empty<string, Semaphore.Semaphore>(),
|
|
346
343
|
log: localLog(path),
|
|
347
344
|
path,
|
|
@@ -1,23 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
Array as Arr,
|
|
3
|
-
Clock,
|
|
4
|
-
Context,
|
|
5
|
-
Effect,
|
|
6
|
-
FileSystem,
|
|
7
|
-
Layer,
|
|
8
|
-
Match,
|
|
9
|
-
Option,
|
|
10
|
-
Path,
|
|
11
|
-
Semaphore,
|
|
12
|
-
String as Str,
|
|
13
|
-
} from "effect";
|
|
1
|
+
import { Array as Arr, Clock, Context, Effect, FileSystem, Layer, Match, Option, Path, String as Str } from "effect";
|
|
14
2
|
|
|
15
3
|
import { ActorUnavailableError } from "#src/contract/client/actor-unavailable.errors";
|
|
16
|
-
import { InvalidRequestError } from "#src/contract/client/invalid-request.errors";
|
|
4
|
+
import type { InvalidRequestError } from "#src/contract/client/invalid-request.errors";
|
|
17
5
|
import { ActorComputerClients } from "#src/contract/computer/computer-client.adapter";
|
|
18
6
|
import type { ComputerAddress, ComputerClient } from "#src/contract/computer/computer-client.contract";
|
|
19
7
|
import { localLog } from "#src/host/computer/local-computer-log.layer";
|
|
20
|
-
import {
|
|
8
|
+
import { findRecord, listed, placeOf, saveRecord } from "#src/host/computer/local-computer-records.layer";
|
|
21
9
|
import type { Failing, Place, Store } from "#src/host/computer/local-computer-records.layer";
|
|
22
10
|
|
|
23
11
|
/** One actor fork's computers: the parent's, copied into the child, under the store's computers directory. */
|
|
@@ -48,15 +36,12 @@ interface Found {
|
|
|
48
36
|
|
|
49
37
|
const markPrefix = "@fork-";
|
|
50
38
|
|
|
51
|
-
const oneAtATime = 1;
|
|
52
|
-
|
|
53
39
|
// A computer's name starts with a letter and a key segment never holds "@", so a mark is never read as either.
|
|
54
40
|
const markOf = (fork: string): string => `${markPrefix}${encodeURIComponent(fork)}`;
|
|
55
41
|
|
|
56
42
|
const storeOf = Effect.fnUntraced(function* storeOf(root: string) {
|
|
57
43
|
const path = yield* Path.Path;
|
|
58
|
-
const
|
|
59
|
-
const store: Store = { admission, path, root };
|
|
44
|
+
const store: Store = { path, root };
|
|
60
45
|
|
|
61
46
|
return store;
|
|
62
47
|
});
|
|
@@ -72,11 +57,6 @@ const failedWith =
|
|
|
72
57
|
actor,
|
|
73
58
|
message: `${actor} can't fork while its computer ${computer} is held by ${hold}, a fork that didn't finish. Retry that fork, or resume the computer with the hold ${hold}, to release it.`,
|
|
74
59
|
}),
|
|
75
|
-
ComputerLimitError: ({ maximum }) =>
|
|
76
|
-
InvalidRequestError.make({
|
|
77
|
-
actor,
|
|
78
|
-
message: `${actor} can't fork: copying its computers would pass the store's limit of ${String(maximum)} computers. Remove a computer, then fork again.`,
|
|
79
|
-
}),
|
|
80
60
|
InvalidRequestError: (invalid): ForkFailure => invalid,
|
|
81
61
|
}),
|
|
82
62
|
Match.orElse(() =>
|
|
@@ -174,10 +154,7 @@ const copied =
|
|
|
174
154
|
const target = placeOf(store)(child, name);
|
|
175
155
|
const note = `copied from ${parent.actor}/${parent.key} by the fork ${fork}, stopped`;
|
|
176
156
|
|
|
177
|
-
return Effect.andThen(
|
|
178
|
-
admitted({ computer: name, store })(copyOne(source, target)),
|
|
179
|
-
localLog(store.path).note(target, "info", note),
|
|
180
|
-
);
|
|
157
|
+
return Effect.andThen(copyOne(source, target), localLog(store.path).note(target, "info", note));
|
|
181
158
|
};
|
|
182
159
|
|
|
183
160
|
const alreadyKept = ({ child, parent }: ComputersFork): ActorUnavailableError =>
|
|
@@ -119,11 +119,9 @@ const recordedVersion = ({ commandId, reader }: CommandAt): Sql<Option.Option<nu
|
|
|
119
119
|
Option.map(Arr.head(rows), (row) => row.seq),
|
|
120
120
|
);
|
|
121
121
|
|
|
122
|
-
/** A command
|
|
122
|
+
/** A command the running engine batch's journal answers for has its seq there; any other is read. */
|
|
123
123
|
const commandVersion = (at: CommandAt): Sql<Option.Option<number>> =>
|
|
124
|
-
|
|
125
|
-
Option.match(seq, { onNone: () => recordedVersion(at), onSome: () => Effect.succeed(seq) }),
|
|
126
|
-
);
|
|
124
|
+
CommandBatchJournal.seq(at.reader, at.commandId, recordedVersion(at));
|
|
127
125
|
|
|
128
126
|
/**
|
|
129
127
|
* When a command recorded in the journal ran, in epoch milliseconds. One the running engine batch recorded, which is
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Boolean as Bool, Effect, Predicate, Stream } from "effect";
|
|
1
|
+
import { Array as Arr, Boolean as Bool, Effect, Option, Predicate, Ref, Stream } from "effect";
|
|
2
2
|
|
|
3
3
|
import { CurrentCaller } from "#src/contract/caller/current-caller.service";
|
|
4
4
|
import { InvalidRequestError } from "#src/contract/client/invalid-request.errors";
|
|
@@ -13,25 +13,60 @@ import type { FileActor } from "#src/serve/rpc/file/file-rpc-access.pure";
|
|
|
13
13
|
|
|
14
14
|
const invalid = (actor: string, message: string): InvalidRequestError => InvalidRequestError.make({ actor, message });
|
|
15
15
|
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
16
|
+
const nothing = 0;
|
|
17
|
+
|
|
18
|
+
const next = 1;
|
|
19
|
+
|
|
20
|
+
interface Piecing {
|
|
21
|
+
/** Bytes read but not yet a whole piece. */
|
|
22
|
+
readonly pending: Uint8Array;
|
|
23
|
+
readonly sent: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const concatenated = (before: Uint8Array, after: Uint8Array): Uint8Array => {
|
|
27
|
+
const joined = new Uint8Array(before.byteLength + after.byteLength);
|
|
27
28
|
|
|
28
|
-
|
|
29
|
+
joined.set(before);
|
|
30
|
+
joined.set(after, before.byteLength);
|
|
29
31
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
+
return joined;
|
|
33
|
+
};
|
|
32
34
|
|
|
33
|
-
|
|
35
|
+
/**
|
|
36
|
+
* The file's bytes as the pieces one call carries: whole pieces of `computerFileBytes`, then what is left, and one
|
|
37
|
+
* empty piece for an empty file, so writing it still makes the file.
|
|
38
|
+
*/
|
|
39
|
+
const piecesOf = <Failure, Services>(
|
|
40
|
+
source: Stream.Stream<Uint8Array, Failure, Services>,
|
|
41
|
+
): Stream.Stream<Uint8Array, Failure, Services> =>
|
|
42
|
+
Stream.mapAccum(
|
|
43
|
+
source,
|
|
44
|
+
(): Piecing => ({ pending: new Uint8Array(), sent: false }),
|
|
45
|
+
({ pending, sent }, chunk) => {
|
|
46
|
+
const bytes = concatenated(pending, chunk);
|
|
47
|
+
const whole = Math.floor(bytes.byteLength / computerFileBytes);
|
|
48
|
+
|
|
49
|
+
// `Arr.makeBy` makes at least one element, so no whole piece is no pieces.
|
|
50
|
+
const pieces = Bool.match(whole > nothing, {
|
|
51
|
+
onFalse: () => [],
|
|
52
|
+
onTrue: () =>
|
|
53
|
+
Arr.makeBy(whole, (index) => bytes.subarray(index * computerFileBytes, (index + next) * computerFileBytes)),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
return [{ pending: bytes.subarray(whole * computerFileBytes), sent: sent || whole > nothing }, pieces];
|
|
34
57
|
},
|
|
58
|
+
{ onHalt: ({ pending, sent }) => Arr.filter([pending], (rest) => rest.byteLength > nothing || !sent) },
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
/** The computer file's pieces from its start, until one comes back short. */
|
|
62
|
+
const readPieces = (
|
|
63
|
+
read: (offset: number) => Effect.Effect<Uint8Array, ComputerClientFailure>,
|
|
64
|
+
): Stream.Stream<Uint8Array, ComputerClientFailure> =>
|
|
65
|
+
Stream.paginate(nothing, (offset) =>
|
|
66
|
+
Effect.map(read(offset), (piece) => [
|
|
67
|
+
Arr.filter([piece], (bytes) => bytes.byteLength > nothing),
|
|
68
|
+
Option.liftPredicate(offset + piece.byteLength, () => piece.byteLength === computerFileBytes),
|
|
69
|
+
]),
|
|
35
70
|
);
|
|
36
71
|
|
|
37
72
|
interface Copying {
|
|
@@ -42,23 +77,37 @@ interface Copying {
|
|
|
42
77
|
}
|
|
43
78
|
|
|
44
79
|
const transfer = Effect.fnUntraced(function* transfer(
|
|
45
|
-
{
|
|
80
|
+
{ client, request }: Copying,
|
|
46
81
|
files: ReturnType<typeof hostFileClient>,
|
|
47
82
|
) {
|
|
48
83
|
const computerPath = yield* filePathOf(request.computerPath);
|
|
49
84
|
|
|
50
85
|
if (request.direction === "toComputer") {
|
|
51
|
-
const
|
|
86
|
+
const bytes = yield* Stream.runFoldEffect(
|
|
87
|
+
piecesOf(files.download(request.path)),
|
|
88
|
+
() => nothing,
|
|
89
|
+
(offset, content) =>
|
|
90
|
+
Effect.as(
|
|
91
|
+
client.files(
|
|
92
|
+
request.computer,
|
|
93
|
+
ComputerFileRequest.cases.WriteAt.make({ content, offset, path: computerPath }),
|
|
94
|
+
),
|
|
95
|
+
offset + content.byteLength,
|
|
96
|
+
),
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
return { bytes } satisfies ComputerCopyResult;
|
|
100
|
+
}
|
|
52
101
|
|
|
53
|
-
|
|
102
|
+
const copied = yield* Ref.make(nothing);
|
|
54
103
|
|
|
55
|
-
|
|
56
|
-
|
|
104
|
+
const pieces = readPieces((offset) =>
|
|
105
|
+
client.files(request.computer, ComputerFileRequest.cases.ReadAt.make({ offset, path: computerPath })),
|
|
106
|
+
).pipe(Stream.tap((piece) => Ref.update(copied, (bytes) => bytes + piece.byteLength)));
|
|
57
107
|
|
|
58
|
-
const
|
|
59
|
-
const receipt = yield* files.upload(Stream.make(content));
|
|
108
|
+
const receipt = yield* files.upload(pieces);
|
|
60
109
|
|
|
61
|
-
return { bytes:
|
|
110
|
+
return { bytes: yield* Ref.get(copied), receipt } satisfies ComputerCopyResult;
|
|
62
111
|
});
|
|
63
112
|
|
|
64
113
|
/** Both directions pass the actor's file authorization rule; a copy never bypasses a command to attach a file. */
|