@akterjs/effect 0.1.2 → 0.1.4

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 (25) hide show
  1. package/dist/contract/computer/computer-limit.errors.d.mts +3 -3
  2. package/dist/contract/computer/computer-limit.errors.mjs +3 -3
  3. package/dist/contract/computer/files/computer-files.schema.d.mts +16 -1
  4. package/dist/contract/computer/files/computer-files.schema.mjs +17 -1
  5. package/dist/host/command/batch/command-batch.sql.d.mts +6 -3
  6. package/dist/host/command/batch/command-batch.sql.mjs +10 -7
  7. package/dist/host/computer/local-computer-files.layer.d.mts +10 -1
  8. package/dist/host/computer/local-computer-files.layer.mjs +50 -7
  9. package/dist/host/computer/local-computer-records.layer.d.mts +2 -9
  10. package/dist/host/computer/local-computer-records.layer.mjs +2 -23
  11. package/dist/host/computer/local-computers.adapter.mjs +4 -11
  12. package/dist/host/fork/fork-computers.adapter.mjs +7 -18
  13. package/dist/serve/live/live-tables.sql.d.mts +1 -1
  14. package/dist/serve/live/live-tables.sql.mjs +2 -5
  15. package/dist/serve/rpc/computer/computer-copy.layer.mjs +43 -20
  16. package/package.json +1 -1
  17. package/src/contract/computer/computer-limit.errors.ts +3 -3
  18. package/src/contract/computer/files/computer-files.schema.ts +13 -1
  19. package/src/host/command/batch/command-batch.sql.ts +22 -15
  20. package/src/host/computer/local-computer-files.layer.ts +66 -11
  21. package/src/host/computer/local-computer-records.layer.ts +3 -56
  22. package/src/host/computer/local-computers.adapter.ts +2 -5
  23. package/src/host/fork/fork-computers.adapter.ts +5 -28
  24. package/src/serve/live/live-tables.sql.ts +2 -4
  25. 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 would pass a limit, so none was made: `store`, the computers one app environment may keep, or
11
- * `account`, the sandboxes the platform's provider account may run at once. Remove a computer, or try again once
12
- * one is gone. Nothing waits for room.
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 would pass a limit, so none was made: `store`, the computers one app environment may keep, or
5
- * `account`, the sandboxes the platform's provider account may run at once. Remove a computer, or try again once
6
- * one is gone. Nothing waits for room.
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
- /** One file per call, bounded before crossing the host/control link. Paths are relative to the computer's files directory. */
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
- /** One file per call, bounded before crossing the host/control link. Paths are relative to the computer's files directory. */
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 recorded in this batch was given. */
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
- /** The seq a command recorded in this batch was given, or none when the batch did not record it. */
41
- readonly seq: (commandId: string) => Effect.Effect<Option.Option<number>>;
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.empty(),
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
- /** The seq a command recorded in this batch was given, or none when the batch did not record it. */
70
- seq: (commandId) => Effect.flatMap(CommandBatch, (batch) => Option.match(batch, {
71
- onNone: () => Effect.succeedNone,
72
- onSome: (open) => Effect.map(Ref.get(open.current), Option.flatMap((journal) => HashMap.get(journal.seqs, commandId)))
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
- }) => Effect.Effect<Uint8Array<ArrayBuffer>, Failing, FileSystem.FileSystem | Path.Path>;
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 = Uint8Array.from(Array.flatMap(chunks, (chunk) => [...chunk]));
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
- if (Predicate.isTagged(request, "Write") && request.content.byteLength > 1048576) return yield* InvalidRequestError.make({
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 holds at most 1 MiB"
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, Semaphore } from "effect";
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
- /** Makes a new computer only while the store has room, counting and making under one permit. */
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 { Array, Effect, FileSystem, Option, Schema, String } from "effect";
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
- /** Every computer the store keeps, of every actor. */
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 { admitted, computerOf, findRecord, keySegment, listed, movedTo, notHeld, placeOf, present, recordFor, saveRecord } from "./local-computer-records.layer.mjs";
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: () => admitted({
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* admitted({
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 { admitted, findRecord, listed, placeOf, saveRecord } from "../computer/local-computer-records.layer.mjs";
6
- import { Array, Clock, Context, Effect, FileSystem, Layer, Match, Option, Path, Semaphore, String as String$1 } from "effect";
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
- admission: yield* Semaphore.make(oneAtATime),
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$1.Order), (name) => placeOf(store)(parent, name));
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(admitted({
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$1.startsWith(markPrefix))) yield* fs.remove(directory, {
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$1.replaceAll(path.sep, "/")(path.relative(root, owner)))) return mark;
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 recorded in the running engine batch has its seq in the batch journal; any other is read. */
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 recorded in the running engine batch has its seq in the batch journal; any other is read. */
57
- const commandVersion = (at) => Effect.flatMap(CommandBatchJournal.seq(at.commandId), (seq) => Option.match(seq, {
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 collect = (actor, source) => Stream.runFoldEffect(source, () => /* @__PURE__ */ new Uint8Array(), (before, chunk) => {
14
- if (before.byteLength + chunk.byteLength > 1048576) return Effect.fail(invalid(actor, "a computer file holds at most 1 MiB"));
15
- const joined = new Uint8Array(before.byteLength + chunk.byteLength);
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(chunk, before.byteLength);
18
- return Effect.succeed(joined);
19
- });
20
- const transfer = Effect.fnUntraced(function* transfer({ actor, client, request }, files) {
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
- const content = yield* collect(actor.name, files.download(request.path));
24
- yield* client.files(request.computer, ComputerFileRequest.cases.Write.make({
25
- content,
26
- path: computerPath
27
- }));
28
- return { bytes: content.byteLength };
29
- }
30
- const content = yield* client.files(request.computer, ComputerFileRequest.cases.Read.make({ path: computerPath }));
31
- const receipt = yield* files.upload(Stream.make(content));
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: content.byteLength,
56
+ bytes: yield* Ref.get(copied),
34
57
  receipt
35
58
  };
36
59
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akterjs/effect",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "The Effect SDK for Akter: contracts, host runtime, local engine and Story tests.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -1,9 +1,9 @@
1
1
  import { Schema } from "effect";
2
2
 
3
3
  /**
4
- * A new computer would pass a limit, so none was made: `store`, the computers one app environment may keep, or
5
- * `account`, the sandboxes the platform's provider account may run at once. Remove a computer, or try again once
6
- * one is gone. Nothing waits for room.
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
- /** One file per call, bounded before crossing the host/control link. Paths are relative to the computer's files directory. */
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 recorded in this batch was given. */
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.empty(),
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
- /** The seq a command recorded in this batch was given, or none when the batch did not record it. */
157
- seq: (commandId: string): Effect.Effect<Option.Option<number>> =>
158
- Effect.flatMap(CommandBatch, (batch) =>
159
- Option.match(batch, {
160
- onNone: () => Effect.succeedNone,
161
- onSome: (open) =>
162
- Effect.map(
163
- Ref.get(open.current),
164
- Option.flatMap((journal) => HashMap.get(journal.seqs, commandId)),
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 = Uint8Array.from(Arr.flatMap(chunks, (chunk) => [...chunk]));
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
- if (Predicate.isTagged(request, "Write") && request.content.byteLength > computerFileBytes) {
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 (Predicate.isTagged(request, "Write")) {
97
- return yield* writeFile(target, request.content);
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
- return yield* readFile(target, place.actor);
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 { Array as Arr, Effect, FileSystem, Option, Schema, String as Str } from "effect";
2
- import type { Path, Semaphore } from "effect";
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
- /** Every computer the store keeps, of every actor. */
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: () => admitted({ computer: spec.name, store: local })(made(local, place, spec)),
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* admitted({ computer: name, store: local })(copied(place, target, { ...record.spec, name }));
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 { admitted, findRecord, listed, placeOf, saveRecord } from "#src/host/computer/local-computer-records.layer";
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 admission = yield* Semaphore.make(oneAtATime);
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 recorded in the running engine batch has its seq in the batch journal; any other is read. */
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
- Effect.flatMap(CommandBatchJournal.seq(at.commandId), (seq) =>
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 collect = <Failure, Services>(
17
- actor: string,
18
- source: Stream.Stream<Uint8Array, Failure, Services>,
19
- ): Effect.Effect<Uint8Array, Failure | InvalidRequestError, Services> =>
20
- Stream.runFoldEffect(
21
- source,
22
- () => new Uint8Array(),
23
- (before, chunk) => {
24
- if (before.byteLength + chunk.byteLength > computerFileBytes) {
25
- return Effect.fail(invalid(actor, "a computer file holds at most 1 MiB"));
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
- const joined = new Uint8Array(before.byteLength + chunk.byteLength);
29
+ joined.set(before);
30
+ joined.set(after, before.byteLength);
29
31
 
30
- joined.set(before);
31
- joined.set(chunk, before.byteLength);
32
+ return joined;
33
+ };
32
34
 
33
- return Effect.succeed(joined);
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
- { actor, client, request }: Copying,
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 content = yield* collect(actor.name, files.download(request.path));
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
- yield* client.files(request.computer, ComputerFileRequest.cases.Write.make({ content, path: computerPath }));
102
+ const copied = yield* Ref.make(nothing);
54
103
 
55
- return { bytes: content.byteLength } satisfies ComputerCopyResult;
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 content = yield* client.files(request.computer, ComputerFileRequest.cases.Read.make({ path: computerPath }));
59
- const receipt = yield* files.upload(Stream.make(content));
108
+ const receipt = yield* files.upload(pieces);
60
109
 
61
- return { bytes: content.byteLength, receipt } satisfies ComputerCopyResult;
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. */