@xandreed/plugin-session-files 0.0.0-stage → 0.15.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +4 -2
- package/dist/bounded-call.adapter.d.ts +14 -0
- package/dist/bounded-call.adapter.js +67 -0
- package/dist/files-io.adapter.d.ts +39 -0
- package/dist/files-io.adapter.js +22 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +7 -0
- package/dist/media-store-directory.adapter.d.ts +16 -0
- package/dist/media-store-directory.adapter.js +77 -0
- package/dist/media-store-directory.entity.d.ts +7 -0
- package/dist/media-store-directory.entity.js +2 -0
- package/dist/plugin.adapter.d.ts +26 -0
- package/dist/plugin.adapter.js +21 -0
- package/dist/session-files.entity.d.ts +135 -0
- package/dist/session-files.entity.functions.d.ts +76 -0
- package/dist/session-files.entity.functions.js +65 -0
- package/dist/session-files.entity.js +38 -0
- package/dist/session-log.adapter.d.ts +60 -0
- package/dist/session-log.adapter.js +287 -0
- package/package.json +29 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Xand Reed
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @xandreed/plugin-session-files
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Efferent composable agent framework, 0.15.1. Node 24 or Bun.
|
|
4
|
+
|
|
5
|
+
Documentation: https://github.com/xandreeddev/efferent
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { FileStoreIo, FileStoreStalled } from "./session-files.entity.js";
|
|
3
|
+
type Failed = FileStoreIo | FileStoreStalled;
|
|
4
|
+
/**
|
|
5
|
+
* Two native operations per handle. A call holds a slot from its admission
|
|
6
|
+
* until its native operation settles: one whose caller stopped waiting (a
|
|
7
|
+
* timeout, an interrupt) keeps its slot until it settles, the handle's
|
|
8
|
+
* capacity lowered by one meanwhile. While healthy, a call queues for a free
|
|
9
|
+
* slot within the admission deadline, so a hung operation slows the others
|
|
10
|
+
* and never fails them; a cooldown probe never waits (a full slot set fails
|
|
11
|
+
* it at once).
|
|
12
|
+
*/
|
|
13
|
+
export declare const makeBoundedFilesCall: (stallMs: number) => Effect.Effect<(<A>(op: string, path: string, run: () => Promise<A>) => Effect.Effect<A, Failed>), never, never>;
|
|
14
|
+
export {};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { Cause, Clock, Deferred, Effect, Exit, Option, Ref, Result, Semaphore } from "effect";
|
|
2
|
+
import { FileStoreIo, FileStoreStalled } from "./session-files.entity.js";
|
|
3
|
+
import { abandonCall, admitCall, afterCall } from "./session-files.entity.functions.js";
|
|
4
|
+
const withinDeadline = (promise, ms, stalled) => new Promise((resolve, reject) => {
|
|
5
|
+
const timer = setTimeout(() => reject(stalled), ms);
|
|
6
|
+
promise.then((value) => {
|
|
7
|
+
clearTimeout(timer);
|
|
8
|
+
resolve(value);
|
|
9
|
+
}, (error) => {
|
|
10
|
+
clearTimeout(timer);
|
|
11
|
+
reject(error);
|
|
12
|
+
});
|
|
13
|
+
}), NATIVE_SLOTS = 2;
|
|
14
|
+
export const makeBoundedFilesCall = Effect.fnUntraced(function* (stallMs) {
|
|
15
|
+
const slots = yield* Semaphore.make(NATIVE_SLOTS), health = yield* Ref.make({ _tag: "Healthy" }), orphans = { count: 0 }, capacity = (change) => Effect.suspend(() => {
|
|
16
|
+
orphans.count += change;
|
|
17
|
+
return slots.resize(NATIVE_SLOTS - orphans.count);
|
|
18
|
+
}), settle = (answered) => Clock.currentTimeMillis.pipe(Effect.flatMap((now) => Ref.update(health, (current) => afterCall(current, answered, now))));
|
|
19
|
+
return (op, path, run) => Effect.gen(function* () {
|
|
20
|
+
const stalled = new FileStoreStalled({ op, path, ms: stallMs }), now = yield* Clock.currentTimeMillis, before = yield* Ref.get(health);
|
|
21
|
+
if (!admitCall(before, now, stallMs)[0])
|
|
22
|
+
return yield* Effect.fail(stalled);
|
|
23
|
+
const admission = yield* Deferred.make(), execute = Effect.uninterruptibleMask((restore) => Effect.gen(function* () {
|
|
24
|
+
const claimed = yield* Clock.currentTimeMillis.pipe(Effect.flatMap((at) => Ref.modify(health, (current) => {
|
|
25
|
+
const [allowed, next] = admitCall(current, at, stallMs);
|
|
26
|
+
return [{ allowed, probe: allowed && current._tag === "Stalled" ? Option.some(next) : Option.none() }, next];
|
|
27
|
+
})));
|
|
28
|
+
if (!claimed.allowed)
|
|
29
|
+
return yield* Effect.fail(stalled);
|
|
30
|
+
const native = { settled: !1, orphaned: !1 }, outcome = yield* Effect.sync(() => Promise.resolve().then(run).then((value) => Result.succeed(value), (cause) => Result.fail(cause)).finally(() => {
|
|
31
|
+
native.settled = !0;
|
|
32
|
+
if (native.orphaned)
|
|
33
|
+
Effect.runSync(capacity(-1));
|
|
34
|
+
})), handOver = Effect.suspend(() => {
|
|
35
|
+
if (native.settled)
|
|
36
|
+
return Effect.void;
|
|
37
|
+
native.orphaned = !0;
|
|
38
|
+
return capacity(1);
|
|
39
|
+
});
|
|
40
|
+
return yield* restore(Effect.tryPromise({
|
|
41
|
+
try: () => withinDeadline(outcome, stallMs, stalled),
|
|
42
|
+
catch: (cause) => cause === stalled ? stalled : new FileStoreIo({ op, path, cause })
|
|
43
|
+
}).pipe(Effect.flatMap(Result.match({
|
|
44
|
+
onSuccess: Effect.succeed,
|
|
45
|
+
onFailure: (cause) => Effect.fail(new FileStoreIo({ op, path, cause }))
|
|
46
|
+
})))).pipe(Effect.onExit((exit) => handOver.pipe(Effect.andThen(() => {
|
|
47
|
+
if (Exit.isSuccess(exit))
|
|
48
|
+
return settle(!0);
|
|
49
|
+
const failed = exit.cause.reasons.find(Cause.isFailReason);
|
|
50
|
+
return failed === void 0 ? Option.match(claimed.probe, {
|
|
51
|
+
onNone: () => Effect.void,
|
|
52
|
+
onSome: (probe) => Ref.update(health, (current) => abandonCall(current, probe))
|
|
53
|
+
}) : settle(failed.error._tag === "FileStoreIo");
|
|
54
|
+
}))));
|
|
55
|
+
}));
|
|
56
|
+
if (before._tag === "Stalled")
|
|
57
|
+
return yield* slots.withPermitsIfAvailable(1)(execute).pipe(Effect.flatMap(Option.match({
|
|
58
|
+
onNone: () => Effect.fail(stalled),
|
|
59
|
+
onSome: Effect.succeed
|
|
60
|
+
})));
|
|
61
|
+
return yield* Effect.uninterruptibleMask((restore) => restore(Effect.raceFirst(slots.withPermits(1)(Effect.gen(function* () {
|
|
62
|
+
if (!(yield* Deferred.succeed(admission, void 0)))
|
|
63
|
+
return yield* Effect.fail(stalled);
|
|
64
|
+
return yield* restore(execute);
|
|
65
|
+
})).pipe(Effect.interruptible), Effect.sleep(stallMs).pipe(Effect.andThen(Deferred.fail(admission, stalled)), Effect.flatMap((expired) => expired ? Effect.fail(stalled) : Effect.never), Effect.interruptible))));
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The file system calls the store makes, each one promise: a seam a test
|
|
3
|
+
* replaces to make a call hang, fail or pause at a named point. Not a
|
|
4
|
+
* service: a constructor option of the store, like `SessionsLive`'s
|
|
5
|
+
* `liveness`.
|
|
6
|
+
*
|
|
7
|
+
* @public
|
|
8
|
+
*/
|
|
9
|
+
export interface FilesIo {
|
|
10
|
+
/** Create a directory and its parents (owner only); one that exists is fine. */
|
|
11
|
+
readonly mkdir: (path: string) => Promise<void>;
|
|
12
|
+
/** Create a new file with `bytes` (owner only), flushed to the disk before it answers; one that exists fails `EEXIST`. */
|
|
13
|
+
readonly create: (path: string, bytes: Uint8Array) => Promise<void>;
|
|
14
|
+
readonly link: (from: string, to: string) => Promise<void>;
|
|
15
|
+
readonly rename: (from: string, to: string) => Promise<void>;
|
|
16
|
+
readonly read: (path: string) => Promise<Uint8Array>;
|
|
17
|
+
/** The file's first bytes, up to `length` (all of them when the file is that long, however many reads it takes). */
|
|
18
|
+
readonly readStart: (path: string, length: number) => Promise<Uint8Array>;
|
|
19
|
+
readonly list: (path: string) => Promise<ReadonlyArray<string>>;
|
|
20
|
+
/** Whether the path exists; a missing one answers false, any other failure rejects. */
|
|
21
|
+
readonly exists: (path: string) => Promise<boolean>;
|
|
22
|
+
readonly unlink: (path: string) => Promise<void>;
|
|
23
|
+
/** Remove a directory and everything in it. */
|
|
24
|
+
readonly removeTree: (path: string) => Promise<void>;
|
|
25
|
+
/** Flush a directory's entries to the disk; a file system that cannot answers an error the store ignores. */
|
|
26
|
+
readonly syncDirectory: (path: string) => Promise<void>;
|
|
27
|
+
}
|
|
28
|
+
/** One positioned read: it may answer fewer bytes than asked (an interrupted call, a network or FUSE mount). */
|
|
29
|
+
export type ReadAt = (buffer: Uint8Array, offset: number, length: number, position: number) => Promise<{
|
|
30
|
+
readonly bytesRead: number;
|
|
31
|
+
}>;
|
|
32
|
+
/** The first `length` bytes of a file, reading again after a short answer until the buffer is full or the file ends. */
|
|
33
|
+
export declare const readFully: (read: ReadAt, length: number) => Promise<Uint8Array>;
|
|
34
|
+
/**
|
|
35
|
+
* The store's calls on the local machine's file system (`node:fs/promises`), on Node and Bun.
|
|
36
|
+
*
|
|
37
|
+
* @public
|
|
38
|
+
*/
|
|
39
|
+
export declare const nodeFilesIo: FilesIo;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { link, mkdir, open, readdir, readFile, rename, rm, stat, unlink } from "node:fs/promises";
|
|
2
|
+
import { Option } from "effect";
|
|
3
|
+
import { errorCodeOf } from "@xandreed/core";
|
|
4
|
+
const missing = (error) => Option.contains(errorCodeOf(error), "ENOENT");
|
|
5
|
+
export const readFully = (read, length) => {
|
|
6
|
+
const buffer = new Uint8Array(length), from = (offset) => offset >= length ? Promise.resolve(offset) : read(buffer, offset, length - offset, offset).then(({ bytesRead }) => bytesRead === 0 ? offset : from(offset + bytesRead));
|
|
7
|
+
return from(0).then((filled) => buffer.subarray(0, filled));
|
|
8
|
+
}, nodeFilesIo = {
|
|
9
|
+
mkdir: (path) => mkdir(path, { recursive: !0, mode: 448 }).then(() => {
|
|
10
|
+
return;
|
|
11
|
+
}),
|
|
12
|
+
create: (path, bytes) => open(path, "wx", 384).then((handle) => handle.writeFile(bytes).then(() => handle.sync()).then(() => handle.close(), (error) => handle.close().then(() => Promise.reject(error), () => Promise.reject(error)))),
|
|
13
|
+
link: (from, to) => link(from, to),
|
|
14
|
+
rename: (from, to) => rename(from, to),
|
|
15
|
+
read: (path) => readFile(path),
|
|
16
|
+
readStart: (path, length) => open(path, "r").then((handle) => readFully((buffer, offset, size, position) => handle.read(buffer, offset, size, position), length).then((bytes) => handle.close().then(() => bytes), (error) => handle.close().then(() => Promise.reject(error), () => Promise.reject(error)))),
|
|
17
|
+
list: (path) => readdir(path),
|
|
18
|
+
exists: (path) => stat(path).then(() => !0, (error) => missing(error) ? !1 : Promise.reject(error)),
|
|
19
|
+
unlink: (path) => unlink(path),
|
|
20
|
+
removeTree: (path) => rm(path, { recursive: !0, force: !0 }),
|
|
21
|
+
syncDirectory: (path) => open(path, "r").then((handle) => handle.sync().then(() => handle.close(), (error) => handle.close().then(() => Promise.reject(error), () => Promise.reject(error))))
|
|
22
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { sessionFilesPlugin } from "./plugin.adapter.js";
|
|
2
|
+
export { makeSessionLogFiles, SessionLogFilesLive } from "./session-log.adapter.js";
|
|
3
|
+
export type { SessionLogFilesOptions } from "./session-log.adapter.js";
|
|
4
|
+
export type { FilesIo } from "./files-io.adapter.js";
|
|
5
|
+
export { nodeFilesIo } from "./files-io.adapter.js";
|
|
6
|
+
export { MediaStoreDirectoryLive } from "./media-store-directory.adapter.js";
|
|
7
|
+
export type { MediaStoreDirectoryOptions } from "./media-store-directory.adapter.js";
|
|
8
|
+
export { sessionFilesPlugin, sessionLogFilesPlugin } from "./plugin.adapter.js";
|
|
9
|
+
export { SessionFilesConfig, sessionFilesDefaults } from "./session-files.entity.js";
|
|
10
|
+
/** @public */
|
|
11
|
+
export default sessionFilesPlugin;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { sessionFilesPlugin } from "./plugin.adapter.js";
|
|
2
|
+
export { makeSessionLogFiles, SessionLogFilesLive } from "./session-log.adapter.js";
|
|
3
|
+
export { nodeFilesIo } from "./files-io.adapter.js";
|
|
4
|
+
export { MediaStoreDirectoryLive } from "./media-store-directory.adapter.js";
|
|
5
|
+
export { sessionFilesPlugin, sessionLogFilesPlugin } from "./plugin.adapter.js";
|
|
6
|
+
export { SessionFilesConfig, sessionFilesDefaults } from "./session-files.entity.js";
|
|
7
|
+
export default sessionFilesPlugin;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Layer } from "effect";
|
|
2
|
+
import { MediaStore, MediaStoreError } from "@xandreed/core";
|
|
3
|
+
import type { FilesIo } from "./files-io.adapter.js";
|
|
4
|
+
/** The directory adapter's bounded IO and fault-injection seam. @public */
|
|
5
|
+
export interface MediaStoreDirectoryOptions {
|
|
6
|
+
readonly io?: FilesIo;
|
|
7
|
+
readonly stallMs?: number;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Immutable SHA-256 media blobs, published by exclusive hard link after file sync.
|
|
11
|
+
* The independent root retains orphans; session removal never deletes blobs.
|
|
12
|
+
* Opening sweeps temporary files older than an hour; a put of bytes whose final
|
|
13
|
+
* file is damaged keeps that file in `quarantine/` and replaces it.
|
|
14
|
+
* @public
|
|
15
|
+
*/
|
|
16
|
+
export declare const MediaStoreDirectoryLive: (root: string, options?: MediaStoreDirectoryOptions) => Layer.Layer<MediaStore, MediaStoreError, never>;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { Clock, Effect, Layer, Match, Option, Result, Schema } from "effect";
|
|
4
|
+
import { errorCodeOf, MEDIA_LIMITS, MediaHash, MediaStore, MediaStoreBytes, MediaStoreError } from "@xandreed/core";
|
|
5
|
+
import { nodeFilesIo } from "./files-io.adapter.js";
|
|
6
|
+
import { makeBoundedFilesCall } from "./bounded-call.adapter.js";
|
|
7
|
+
import { MEDIA_DIRECTORY_LAYOUT, MediaDirectoryMarker } from "./media-store-directory.entity.js";
|
|
8
|
+
import { sessionFilesDefaults } from "./session-files.entity.js";
|
|
9
|
+
const missing = (error) => error._tag === "FileStoreIo" && Option.contains(errorCodeOf(error.cause), "ENOENT"), unavailable = (error) => Match.valueTags(error, {
|
|
10
|
+
FileStoreIo: ({ cause }) => new MediaStoreError({ reason: "Unavailable", message: "The media directory is unavailable.", cause }),
|
|
11
|
+
FileStoreStalled: (cause) => new MediaStoreError({ reason: "Unavailable", message: "The media directory did not answer within its deadline.", cause })
|
|
12
|
+
}), corrupt = () => new MediaStoreError({ reason: "Corrupt", message: "The stored media does not match its content key." }), equal = (left, right) => left.length === right.length && left.every((byte, index) => byte === right[index]), stallBound = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 2147483647 })), sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex"), TEMPORARY_GRACE_MS = 3600000, createdAt = (name) => Option.map(Option.fromNullishOr(/^(?:[0-9a-f]{64}|marker)\.(\d{1,16})\.[0-9a-f-]{36}(?:\.tmp)?$/.exec(name)?.[1]), Number), makeDirectory = Effect.fnUntraced(function* (root, options) {
|
|
13
|
+
const io = options?.io ?? nodeFilesIo, stallMs = yield* Schema.decodeUnknownEffect(stallBound)(options?.stallMs ?? sessionFilesDefaults.stallMs).pipe(Effect.mapError((cause) => new MediaStoreError({ reason: "Invalid", message: "The media directory deadline is invalid.", cause }))), call = yield* makeBoundedFilesCall(stallMs), blobs = join(root, "blobs"), temporary = join(root, "tmp"), quarantine = join(root, "quarantine"), marker = join(root, "media-store.json"), encoder = new TextEncoder, decoder = new TextDecoder, clean = (path) => call("unlink", path, () => io.unlink(path)).pipe(Effect.ignore({ log: "Debug", message: "A temporary media file was left for later cleanup." })), sync = (path) => call("sync", path, () => io.syncDirectory(path)).pipe(Effect.ignore({ log: "Debug", message: "The media directory could not synchronize its directory entries." })), read = (path, max) => call("read", path, () => io.readStart(path, max + 1)).pipe(Effect.map(Option.some), Effect.catchIf(missing, () => Effect.succeed(Option.none())), Effect.mapError(unavailable)), markerAt = Effect.gen(function* () {
|
|
14
|
+
const found = yield* read(marker, 1024);
|
|
15
|
+
return yield* Option.match(found, {
|
|
16
|
+
onNone: () => Effect.succeed(!1),
|
|
17
|
+
onSome: (bytes) => Effect.gen(function* () {
|
|
18
|
+
if (bytes.length > 1024)
|
|
19
|
+
return yield* Effect.fail(new MediaStoreError({ reason: "Corrupt", message: "The media directory marker is invalid." }));
|
|
20
|
+
if ((yield* Schema.decodeUnknownEffect(Schema.fromJsonString(MediaDirectoryMarker))(decoder.decode(bytes)).pipe(Effect.mapError((cause) => new MediaStoreError({ reason: "Corrupt", message: "The media directory marker is invalid.", cause })))).layout !== MEDIA_DIRECTORY_LAYOUT)
|
|
21
|
+
return yield* Effect.fail(new MediaStoreError({ reason: "Unavailable", message: "The media directory layout is not supported." }));
|
|
22
|
+
return !0;
|
|
23
|
+
})
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
yield* Effect.forEach([root, blobs, temporary, quarantine], (path) => call("mkdir", path, () => io.mkdir(path)), { concurrency: 1, discard: !0 }).pipe(Effect.mapError(unavailable));
|
|
27
|
+
const temporaryPath = (prefix, suffix) => Clock.currentTimeMillis.pipe(Effect.map((now) => join(temporary, `${prefix}.${now}.${randomUUID()}${suffix}`)));
|
|
28
|
+
yield* Effect.gen(function* () {
|
|
29
|
+
const now = yield* Clock.currentTimeMillis, names = yield* call("list", temporary, () => io.list(temporary));
|
|
30
|
+
yield* Effect.forEach(names.filter((name) => Option.exists(createdAt(name), (at) => now - at > TEMPORARY_GRACE_MS)), (name) => clean(join(temporary, name)), { concurrency: 1, discard: !0 });
|
|
31
|
+
}).pipe(Effect.ignore({ log: "Debug", message: "The media directory's abandoned temporary files were not swept." }));
|
|
32
|
+
if (!(yield* markerAt)) {
|
|
33
|
+
const path = yield* temporaryPath("marker", "");
|
|
34
|
+
yield* Effect.gen(function* () {
|
|
35
|
+
yield* call("create", path, () => io.create(path, encoder.encode(`${JSON.stringify({ layout: MEDIA_DIRECTORY_LAYOUT, algorithm: "sha256" })}
|
|
36
|
+
`))).pipe(Effect.mapError(unavailable));
|
|
37
|
+
yield* call("link", marker, () => io.link(path, marker)).pipe(Effect.catch((error) => markerAt.pipe(Effect.catchIf((failure) => failure.reason === "Unavailable", () => Effect.fail(unavailable(error))), Effect.flatMap((exists) => exists ? Effect.void : Effect.fail(unavailable(error))))), Effect.ensuring(clean(path).pipe(Effect.interruptible)));
|
|
38
|
+
yield* sync(root);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
const blobAt = (hash) => read(join(blobs, `${hash}.bin`), MEDIA_LIMITS.imageBytes).pipe(Effect.flatMap((found) => Option.match(found, {
|
|
42
|
+
onNone: () => Effect.succeed(Option.none()),
|
|
43
|
+
onSome: (raw) => {
|
|
44
|
+
const bytes = new Uint8Array(raw);
|
|
45
|
+
return bytes.length > 0 && bytes.length <= MEDIA_LIMITS.imageBytes && sha256(bytes) === hash ? Effect.succeed(Option.some(bytes)) : Effect.fail(corrupt());
|
|
46
|
+
}
|
|
47
|
+
}))), checkHash = (hash) => Schema.decodeUnknownEffect(MediaHash)(hash).pipe(Effect.mapError((cause) => new MediaStoreError({ reason: "Invalid", message: "The media content key is invalid.", cause })));
|
|
48
|
+
return MediaStore.of({
|
|
49
|
+
get: Effect.fn("media_store.get")(function* (key) {
|
|
50
|
+
return yield* blobAt(yield* checkHash(key));
|
|
51
|
+
}),
|
|
52
|
+
put: Effect.fn("media_store.put")(function* (input) {
|
|
53
|
+
const decoded = Schema.decodeUnknownResult(MediaStoreBytes)(input);
|
|
54
|
+
if (Result.isFailure(decoded))
|
|
55
|
+
return yield* Effect.fail(new MediaStoreError({ reason: "Invalid", message: "The media bytes are outside the store's bounds.", cause: decoded.failure }));
|
|
56
|
+
const bytes = new Uint8Array(decoded.success), checked = Schema.decodeUnknownResult(MediaStoreBytes)(bytes);
|
|
57
|
+
if (Result.isFailure(checked))
|
|
58
|
+
return yield* Effect.fail(new MediaStoreError({ reason: "Invalid", message: "The media bytes are outside the store's bounds.", cause: checked.failure }));
|
|
59
|
+
const hash = MediaHash.make(sha256(bytes)), final = join(blobs, `${hash}.bin`), presence = blobAt(hash).pipe(Effect.map((found) => Option.match(found, { onNone: () => "absent", onSome: (stored) => equal(stored, bytes) ? "present" : "corrupt" })), Effect.catchIf((failure) => failure.reason === "Corrupt", () => Effect.succeed("corrupt"))), repair = (path) => Effect.gen(function* () {
|
|
60
|
+
const kept = join(quarantine, `${hash}.${yield* Clock.currentTimeMillis}.${randomUUID()}.bin`);
|
|
61
|
+
yield* call("link", kept, () => io.link(final, kept)).pipe(Effect.catchIf(missing, () => Effect.void), Effect.mapError(unavailable));
|
|
62
|
+
yield* call("rename", final, () => io.rename(path, final)).pipe(Effect.mapError(unavailable));
|
|
63
|
+
yield* Effect.logWarning("A damaged media blob was replaced by the bytes put again; the damaged copy is in quarantine.").pipe(Effect.annotateLogs({ "efferent.media.hash": hash }));
|
|
64
|
+
}), found = yield* presence;
|
|
65
|
+
if (found === "present")
|
|
66
|
+
return hash;
|
|
67
|
+
const path = yield* temporaryPath(hash, ".tmp");
|
|
68
|
+
yield* Effect.gen(function* () {
|
|
69
|
+
yield* call("create", path, () => io.create(path, bytes)).pipe(Effect.mapError(unavailable));
|
|
70
|
+
yield* (found === "corrupt" ? repair(path) : call("link", final, () => io.link(path, final)).pipe(Effect.catch((error) => presence.pipe(Effect.catchIf((failure) => failure.reason === "Unavailable", () => Effect.fail(unavailable(error))), Effect.flatMap((now) => now === "present" ? Effect.void : now === "corrupt" ? repair(path) : Effect.fail(unavailable(error))))))).pipe(Effect.ensuring(clean(path).pipe(Effect.interruptible)));
|
|
71
|
+
yield* sync(blobs);
|
|
72
|
+
});
|
|
73
|
+
return hash;
|
|
74
|
+
})
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
export const MediaStoreDirectoryLive = (root, options) => Layer.effect(MediaStore, makeDirectory(root, options));
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
/** Independent of the session log's store.json and FILES_LAYOUT. */
|
|
3
|
+
export declare const MEDIA_DIRECTORY_LAYOUT = 1;
|
|
4
|
+
export declare const MediaDirectoryMarker: Schema.Struct<{
|
|
5
|
+
readonly layout: Schema.Int;
|
|
6
|
+
readonly algorithm: Schema.Literal<"sha256">;
|
|
7
|
+
}>;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { SessionLog, TurnAdmission } from "@xandreed/core";
|
|
2
|
+
/**
|
|
3
|
+
* The session log in a directory, as a plugin: provides `SessionLog`.
|
|
4
|
+
*
|
|
5
|
+
* @public
|
|
6
|
+
*/
|
|
7
|
+
export declare const sessionLogFilesPlugin: import("@xandreed/core").TypedPlugin<{
|
|
8
|
+
readonly path: string;
|
|
9
|
+
readonly stallMs: number;
|
|
10
|
+
}, {
|
|
11
|
+
readonly path: string;
|
|
12
|
+
readonly stallMs: number;
|
|
13
|
+
}, SessionLog, import("@xandreed/core").SessionLogError, never>;
|
|
14
|
+
/**
|
|
15
|
+
* The session log in a directory and the open admission policy; compose
|
|
16
|
+
* `sessionsPlugin` beside it in the host's graph.
|
|
17
|
+
*
|
|
18
|
+
* @public
|
|
19
|
+
*/
|
|
20
|
+
export declare const sessionFilesPlugin: import("@xandreed/core").TypedPlugin<{
|
|
21
|
+
readonly path: string;
|
|
22
|
+
readonly stallMs: number;
|
|
23
|
+
}, {
|
|
24
|
+
readonly path: string;
|
|
25
|
+
readonly stallMs: number;
|
|
26
|
+
}, SessionLog | TurnAdmission, import("@xandreed/core").SessionLogError, never>;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { Layer } from "effect";
|
|
2
|
+
import { definePlugin, SessionLog, TurnAdmission, TurnAdmissionOpen } from "@xandreed/core";
|
|
3
|
+
import { SessionLogFilesLive } from "./session-log.adapter.js";
|
|
4
|
+
import { SessionFilesConfig, sessionFilesDefaults } from "./session-files.entity.js";
|
|
5
|
+
export const sessionLogFilesPlugin = definePlugin({
|
|
6
|
+
id: "@xandreed/plugin-session-files/log",
|
|
7
|
+
version: "0.15.1",
|
|
8
|
+
scope: "runtime",
|
|
9
|
+
config: SessionFilesConfig,
|
|
10
|
+
defaults: sessionFilesDefaults,
|
|
11
|
+
provides: [SessionLog],
|
|
12
|
+
layer: ({ path, stallMs }) => SessionLogFilesLive(path, { stallMs })
|
|
13
|
+
}), sessionFilesPlugin = definePlugin({
|
|
14
|
+
id: "@xandreed/plugin-session-files",
|
|
15
|
+
version: "0.15.1",
|
|
16
|
+
scope: "runtime",
|
|
17
|
+
config: SessionFilesConfig,
|
|
18
|
+
defaults: sessionFilesDefaults,
|
|
19
|
+
provides: [SessionLog, TurnAdmission],
|
|
20
|
+
layer: ({ path, stallMs }) => Layer.merge(SessionLogFilesLive(path, { stallMs }), TurnAdmissionOpen)
|
|
21
|
+
});
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
import { JsonObject } from "@xandreed/core";
|
|
3
|
+
/** The version of the directory's layout; a later one is refused (`store.version`). Shipped: change it only with a migration. */
|
|
4
|
+
export declare const FILES_LAYOUT = 1;
|
|
5
|
+
/**
|
|
6
|
+
* How long something stays in `staging/` before an open sweeps it as left
|
|
7
|
+
* by a create, an open or a link probe that stopped half way: an hour, and
|
|
8
|
+
* never less than 16 stall bounds (more than the longest staging takes).
|
|
9
|
+
*/
|
|
10
|
+
export declare const STAGING_SWEEP_MS = 3600000;
|
|
11
|
+
/** `store.json`: the layout the directory holds. */
|
|
12
|
+
export declare const StoreMarker: Schema.Struct<{
|
|
13
|
+
readonly layout: Schema.Int;
|
|
14
|
+
}>;
|
|
15
|
+
export type StoreMarker = typeof StoreMarker.Type;
|
|
16
|
+
/** A writer's mark on what it published: told apart from another writer's after an answer that may be wrong (a retransmitted link). */
|
|
17
|
+
export declare const PublishNonce: Schema.brand<Schema.String, "PublishNonce">;
|
|
18
|
+
export type PublishNonce = typeof PublishNonce.Type;
|
|
19
|
+
/** `header.json`: the header, the storage time it was created at, and its creator's nonce. */
|
|
20
|
+
export declare const HeaderRecord: Schema.Struct<{
|
|
21
|
+
readonly header: Schema.Struct<{
|
|
22
|
+
readonly id: Schema.brand<Schema.String, "ConversationId">;
|
|
23
|
+
readonly owner: Schema.Trimmed;
|
|
24
|
+
readonly origin: Schema.Trimmed;
|
|
25
|
+
readonly createdAt: Schema.Number;
|
|
26
|
+
readonly meta: Schema.$Record<Schema.String, Schema.Codec<Schema.Json, Schema.Json, never, never>>;
|
|
27
|
+
readonly parent: Schema.OptionFromNullOr<Schema.Struct<{
|
|
28
|
+
readonly id: Schema.brand<Schema.String, "ConversationId">;
|
|
29
|
+
readonly through: Schema.Int;
|
|
30
|
+
readonly turnAtFork: Schema.Int;
|
|
31
|
+
}>>;
|
|
32
|
+
}>;
|
|
33
|
+
readonly createdNow: Schema.Number;
|
|
34
|
+
readonly nonce: Schema.brand<Schema.String, "PublishNonce">;
|
|
35
|
+
}>;
|
|
36
|
+
export type HeaderRecord = typeof HeaderRecord.Type;
|
|
37
|
+
/** A commit file's first line: what it holds, read alone to skip a commit a read does not need. */
|
|
38
|
+
export declare const CommitMeta: Schema.Struct<{
|
|
39
|
+
readonly layout: Schema.Int;
|
|
40
|
+
readonly session: Schema.brand<Schema.String, "ConversationId">;
|
|
41
|
+
/** The revision this commit made: its file's name. */
|
|
42
|
+
readonly revision: Schema.Int;
|
|
43
|
+
/** The first event's sequence number, and the session's last after the commit. */
|
|
44
|
+
readonly first: Schema.Int;
|
|
45
|
+
readonly last: Schema.Int;
|
|
46
|
+
/** The storage time: every event's, and the head's `updatedAt`. */
|
|
47
|
+
readonly at: Schema.Number;
|
|
48
|
+
/** The kinds of its events, each once. */
|
|
49
|
+
readonly kinds: Schema.$Array<Schema.String>;
|
|
50
|
+
readonly nonce: Schema.brand<Schema.String, "PublishNonce">;
|
|
51
|
+
}>;
|
|
52
|
+
export type CommitMeta = typeof CommitMeta.Type;
|
|
53
|
+
/** A commit file's second line: the head's whole state after the commit (a commit without one carries the state before it). */
|
|
54
|
+
export declare const CommitState: Schema.Struct<{
|
|
55
|
+
readonly state: Schema.$Record<Schema.String, Schema.Codec<Schema.Json, Schema.Json, never, never>>;
|
|
56
|
+
}>;
|
|
57
|
+
export type CommitState = typeof CommitState.Type;
|
|
58
|
+
/** One event of a commit. */
|
|
59
|
+
export declare const CommitEvent: Schema.Struct<{
|
|
60
|
+
readonly seq: Schema.Int;
|
|
61
|
+
readonly turn: Schema.NullOr<Schema.Int>;
|
|
62
|
+
readonly kind: Schema.String;
|
|
63
|
+
readonly data: Schema.$Record<Schema.String, Schema.Codec<Schema.Json, Schema.Json, never, never>>;
|
|
64
|
+
}>;
|
|
65
|
+
export type CommitEvent = typeof CommitEvent.Type;
|
|
66
|
+
/** A commit file's last line: the bytes before it and their CRC-32, so a file cut short or changed is told apart from a whole one. */
|
|
67
|
+
export declare const CommitTrailer: Schema.Struct<{
|
|
68
|
+
readonly bytes: Schema.Int;
|
|
69
|
+
readonly crc32: Schema.String;
|
|
70
|
+
}>;
|
|
71
|
+
export type CommitTrailer = typeof CommitTrailer.Type;
|
|
72
|
+
/** A commit file, read whole and checked. */
|
|
73
|
+
export interface CommitRecord {
|
|
74
|
+
readonly meta: CommitMeta;
|
|
75
|
+
readonly state: JsonObject;
|
|
76
|
+
readonly events: ReadonlyArray<CommitEvent>;
|
|
77
|
+
}
|
|
78
|
+
/** Why a commit file is not a whole commit. */
|
|
79
|
+
export declare const CommitDamage: Schema.Literals<readonly ["short", "checksum", "decode"]>;
|
|
80
|
+
export type CommitDamage = typeof CommitDamage.Type;
|
|
81
|
+
/**
|
|
82
|
+
* What to do with a commit file that is not whole: at the session's tail it
|
|
83
|
+
* is a torn tail, set aside (`quarantine`), and the commit before it is the
|
|
84
|
+
* head; anywhere else the session is damaged (`store.decode`), and nothing
|
|
85
|
+
* is moved.
|
|
86
|
+
*/
|
|
87
|
+
export type TailVerdict = {
|
|
88
|
+
readonly _tag: "Intact";
|
|
89
|
+
} | {
|
|
90
|
+
readonly _tag: "Quarantine";
|
|
91
|
+
readonly damage: CommitDamage;
|
|
92
|
+
} | {
|
|
93
|
+
readonly _tag: "Corrupt";
|
|
94
|
+
readonly damage: CommitDamage;
|
|
95
|
+
};
|
|
96
|
+
/** How the store answers while its directory stalls: every call fails at once until one goes through again. */
|
|
97
|
+
export type StoreHealth = {
|
|
98
|
+
readonly _tag: "Healthy";
|
|
99
|
+
} | {
|
|
100
|
+
readonly _tag: "Stalled";
|
|
101
|
+
readonly since: number;
|
|
102
|
+
readonly probing: boolean;
|
|
103
|
+
};
|
|
104
|
+
/**
|
|
105
|
+
* The file store's options: the directory, and how long one file system
|
|
106
|
+
* call may take before the store fails it with `store.stalled` (a hung
|
|
107
|
+
* network mount never answers; the store then fails every call at once
|
|
108
|
+
* until one, let through a `stallMs` later, answers again).
|
|
109
|
+
*
|
|
110
|
+
* @public
|
|
111
|
+
*/
|
|
112
|
+
export declare const SessionFilesConfig: Schema.Struct<{
|
|
113
|
+
readonly path: Schema.String;
|
|
114
|
+
readonly stallMs: Schema.Int;
|
|
115
|
+
}>;
|
|
116
|
+
export type SessionFilesConfig = typeof SessionFilesConfig.Type;
|
|
117
|
+
/** @public */
|
|
118
|
+
export declare const sessionFilesDefaults: SessionFilesConfig;
|
|
119
|
+
declare const FileStoreIo_base: Schema.Class<FileStoreIo, Schema.TaggedStruct<"FileStoreIo", {
|
|
120
|
+
readonly op: Schema.String;
|
|
121
|
+
readonly path: Schema.String;
|
|
122
|
+
readonly cause: Schema.Defect;
|
|
123
|
+
}>, import("effect/Cause").YieldableError>;
|
|
124
|
+
/** A file system call failed: its operation, its path, and the file system's own error. */
|
|
125
|
+
export declare class FileStoreIo extends FileStoreIo_base {
|
|
126
|
+
}
|
|
127
|
+
declare const FileStoreStalled_base: Schema.Class<FileStoreStalled, Schema.TaggedStruct<"FileStoreStalled", {
|
|
128
|
+
readonly op: Schema.String;
|
|
129
|
+
readonly path: Schema.String;
|
|
130
|
+
readonly ms: Schema.Int;
|
|
131
|
+
}>, import("effect/Cause").YieldableError>;
|
|
132
|
+
/** A file system call did not answer within `stallMs`. */
|
|
133
|
+
export declare class FileStoreStalled extends FileStoreStalled_base {
|
|
134
|
+
}
|
|
135
|
+
export {};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { Option, Order, Result, Schema } from "effect";
|
|
2
|
+
import type { SessionHead, SessionListQuery } from "@xandreed/core";
|
|
3
|
+
import { CommitMeta, HeaderRecord, StoreMarker } from "./session-files.entity.js";
|
|
4
|
+
import type { CommitDamage, CommitRecord, StoreHealth, TailVerdict } from "./session-files.entity.js";
|
|
5
|
+
/** The CRC-32 of `bytes`, as eight lowercase hex digits. */
|
|
6
|
+
export declare const crc32Of: (bytes: Uint8Array) => string;
|
|
7
|
+
/** A commit file's name: its revision, padded so names sort as numbers do. */
|
|
8
|
+
export declare const revisionFile: (revision: number) => string;
|
|
9
|
+
/** The revision a commit file's name holds; none for any other name (a temporary file, a stray one). */
|
|
10
|
+
export declare const revisionOfFile: (name: string) => Option.Option<number>;
|
|
11
|
+
/** The revision a temporary commit file was written for (`<revision>.jsonl.<nonce>`); none for any other name. */
|
|
12
|
+
export declare const revisionOfTemporary: (name: string) => Option.Option<number>;
|
|
13
|
+
/** Whether a name under `sessions/` is a session's directory: its id. Anything else (an editor's file, a network file system's `.nfs…`) is not read. */
|
|
14
|
+
export declare const isSessionDirectory: (name: string) => boolean;
|
|
15
|
+
/** A name in `staging/` for what `prefix` names, staged at storage time `now` (`<prefix>.<now>.<unique>`), so a later open can tell its age. */
|
|
16
|
+
export declare const stagingName: (prefix: string, now: number, unique: string) => string;
|
|
17
|
+
/** The storage time a name in `staging/` was staged at; none for a name that does not carry one (an earlier release's). */
|
|
18
|
+
export declare const stagedAtOf: (name: string) => Option.Option<number>;
|
|
19
|
+
/**
|
|
20
|
+
* Whether a name in `staging/` was left by a create, an open or a probe that
|
|
21
|
+
* stopped half way: it was staged more than `ageMs` before `now`. A name
|
|
22
|
+
* without its time is never judged abandoned.
|
|
23
|
+
*/
|
|
24
|
+
export declare const isAbandonedStaging: (name: string, now: number, ageMs: number) => boolean;
|
|
25
|
+
/** Whether two files hold the same bytes. */
|
|
26
|
+
export declare const sameBytes: (left: Uint8Array, right: Uint8Array) => boolean;
|
|
27
|
+
/**
|
|
28
|
+
* A handle's cache bounds: how many sessions it remembers, and how many
|
|
29
|
+
* commit first lines (`CommitMeta`) it keeps across them.
|
|
30
|
+
*/
|
|
31
|
+
export declare const CACHE_BOUNDS: {
|
|
32
|
+
readonly sessions: 1024;
|
|
33
|
+
readonly metas: 65536;
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* The cache within its bounds: entries in order of use (the least recently
|
|
37
|
+
* used first), the oldest dropped past `bounds.sessions`; then the oldest
|
|
38
|
+
* entries' metas dropped (`shrink(entry, kept)`, `size` counting them) until
|
|
39
|
+
* at most `bounds.metas` are kept, the newest entry keeping its highest
|
|
40
|
+
* revisions when it alone is over.
|
|
41
|
+
*/
|
|
42
|
+
export declare const trimCache: <K, V>(entries: ReadonlyMap<K, V>, size: (entry: V) => number, shrink: (entry: V, kept: number) => V, bounds: {
|
|
43
|
+
readonly sessions: number;
|
|
44
|
+
readonly metas: number;
|
|
45
|
+
}) => ReadonlyMap<K, V>;
|
|
46
|
+
/** The lines of a commit file, as its bytes: meta, state, one line per event, then the trailer over the bytes before it. */
|
|
47
|
+
export declare const encodeCommit: (record: CommitRecord) => Uint8Array;
|
|
48
|
+
/**
|
|
49
|
+
* A commit file's bytes as the commit: whole (its trailer's length and
|
|
50
|
+
* CRC-32 match the bytes before it) and decoded line by line, or what is
|
|
51
|
+
* wrong with it: cut short (no trailer, or one that counts other bytes),
|
|
52
|
+
* changed (the checksum), or lines that do not decode.
|
|
53
|
+
*/
|
|
54
|
+
export declare const decodeCommit: (bytes: Uint8Array) => Result.Result<CommitRecord, CommitDamage>;
|
|
55
|
+
/** A commit file's first line, read alone (a read that skips the commit needs nothing else); none when it is not one. */
|
|
56
|
+
export declare const metaOfLine: (line: string) => Option.Option<CommitMeta>;
|
|
57
|
+
/** What to do with a commit file that is not whole: a tail is set aside; anything before the tail is damage the store does not repair. */
|
|
58
|
+
export declare const tailVerdict: (decoded: Result.Result<CommitRecord, CommitDamage>, isTail: boolean) => TailVerdict;
|
|
59
|
+
/** `header.json` as text. */
|
|
60
|
+
export declare const headerText: (record: HeaderRecord) => Result.Result<string, Schema.SchemaError>;
|
|
61
|
+
/** `header.json`'s text as its record. */
|
|
62
|
+
export declare const headerOfText: (text: string) => Result.Result<HeaderRecord, Schema.SchemaError>;
|
|
63
|
+
/** `store.json`'s text as its marker. */
|
|
64
|
+
export declare const markerOfText: (text: string) => Result.Result<StoreMarker, Schema.SchemaError>;
|
|
65
|
+
/** Most recently updated first, then by id, descending: the listing's order. */
|
|
66
|
+
export declare const newestFirst: Order.Order<SessionHead>;
|
|
67
|
+
/** Whether a session's header puts it in a listing: the owner's, top-level or the named parent's child. */
|
|
68
|
+
export declare const inListing: (record: HeaderRecord, query: SessionListQuery) => boolean;
|
|
69
|
+
/** The heads strictly older than the listing's cursor, in order, at most its limit. */
|
|
70
|
+
export declare const page: (heads: ReadonlyArray<SessionHead>, query: SessionListQuery) => ReadonlyArray<SessionHead>;
|
|
71
|
+
/** Whether a call goes through: always while healthy; while stalled, one call at a time once `stallMs` passed since the stall (the probe). */
|
|
72
|
+
export declare const admitCall: (health: StoreHealth, now: number, stallMs: number) => readonly [boolean, StoreHealth];
|
|
73
|
+
/** A call's outcome on the store's health: one that answered heals it; one that stalled marks it stalled from now. */
|
|
74
|
+
export declare const afterCall: (health: StoreHealth, answered: boolean, now: number) => StoreHealth;
|
|
75
|
+
/** Cancellation abandons a probe without turning an answered healthy mount into a stalled one. */
|
|
76
|
+
export declare const abandonCall: (health: StoreHealth, claimed: StoreHealth) => StoreHealth;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { Option, Order, Result, Schema } from "effect";
|
|
2
|
+
import { CommitEvent, CommitMeta, CommitState, CommitTrailer, HeaderRecord, StoreMarker } from "./session-files.entity.js";
|
|
3
|
+
const CRC_TABLE = Array.from({ length: 256 }, (_, n) => Array.from({ length: 8 }).reduce((c) => (c & 1) === 1 ? 3988292384 ^ c >>> 1 : c >>> 1, n));
|
|
4
|
+
export const crc32Of = (bytes) => ((bytes.reduce((crc, byte) => (CRC_TABLE[(crc ^ byte) & 255] ?? 0) ^ crc >>> 8, 4294967295) ^ 4294967295) >>> 0).toString(16).padStart(8, "0");
|
|
5
|
+
const REVISION_DIGITS = 12;
|
|
6
|
+
export const revisionFile = (revision) => `${String(revision).padStart(REVISION_DIGITS, "0")}.jsonl`, revisionOfFile = (name) => {
|
|
7
|
+
const match = /^(\d{12})\.jsonl$/.exec(name);
|
|
8
|
+
return match === null ? Option.none() : Option.some(Number(match[1]));
|
|
9
|
+
}, revisionOfTemporary = (name) => {
|
|
10
|
+
const match = /^(\d{12})\.jsonl\./.exec(name);
|
|
11
|
+
return match === null ? Option.none() : Option.some(Number(match[1]));
|
|
12
|
+
};
|
|
13
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
14
|
+
export const isSessionDirectory = (name) => UUID.test(name), stagingName = (prefix, now, unique) => `${prefix}.${now}.${unique}`, stagedAtOf = (name) => {
|
|
15
|
+
const match = /\.(\d+)\.[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(?:\.linked)?$/i.exec(name);
|
|
16
|
+
return match === null ? Option.none() : Option.some(Number(match[1]));
|
|
17
|
+
}, isAbandonedStaging = (name, now, ageMs) => Option.exists(stagedAtOf(name), (at) => now - at > ageMs), sameBytes = (left, right) => left.length === right.length && left.every((byte, index) => byte === right[index]), CACHE_BOUNDS = { sessions: 1024, metas: 65536 }, trimCache = (entries, size, shrink, bounds) => {
|
|
18
|
+
const kept = [...entries].slice(Math.max(0, entries.size - bounds.sessions)), total = kept.reduce((sum, [, entry]) => sum + size(entry), 0);
|
|
19
|
+
return new Map(kept.reduce((acc, [key, entry]) => {
|
|
20
|
+
const own = size(entry), drop = Math.min(own, acc.over);
|
|
21
|
+
acc.out.push([key, drop === 0 ? entry : shrink(entry, own - drop)]);
|
|
22
|
+
return { over: acc.over - drop, out: acc.out };
|
|
23
|
+
}, { over: Math.max(0, total - bounds.metas), out: [] }).out);
|
|
24
|
+
};
|
|
25
|
+
const encoder = new TextEncoder, decoder = new TextDecoder;
|
|
26
|
+
export const encodeCommit = (record) => {
|
|
27
|
+
const body = encoder.encode([
|
|
28
|
+
JSON.stringify(record.meta),
|
|
29
|
+
JSON.stringify({ state: record.state }),
|
|
30
|
+
...record.events.map((event) => JSON.stringify(event))
|
|
31
|
+
].map((line) => `${line}
|
|
32
|
+
`).join("")), trailer = encoder.encode(`${JSON.stringify({ bytes: body.length, crc32: crc32Of(body) })}
|
|
33
|
+
`), file = new Uint8Array(body.length + trailer.length);
|
|
34
|
+
file.set(body);
|
|
35
|
+
file.set(trailer, body.length);
|
|
36
|
+
return file;
|
|
37
|
+
};
|
|
38
|
+
const decodeMetaLine = Schema.decodeUnknownResult(Schema.fromJsonString(CommitMeta)), decodeStateLine = Schema.decodeUnknownResult(Schema.fromJsonString(CommitState)), decodeEventLine = Schema.decodeUnknownResult(Schema.fromJsonString(CommitEvent)), decodeTrailerLine = Schema.decodeUnknownResult(Schema.fromJsonString(CommitTrailer));
|
|
39
|
+
export const decodeCommit = (bytes) => {
|
|
40
|
+
const end = bytes.length > 0 && bytes[bytes.length - 1] === 10 ? bytes.length - 1 : -1;
|
|
41
|
+
if (end < 0)
|
|
42
|
+
return Result.fail("short");
|
|
43
|
+
const trailerStart = bytes.lastIndexOf(10, end - 1) + 1, trailer = decodeTrailerLine(decoder.decode(bytes.subarray(trailerStart, end)));
|
|
44
|
+
if (Result.isFailure(trailer) || trailer.success.bytes !== trailerStart)
|
|
45
|
+
return Result.fail("short");
|
|
46
|
+
const body = bytes.subarray(0, trailerStart);
|
|
47
|
+
if (crc32Of(body) !== trailer.success.crc32)
|
|
48
|
+
return Result.fail("checksum");
|
|
49
|
+
const [metaLine = "", stateLine = "", ...eventLines] = decoder.decode(body).split(`
|
|
50
|
+
`).slice(0, -1), meta = decodeMetaLine(metaLine), state = decodeStateLine(stateLine), events = eventLines.map((line) => decodeEventLine(line));
|
|
51
|
+
if (Result.isFailure(meta) || Result.isFailure(state) || events.some(Result.isFailure))
|
|
52
|
+
return Result.fail("decode");
|
|
53
|
+
return Result.succeed({ meta: meta.success, state: state.success.state, events: events.flatMap((event) => Result.isSuccess(event) ? [event.success] : []) });
|
|
54
|
+
}, metaOfLine = (line) => Result.getSuccess(decodeMetaLine(line)), tailVerdict = (decoded, isTail) => Result.match(decoded, {
|
|
55
|
+
onSuccess: () => ({ _tag: "Intact" }),
|
|
56
|
+
onFailure: (damage) => isTail ? { _tag: "Quarantine", damage } : { _tag: "Corrupt", damage }
|
|
57
|
+
});
|
|
58
|
+
const encodeHeader = Schema.encodeUnknownResult(Schema.fromJsonString(HeaderRecord)), decodeHeaderText = Schema.decodeUnknownResult(Schema.fromJsonString(HeaderRecord)), decodeMarkerText = Schema.decodeUnknownResult(Schema.fromJsonString(StoreMarker));
|
|
59
|
+
export const headerText = (record) => encodeHeader(record), headerOfText = (text) => decodeHeaderText(text), markerOfText = (text) => decodeMarkerText(text), newestFirst = Order.combine(Order.flip(Order.mapInput(Order.Number, (head) => head.updatedAt)), Order.flip(Order.mapInput(Order.String, (head) => head.header.id))), inListing = (record, query) => record.header.owner === query.owner && Option.match(query.parent, {
|
|
60
|
+
onNone: () => Option.isNone(record.header.parent),
|
|
61
|
+
onSome: (parent) => Option.exists(record.header.parent, (lineage) => lineage.id === parent)
|
|
62
|
+
}), page = (heads, query) => [...heads].sort(newestFirst).filter((head) => Option.match(query.before, {
|
|
63
|
+
onNone: () => !0,
|
|
64
|
+
onSome: (cursor) => head.updatedAt < cursor.updatedAt || head.updatedAt === cursor.updatedAt && head.header.id < cursor.id
|
|
65
|
+
})).slice(0, Math.max(0, query.limit)), admitCall = (health, now, stallMs) => health._tag === "Healthy" ? [!0, health] : !health.probing && now - health.since >= stallMs ? [!0, { ...health, probing: !0 }] : [!1, health], afterCall = (health, answered, now) => answered ? { _tag: "Healthy" } : { _tag: "Stalled", since: now, probing: !1 }, abandonCall = (health, claimed) => health !== claimed || health._tag === "Healthy" ? health : { ...health, probing: !1 };
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
import { ConversationId, JsonObject, SessionHeader } from "@xandreed/core";
|
|
3
|
+
export const FILES_LAYOUT = 1, STAGING_SWEEP_MS = 3600000, StoreMarker = Schema.Struct({ layout: Schema.Int }), PublishNonce = Schema.String.pipe(Schema.brand("PublishNonce")), HeaderRecord = Schema.Struct({
|
|
4
|
+
header: SessionHeader,
|
|
5
|
+
createdNow: Schema.Number,
|
|
6
|
+
nonce: PublishNonce
|
|
7
|
+
}), CommitMeta = Schema.Struct({
|
|
8
|
+
layout: Schema.Int,
|
|
9
|
+
session: ConversationId,
|
|
10
|
+
revision: Schema.Int,
|
|
11
|
+
first: Schema.Int,
|
|
12
|
+
last: Schema.Int,
|
|
13
|
+
at: Schema.Number,
|
|
14
|
+
kinds: Schema.Array(Schema.String),
|
|
15
|
+
nonce: PublishNonce
|
|
16
|
+
}), CommitState = Schema.Struct({ state: JsonObject }), CommitEvent = Schema.Struct({
|
|
17
|
+
seq: Schema.Int,
|
|
18
|
+
turn: Schema.NullOr(Schema.Int),
|
|
19
|
+
kind: Schema.String,
|
|
20
|
+
data: JsonObject
|
|
21
|
+
}), CommitTrailer = Schema.Struct({ bytes: Schema.Int, crc32: Schema.String }), CommitDamage = Schema.Literals(["short", "checksum", "decode"]), SessionFilesConfig = Schema.Struct({
|
|
22
|
+
path: Schema.String,
|
|
23
|
+
stallMs: Schema.Int.check(Schema.isGreaterThanOrEqualTo(100))
|
|
24
|
+
}), sessionFilesDefaults = { path: ".efferent/runtime/sessions", stallMs: 1e4 };
|
|
25
|
+
|
|
26
|
+
export class FileStoreIo extends Schema.TaggedError()("FileStoreIo", {
|
|
27
|
+
op: Schema.String,
|
|
28
|
+
path: Schema.String,
|
|
29
|
+
cause: Schema.Defect()
|
|
30
|
+
}) {
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class FileStoreStalled extends Schema.TaggedError()("FileStoreStalled", {
|
|
34
|
+
op: Schema.String,
|
|
35
|
+
path: Schema.String,
|
|
36
|
+
ms: Schema.Int
|
|
37
|
+
}) {
|
|
38
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { Effect, Layer } from "effect";
|
|
2
|
+
import type { Scope } from "effect";
|
|
3
|
+
import { ConversationId, LeaseExpired, RevisionConflict, SessionExists, SessionLog, SessionLogError, SessionMissing } from "@xandreed/core";
|
|
4
|
+
import type { SessionCommit, SessionCommitted, SessionHead, SessionHeader, SessionListQuery, SessionLogEvent, SessionReadQuery } from "@xandreed/core";
|
|
5
|
+
import type { FilesIo } from "./files-io.adapter.js";
|
|
6
|
+
/**
|
|
7
|
+
* The store's options beyond its directory: the stall bound, and the file system calls (a test's seam).
|
|
8
|
+
*
|
|
9
|
+
* @public
|
|
10
|
+
*/
|
|
11
|
+
export interface SessionLogFilesOptions {
|
|
12
|
+
readonly stallMs?: number;
|
|
13
|
+
readonly io?: FilesIo;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* The session log as files in a directory, local or on a network mount
|
|
17
|
+
* (NFS), on Node 24 and Bun: one immutable file per commit, written to a
|
|
18
|
+
* temporary file, flushed, then published by an exclusive hard link to its
|
|
19
|
+
* revision's name. Exactly one writer wins each revision, so a commit is a
|
|
20
|
+
* compare-and-swap without a lock: no handle waits on another, and a handle
|
|
21
|
+
* whose process stopped blocks nobody. A publish whose answer may be wrong
|
|
22
|
+
* (a network file system's retransmitted link) is settled by reading the
|
|
23
|
+
* published file's nonce. A commit's `notAfter` is checked again once its
|
|
24
|
+
* file is written, right before the link. Creating a session publishes its
|
|
25
|
+
* directory by a rename; removing one renames it away first, never emptying
|
|
26
|
+
* it in place, then removes the children that name it as their parent.
|
|
27
|
+
*
|
|
28
|
+
* A commit file holds its trailer's length and CRC-32: one at a session's
|
|
29
|
+
* tail that is not whole (the disk dropped what was flushed) is moved to
|
|
30
|
+
* `quarantine/` and the commit before it is the head, a warning logged; one
|
|
31
|
+
* before the tail fails with `store.decode`, nothing moved. A move that took
|
|
32
|
+
* another writer's whole commit (published after another handle set the
|
|
33
|
+
* torn one aside) puts it back. A directory whose layout a newer release
|
|
34
|
+
* wrote fails to open with `store.version`; one without hard links, with
|
|
35
|
+
* `store.open`. An open sweeps what stopped creates, opens and removals left
|
|
36
|
+
* in `staging/` and `trash/`.
|
|
37
|
+
*
|
|
38
|
+
* At most two file system calls are in flight per handle, and each must
|
|
39
|
+
* answer within `stallMs` (default 10 s), or the call fails with
|
|
40
|
+
* `store.stalled`; then every call fails at once, until one let through a
|
|
41
|
+
* `stallMs` later answers again. A call that did not answer keeps its slot
|
|
42
|
+
* until it does, so a hung mount holds two of the runtime's worker threads,
|
|
43
|
+
* not all of them, and healthy calls queue for what is left. The time is
|
|
44
|
+
* the caller's Effect `Clock`: commits and heads are stamped with it, so
|
|
45
|
+
* instances sharing the directory need clocks in step. A handle caches at
|
|
46
|
+
* most `CACHE_BOUNDS` (sessions, commit first lines). Each call is a
|
|
47
|
+
* `session_log.<op>` span, with `efferent.session.id` when it names one.
|
|
48
|
+
*
|
|
49
|
+
* @public
|
|
50
|
+
*/
|
|
51
|
+
export declare const makeSessionLogFiles: (root: string, options?: SessionLogFilesOptions | undefined) => Effect.Effect<{
|
|
52
|
+
readonly create: (header: SessionHeader) => Effect.Effect<SessionHead, SessionExists | SessionMissing | SessionLogError>;
|
|
53
|
+
readonly head: (id: ConversationId) => Effect.Effect<SessionHead, SessionMissing | SessionLogError>;
|
|
54
|
+
readonly list: (query: SessionListQuery) => Effect.Effect<ReadonlyArray<SessionHead>, SessionLogError>;
|
|
55
|
+
readonly read: (id: ConversationId, query: SessionReadQuery) => Effect.Effect<ReadonlyArray<SessionLogEvent>, SessionMissing | SessionLogError>;
|
|
56
|
+
readonly commit: (id: ConversationId, commit: SessionCommit) => Effect.Effect<SessionCommitted, RevisionConflict | LeaseExpired | SessionMissing | SessionLogError>;
|
|
57
|
+
readonly remove: (id: ConversationId) => Effect.Effect<void, SessionLogError>;
|
|
58
|
+
}, SessionLogError, Scope.Scope>;
|
|
59
|
+
/** The session log in a directory (see `makeSessionLogFiles`). @public */
|
|
60
|
+
export declare const SessionLogFilesLive: (root: string, options?: SessionLogFilesOptions) => Layer.Layer<SessionLog, SessionLogError>;
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { Cause, Clock, Effect, Layer, Match, Option, Ref, Result, Schema } from "effect";
|
|
4
|
+
import {
|
|
5
|
+
ConversationId,
|
|
6
|
+
errorCodeOf,
|
|
7
|
+
LeaseExpired,
|
|
8
|
+
reasonOf,
|
|
9
|
+
RevisionConflict,
|
|
10
|
+
SessionExists,
|
|
11
|
+
SessionIncarnation,
|
|
12
|
+
SessionLog,
|
|
13
|
+
SessionLogError,
|
|
14
|
+
SessionMissing,
|
|
15
|
+
storedJson
|
|
16
|
+
} from "@xandreed/core";
|
|
17
|
+
import { nodeFilesIo } from "./files-io.adapter.js";
|
|
18
|
+
import { makeBoundedFilesCall } from "./bounded-call.adapter.js";
|
|
19
|
+
import { FILES_LAYOUT, PublishNonce, sessionFilesDefaults, STAGING_SWEEP_MS } from "./session-files.entity.js";
|
|
20
|
+
import {
|
|
21
|
+
CACHE_BOUNDS,
|
|
22
|
+
decodeCommit,
|
|
23
|
+
encodeCommit,
|
|
24
|
+
headerOfText,
|
|
25
|
+
headerText,
|
|
26
|
+
inListing,
|
|
27
|
+
isAbandonedStaging,
|
|
28
|
+
isSessionDirectory,
|
|
29
|
+
markerOfText,
|
|
30
|
+
metaOfLine,
|
|
31
|
+
page,
|
|
32
|
+
revisionFile,
|
|
33
|
+
revisionOfFile,
|
|
34
|
+
revisionOfTemporary,
|
|
35
|
+
sameBytes,
|
|
36
|
+
stagingName,
|
|
37
|
+
tailVerdict,
|
|
38
|
+
trimCache
|
|
39
|
+
} from "./session-files.entity.functions.js";
|
|
40
|
+
const META_PREFIX = 65536, sessionIdOf = (name) => isSessionDirectory(name) ? Schema.decodeUnknownOption(ConversationId)(name) : Option.none(), encoder = new TextEncoder, decoder = new TextDecoder, isCode = (error, ...codes) => error._tag === "FileStoreIo" && Option.exists(errorCodeOf(error.cause), (code) => codes.includes(code)), vanished = (error) => error._tag === "SessionLogError" && Option.exists(errorCodeOf(error.cause), (code) => code === "ENOENT" || code === "ENOTDIR"), onSession = (effect, id) => Effect.annotateCurrentSpan("efferent.session.id", id).pipe(Effect.andThen(effect)), storeError = (code, what) => (error) => Match.valueTags(error, {
|
|
41
|
+
FileStoreStalled: (stalled) => new SessionLogError({
|
|
42
|
+
code: "store.stalled",
|
|
43
|
+
message: `the session directory did not answer ${stalled.op} of ${stalled.path} within ${stalled.ms} ms; a write it held may still land`
|
|
44
|
+
}),
|
|
45
|
+
FileStoreIo: (failed) => new SessionLogError({ code, message: `the session directory failed to ${what}: ${reasonOf(Cause.die(failed.cause))}`, cause: failed.cause })
|
|
46
|
+
}), decodeError = (what, detail) => new SessionLogError({ code: "store.decode", message: `${what} does not decode: ${detail}` }), incarnationOf = (header) => SessionIncarnation.make(header.nonce), cacheWith = (all, id, entry) => trimCache(new Map([...all].filter(([key]) => key !== id)).set(id, entry), (known) => known.metas.size, (known, kept) => ({
|
|
47
|
+
...known,
|
|
48
|
+
metas: new Map([...known.metas].toSorted(([left], [right]) => left - right).slice(known.metas.size - kept))
|
|
49
|
+
}), CACHE_BOUNDS);
|
|
50
|
+
export const makeSessionLogFiles = Effect.fnUntraced(function* (root, options) {
|
|
51
|
+
const io = options?.io ?? nodeFilesIo, stallMs = options?.stallMs ?? sessionFilesDefaults.stallMs, known = yield* Ref.make(new Map), swept = yield* Ref.make(new Set), sessionsDir = join(root, "sessions"), stagingDir = join(root, "staging"), dirOf = (id) => join(sessionsDir, id), headerPath = (id) => join(dirOf(id), "header.json"), commitsDir = (id) => join(dirOf(id), "commits"), commitPath = (id, revision) => join(commitsDir(id), revisionFile(revision)), nonce = () => PublishNonce.make(randomUUID()), staged = (prefix, now) => join(stagingDir, stagingName(prefix, now, randomUUID())), call = yield* makeBoundedFilesCall(stallMs), bestEffort = (op, path, run) => call(op, path, run).pipe(Effect.ignore({ log: "Debug", message: `the session directory could not ${op} ${path}; it is left as it is` })), openedAt = yield* Clock.currentTimeMillis;
|
|
52
|
+
yield* Effect.forEach([root, sessionsDir, stagingDir, join(root, "trash"), join(root, "quarantine")], (path) => call("mkdir", path, () => io.mkdir(path)), { concurrency: 1, discard: !0 }).pipe(Effect.mapError(storeError("store.open", "create its folders")));
|
|
53
|
+
const probe = staged("link-probe", openedAt);
|
|
54
|
+
yield* call("create", probe, () => io.create(probe, encoder.encode(`probe
|
|
55
|
+
`))).pipe(Effect.mapError(storeError("store.open", "write a file")));
|
|
56
|
+
yield* call("link", probe, () => io.link(probe, `${probe}.linked`)).pipe(Effect.mapError((error) => isCode(error, "EPERM", "EOPNOTSUPP", "ENOTSUP", "ENOSYS", "EXDEV") ? new SessionLogError({ code: "store.open", message: `the session directory ${root} does not support hard links, which this store needs to publish a commit exclusively` }) : storeError("store.open", "link a file")(error)));
|
|
57
|
+
yield* bestEffort("unlink", probe, () => io.unlink(probe));
|
|
58
|
+
yield* bestEffort("unlink", `${probe}.linked`, () => io.unlink(`${probe}.linked`));
|
|
59
|
+
const markerPath = join(root, "store.json"), readMarker = call("read", markerPath, () => io.read(markerPath)).pipe(Effect.mapError(storeError("store.open", "read store.json")), Effect.flatMap((bytes) => Result.match(markerOfText(decoder.decode(bytes)), {
|
|
60
|
+
onFailure: (error) => Effect.fail(decodeError("store.json", error.message)),
|
|
61
|
+
onSuccess: Effect.succeed
|
|
62
|
+
}))), markerTemp = staged("store.json", openedAt);
|
|
63
|
+
yield* call("create", markerTemp, () => io.create(markerTemp, encoder.encode(`${JSON.stringify({ layout: FILES_LAYOUT })}
|
|
64
|
+
`))).pipe(Effect.andThen(call("link", markerPath, () => io.link(markerTemp, markerPath)).pipe(Effect.catchIf((error) => isCode(error, "EEXIST"), () => Effect.void))), Effect.mapError(storeError("store.open", "write store.json")));
|
|
65
|
+
yield* bestEffort("unlink", markerTemp, () => io.unlink(markerTemp));
|
|
66
|
+
const marker = yield* readMarker;
|
|
67
|
+
if (marker.layout > FILES_LAYOUT)
|
|
68
|
+
return yield* Effect.fail(new SessionLogError({
|
|
69
|
+
code: "store.version",
|
|
70
|
+
message: `the session directory is at layout ${marker.layout}, written by a newer release; this release reads up to layout ${FILES_LAYOUT}`
|
|
71
|
+
}));
|
|
72
|
+
const leftIn = (folder) => call("list", join(root, folder), () => io.list(join(root, folder))).pipe(Effect.tapError((error) => Effect.logDebug(`the session directory's ${folder} could not be listed (${error._tag}); it is swept at the next open`)), Effect.orElseSucceed(() => [])), trashed = yield* leftIn("trash");
|
|
73
|
+
yield* Effect.forEach(trashed, (name) => bestEffort("remove", join(root, "trash", name), () => io.removeTree(join(root, "trash", name))), { concurrency: 1, discard: !0 });
|
|
74
|
+
const abandoned = (yield* leftIn("staging")).filter((name) => isAbandonedStaging(name, openedAt, Math.max(STAGING_SWEEP_MS, 16 * stallMs)));
|
|
75
|
+
yield* Effect.forEach(abandoned, (name) => bestEffort("remove", join(stagingDir, name), () => io.removeTree(join(stagingDir, name))), { concurrency: 1, discard: !0 });
|
|
76
|
+
const headerOf = (id) => call("read", headerPath(id), () => io.read(headerPath(id))).pipe(Effect.catchIf((error) => isCode(error, "ENOENT", "ENOTDIR"), () => Effect.fail(new SessionMissing({ session: id }))), Effect.mapError((error) => error._tag === "SessionMissing" ? error : storeError("store.io", "read a session's header")(error)), Effect.flatMap((bytes) => Result.match(headerOfText(decoder.decode(bytes)), {
|
|
77
|
+
onFailure: (error) => Effect.fail(decodeError(`the header of session ${id}`, error.message)),
|
|
78
|
+
onSuccess: Effect.succeed
|
|
79
|
+
}))), exists = (path) => call("stat", path, () => io.exists(path)).pipe(Effect.mapError(storeError("store.io", "look a file up"))), goneOr = (id, what) => (error) => isCode(error, "ENOENT", "ENOTDIR") ? exists(headerPath(id)).pipe(Effect.flatMap((there) => Effect.fail(there ? storeError("store.io", what)(error) : new SessionMissing({ session: id })))) : Effect.fail(storeError("store.io", what)(error)), probeForward = (id, from) => exists(commitPath(id, from + 1)).pipe(Effect.flatMap((found) => found ? probeForward(id, from + 1) : Effect.succeed(from))), listedRevision = (id) => call("list", commitsDir(id), () => io.list(commitsDir(id))).pipe(Effect.catch(goneOr(id, "list a session's commits")), Effect.map((names) => names.reduce((highest, name) => Option.match(revisionOfFile(name), { onNone: () => highest, onSome: (revision) => Math.max(highest, revision) }), 0))), commitBytes = (id, revision) => call("read", commitPath(id, revision), () => io.read(commitPath(id, revision))).pipe(Effect.catch(goneOr(id, "read a commit"))), wholeCommit = (id, revision) => commitBytes(id, revision).pipe(Effect.flatMap((bytes) => Result.match(decodeCommit(bytes), {
|
|
80
|
+
onFailure: (damage) => Effect.fail(decodeError(`commit ${revision} of session ${id}`, `${damage}; it is not the session's last, so it is not repaired`)),
|
|
81
|
+
onSuccess: Effect.succeed
|
|
82
|
+
}))), quarantine = (id, revision, torn, damage) => Effect.gen(function* () {
|
|
83
|
+
const folder = join(root, "quarantine", id);
|
|
84
|
+
yield* call("mkdir", folder, () => io.mkdir(folder)).pipe(Effect.mapError(storeError("store.io", "create the quarantine")));
|
|
85
|
+
const aside = join(folder, `${revisionFile(revision)}.${randomUUID()}`);
|
|
86
|
+
if (!(yield* call("rename", commitPath(id, revision), () => io.rename(commitPath(id, revision), aside)).pipe(Effect.as(!0), Effect.catchIf((error) => isCode(error, "ENOENT"), () => Effect.succeed(!1)), Effect.mapError(storeError("store.io", "set a torn commit aside")))))
|
|
87
|
+
return "aside";
|
|
88
|
+
const taken = yield* call("read", aside, () => io.read(aside)).pipe(Effect.mapError(storeError("store.io", "read a commit set aside"))), annotated = Effect.annotateLogs({ "efferent.session.id": id });
|
|
89
|
+
if (sameBytes(taken, torn)) {
|
|
90
|
+
yield* Effect.logWarning(`a torn commit at the tail of the session (revision ${revision}, ${damage}) was moved to ${aside}; the commit before it is the session's head`).pipe(annotated);
|
|
91
|
+
return "aside";
|
|
92
|
+
}
|
|
93
|
+
if (yield* call("link", commitPath(id, revision), () => io.link(aside, commitPath(id, revision))).pipe(Effect.as(!0), Effect.catchIf((error) => isCode(error, "EEXIST"), () => Effect.succeed(!1)), Effect.mapError(storeError("store.io", "put a commit back")))) {
|
|
94
|
+
yield* bestEffort("unlink", aside, () => io.unlink(aside));
|
|
95
|
+
return "restored";
|
|
96
|
+
}
|
|
97
|
+
yield* Effect.logError(`revision ${revision} of the session, published again after a torn one was set aside, was moved to ${aside} and could not be put back: a third writer took the revision; its bytes are kept there`).pipe(annotated);
|
|
98
|
+
return "restored";
|
|
99
|
+
}), tailOfRecord = (record) => ({ revision: record.meta.revision, seq: record.meta.last, at: record.meta.at, state: record.state }), tailAt = (id, header, revision, repaired) => {
|
|
100
|
+
if (revision === 0)
|
|
101
|
+
return Effect.succeed({ tail: { revision: 0, seq: 0, at: header.createdNow, state: {} }, meta: Option.none() });
|
|
102
|
+
return commitBytes(id, revision).pipe(Effect.flatMap((bytes) => {
|
|
103
|
+
const decoded = decodeCommit(bytes);
|
|
104
|
+
return Match.valueTags(tailVerdict(decoded, !repaired), {
|
|
105
|
+
Intact: () => Result.match(decoded, {
|
|
106
|
+
onFailure: () => Effect.fail(decodeError(`commit ${revision} of session ${id}`, "unreadable")),
|
|
107
|
+
onSuccess: (record) => Effect.succeed({ tail: tailOfRecord(record), meta: Option.some(record.meta) })
|
|
108
|
+
}),
|
|
109
|
+
Quarantine: ({ damage }) => quarantine(id, revision, bytes, damage).pipe(Effect.flatMap((outcome) => outcome === "aside" ? tailAt(id, header, revision - 1, !0) : tailAt(id, header, revision, repaired))),
|
|
110
|
+
Corrupt: ({ damage }) => Effect.fail(decodeError(`commit ${revision} of session ${id}`, `${damage}, below a torn tail`))
|
|
111
|
+
});
|
|
112
|
+
}));
|
|
113
|
+
}, remember = (id, headerNonce, tail, metas) => Ref.update(known, (all) => {
|
|
114
|
+
const before = Option.filter(Option.fromNullishOr(all.get(id)), (entry) => entry.nonce === headerNonce), kept = Option.match(before, { onNone: () => new Map, onSome: (entry) => new Map(entry.metas) });
|
|
115
|
+
Array.from(metas).forEach((meta) => kept.set(meta.revision, meta));
|
|
116
|
+
return cacheWith(all, id, { nonce: headerNonce, tail, metas: kept });
|
|
117
|
+
}), currentOnce = (id) => Effect.gen(function* () {
|
|
118
|
+
const header = yield* headerOf(id), before = Option.filter(Option.fromNullishOr((yield* Ref.get(known)).get(id)), (entry) => entry.nonce === header.nonce), start = yield* Option.match(before, {
|
|
119
|
+
onNone: () => listedRevision(id),
|
|
120
|
+
onSome: (entry) => entry.tail.revision === 0 ? Effect.succeed(0) : exists(commitPath(id, entry.tail.revision)).pipe(Effect.flatMap((still) => still ? Effect.succeed(entry.tail.revision) : listedRevision(id)))
|
|
121
|
+
}), revision = yield* probeForward(id, start), same = Option.filter(before, (entry) => entry.tail.revision === revision);
|
|
122
|
+
if (Option.isSome(same)) {
|
|
123
|
+
yield* Ref.update(known, (all) => cacheWith(all, id, same.value));
|
|
124
|
+
return { header, tail: same.value.tail };
|
|
125
|
+
}
|
|
126
|
+
const found = yield* tailAt(id, header, revision, !1);
|
|
127
|
+
yield* remember(id, header.nonce, found.tail, Option.toArray(found.meta));
|
|
128
|
+
return { header, tail: found.tail };
|
|
129
|
+
}), current = (id) => currentOnce(id).pipe(Effect.catchIf(vanished, () => currentOnce(id))), headOf = (header, tail, now) => ({
|
|
130
|
+
header: { ...header.header, meta: structuredClone(header.header.meta) },
|
|
131
|
+
incarnation: incarnationOf(header),
|
|
132
|
+
seq: tail.seq,
|
|
133
|
+
revision: tail.revision,
|
|
134
|
+
state: structuredClone(tail.state),
|
|
135
|
+
updatedAt: tail.at,
|
|
136
|
+
now
|
|
137
|
+
}), metaOfFile = (id, revision) => Effect.gen(function* () {
|
|
138
|
+
const start = yield* call("read", commitPath(id, revision), () => io.readStart(commitPath(id, revision), META_PREFIX)).pipe(Effect.catch(goneOr(id, "read a commit"))), text = decoder.decode(start), line = text.slice(0, Math.max(0, text.indexOf(`
|
|
139
|
+
`)));
|
|
140
|
+
return yield* Option.match(metaOfLine(line), {
|
|
141
|
+
onSome: Effect.succeed,
|
|
142
|
+
onNone: () => wholeCommit(id, revision).pipe(Effect.map((record) => record.meta))
|
|
143
|
+
});
|
|
144
|
+
}), eventOf = (id, at) => (event) => ({
|
|
145
|
+
session: id,
|
|
146
|
+
seq: event.seq,
|
|
147
|
+
turn: Option.fromNullishOr(event.turn),
|
|
148
|
+
kind: event.kind,
|
|
149
|
+
at,
|
|
150
|
+
data: event.data
|
|
151
|
+
}), publish = (id, temporary, revision, ours) => call("link", commitPath(id, revision), () => io.link(temporary, commitPath(id, revision))).pipe(Effect.as({ _tag: "Published" }), Effect.catch((linkError) => call("read", commitPath(id, revision), () => io.read(commitPath(id, revision))).pipe(Effect.map((bytes) => Result.match(decodeCommit(bytes), {
|
|
152
|
+
onSuccess: (record) => record.meta.nonce === ours ? { _tag: "Published" } : { _tag: "Taken" },
|
|
153
|
+
onFailure: () => ({ _tag: "Taken" })
|
|
154
|
+
})), Effect.catchIf((error) => isCode(error, "ENOENT", "ENOTDIR"), () => exists(dirOf(id)).pipe(Effect.flatMap((there) => there ? Effect.fail(storeError("store.io", "publish a commit")(linkError)) : Effect.succeed({ _tag: "Missing" })))), Effect.mapError((error) => error._tag === "SessionLogError" ? error : storeError("store.io", "read a commit back")(error))))), sweepTemporaries = (id, revision) => Effect.gen(function* () {
|
|
155
|
+
if (!(yield* Ref.modify(swept, (done) => done.has(id) ? [!1, done] : [!0, done.size >= CACHE_BOUNDS.sessions ? new Set([id]) : new Set(done).add(id)])))
|
|
156
|
+
return;
|
|
157
|
+
const folder = join(dirOf(id), "tmp"), dead = (yield* call("list", folder, () => io.list(folder)).pipe(Effect.tapError((error) => Effect.logDebug(`the temporary files of a session could not be listed (${error._tag}); they are swept at a later commit`)), Effect.orElseSucceed(() => []))).filter((name) => Option.exists(revisionOfTemporary(name), (written) => written <= revision));
|
|
158
|
+
yield* Effect.forEach(dead, (name) => bestEffort("unlink", join(folder, name), () => io.unlink(join(folder, name))), { concurrency: 1, discard: !0 });
|
|
159
|
+
}), discard = (id) => Effect.gen(function* () {
|
|
160
|
+
const aside = join(root, "trash", `${id}.${randomUUID()}`), moved = yield* call("rename", dirOf(id), () => io.rename(dirOf(id), aside)).pipe(Effect.as(!0), Effect.catchIf((error) => isCode(error, "ENOENT"), () => Effect.succeed(!1)), Effect.mapError(storeError("store.io", "remove a session")));
|
|
161
|
+
yield* Ref.update(known, (all) => {
|
|
162
|
+
const rest = new Map(all);
|
|
163
|
+
rest.delete(id);
|
|
164
|
+
return rest;
|
|
165
|
+
});
|
|
166
|
+
if (moved)
|
|
167
|
+
yield* bestEffort("remove", aside, () => io.removeTree(aside));
|
|
168
|
+
}), childrenOf = (parent) => Effect.gen(function* () {
|
|
169
|
+
const names = yield* call("list", sessionsDir, () => io.list(sessionsDir)).pipe(Effect.mapError(storeError("store.io", "list the sessions")));
|
|
170
|
+
return (yield* Effect.forEach(names.flatMap((name) => Option.toArray(sessionIdOf(name))), (name) => headerOf(name).pipe(Effect.map(Option.some), Effect.catchTag("SessionMissing", () => Effect.succeedNone)), { concurrency: 2 })).flatMap((found) => Option.match(found, {
|
|
171
|
+
onNone: () => [],
|
|
172
|
+
onSome: (record) => Option.exists(record.header.parent, (lineage) => lineage.id === parent) ? [record.header.id] : []
|
|
173
|
+
}));
|
|
174
|
+
}), removeTree = (id) => Effect.gen(function* () {
|
|
175
|
+
yield* discard(id);
|
|
176
|
+
const children = yield* childrenOf(id);
|
|
177
|
+
yield* Effect.forEach(children, removeTree, { concurrency: 1, discard: !0 });
|
|
178
|
+
});
|
|
179
|
+
return SessionLog.of({
|
|
180
|
+
create: Effect.fn("session_log.create")(function* (header) {
|
|
181
|
+
const meta = yield* storedJson(header.meta).pipe(Effect.mapError((error) => new SessionLogError({ code: "store.encode", message: `the meta is not JSON: ${error.message}`, cause: error }))), parent = header.parent;
|
|
182
|
+
if (Option.isSome(parent) && !(yield* exists(headerPath(parent.value.id))))
|
|
183
|
+
return yield* Effect.fail(new SessionMissing({ session: parent.value.id }));
|
|
184
|
+
const now = yield* Clock.currentTimeMillis, record = { header: { ...header, meta }, createdNow: now, nonce: nonce() }, text = yield* Result.match(headerText(record), {
|
|
185
|
+
onFailure: (error) => Effect.fail(new SessionLogError({ code: "store.encode", message: `the header does not encode: ${error.message}`, cause: error })),
|
|
186
|
+
onSuccess: Effect.succeed
|
|
187
|
+
}), stagedDir = staged(header.id, now);
|
|
188
|
+
yield* Effect.forEach([join(stagedDir, "commits"), join(stagedDir, "tmp")], (path) => call("mkdir", path, () => io.mkdir(path)), { concurrency: 1, discard: !0 }).pipe(Effect.andThen(call("create", join(stagedDir, "header.json"), () => io.create(join(stagedDir, "header.json"), encoder.encode(text)))), Effect.mapError(storeError("store.io", "stage a new session")));
|
|
189
|
+
if ((yield* call("rename", dirOf(header.id), () => io.rename(stagedDir, dirOf(header.id))).pipe(Effect.as("created"), Effect.catch((renameError) => headerOf(header.id).pipe(Effect.map((found) => found.nonce === record.nonce ? "created" : "exists"), Effect.catchTag("SessionMissing", () => Effect.fail(storeError("store.io", "publish a new session")(renameError))))))) === "exists") {
|
|
190
|
+
yield* bestEffort("remove", stagedDir, () => io.removeTree(stagedDir));
|
|
191
|
+
return yield* Effect.fail(new SessionExists({ session: header.id }));
|
|
192
|
+
}
|
|
193
|
+
if (Option.isSome(parent) && !(yield* exists(headerPath(parent.value.id)))) {
|
|
194
|
+
yield* discard(header.id);
|
|
195
|
+
return yield* Effect.fail(new SessionMissing({ session: parent.value.id }));
|
|
196
|
+
}
|
|
197
|
+
const tail = { revision: 0, seq: 0, at: now, state: {} };
|
|
198
|
+
yield* remember(header.id, record.nonce, tail, []);
|
|
199
|
+
return headOf(record, tail, now);
|
|
200
|
+
}, (effect, header) => onSession(effect, header.id)),
|
|
201
|
+
head: Effect.fn("session_log.head")(function* (id) {
|
|
202
|
+
const { header, tail } = yield* current(id);
|
|
203
|
+
return headOf(header, tail, yield* Clock.currentTimeMillis);
|
|
204
|
+
}, onSession),
|
|
205
|
+
list: Effect.fn("session_log.list")(function* (query) {
|
|
206
|
+
const now = yield* Clock.currentTimeMillis, names = yield* call("list", sessionsDir, () => io.list(sessionsDir)).pipe(Effect.mapError(storeError("store.io", "list the sessions"))), heads = yield* Effect.forEach(names.flatMap((name) => Option.toArray(sessionIdOf(name))), (name) => headerOf(name).pipe(Effect.flatMap((record) => inListing(record, query) ? current(record.header.id).pipe(Effect.map(({ header, tail }) => Option.some(headOf(header, tail, now)))) : Effect.succeedNone), Effect.catchTag("SessionMissing", () => Effect.succeedNone)), { concurrency: 2 });
|
|
207
|
+
return page(heads.flatMap(Option.toArray), query);
|
|
208
|
+
}),
|
|
209
|
+
read: Effect.fn("session_log.read")(function* (id, query) {
|
|
210
|
+
const { header, tail } = yield* current(id), limit = Option.match(query.limit, { onNone: () => Number.POSITIVE_INFINITY, onSome: (value) => Math.max(0, value) }), wanted = (kind) => query.kinds.length === 0 || query.kinds.includes(kind), revisions = Array.from({ length: tail.revision }, (_, index) => index + 1), cached = Option.match(Option.filter(Option.fromNullishOr((yield* Ref.get(known)).get(id)), (entry) => entry.nonce === header.nonce), {
|
|
211
|
+
onNone: () => new Map,
|
|
212
|
+
onSome: (entry) => entry.metas
|
|
213
|
+
}), fresh = new Map, metaAt = (revision) => Option.match(Option.fromNullishOr(cached.get(revision)), {
|
|
214
|
+
onSome: (meta) => Effect.succeed(meta),
|
|
215
|
+
onNone: () => metaOfFile(id, revision).pipe(Effect.tap((meta) => Effect.sync(() => fresh.set(revision, meta))))
|
|
216
|
+
}), events = yield* Effect.reduce(revisions, () => [], (events, revision) => {
|
|
217
|
+
if (events.length >= limit)
|
|
218
|
+
return Effect.succeed(events);
|
|
219
|
+
return metaAt(revision).pipe(Effect.flatMap((meta) => meta.last <= query.after || query.through !== void 0 && meta.first > query.through || meta.first > meta.last || !meta.kinds.some(wanted) ? Effect.succeed(events) : wholeCommit(id, revision).pipe(Effect.map((record) => [
|
|
220
|
+
...events,
|
|
221
|
+
...record.events.filter((event) => event.seq > query.after && (query.through === void 0 || event.seq <= query.through) && wanted(event.kind)).map(eventOf(id, record.meta.at))
|
|
222
|
+
].slice(0, limit)))));
|
|
223
|
+
});
|
|
224
|
+
if (fresh.size > 0)
|
|
225
|
+
yield* Ref.update(known, (all) => Option.match(Option.filter(Option.fromNullishOr(all.get(id)), (entry) => entry.nonce === header.nonce), {
|
|
226
|
+
onNone: () => all,
|
|
227
|
+
onSome: (entry) => cacheWith(all, id, { ...entry, metas: new Map([...entry.metas, ...fresh]) })
|
|
228
|
+
}));
|
|
229
|
+
return events;
|
|
230
|
+
}, onSession),
|
|
231
|
+
commit: Effect.fn("session_log.commit")(function* (id, commit) {
|
|
232
|
+
const data = yield* Effect.forEach(commit.events, (draft) => storedJson(draft.data).pipe(Effect.mapError((error) => new SessionLogError({ code: "store.encode", message: `the data is not JSON: ${error.message}`, cause: error }))), { concurrency: 1 }), { header, tail } = yield* current(id);
|
|
233
|
+
if (commit.incarnation !== void 0 && commit.incarnation !== incarnationOf(header))
|
|
234
|
+
return yield* Effect.fail(new SessionMissing({ session: id }));
|
|
235
|
+
if (tail.revision !== commit.expect)
|
|
236
|
+
return yield* Effect.fail(new RevisionConflict({ session: id, expected: commit.expect, actual: tail.revision }));
|
|
237
|
+
const at = yield* Clock.currentTimeMillis;
|
|
238
|
+
if (Option.isSome(commit.notAfter) && at > commit.notAfter.value)
|
|
239
|
+
return yield* Effect.fail(new LeaseExpired({ session: id, notAfter: commit.notAfter.value, now: at }));
|
|
240
|
+
const revision = tail.revision + 1, events = commit.events.map((draft, index) => ({
|
|
241
|
+
seq: tail.seq + index + 1,
|
|
242
|
+
turn: Option.getOrNull(draft.turn),
|
|
243
|
+
kind: draft.kind,
|
|
244
|
+
data: data[index] ?? {}
|
|
245
|
+
})), ours = nonce(), meta = {
|
|
246
|
+
layout: FILES_LAYOUT,
|
|
247
|
+
session: id,
|
|
248
|
+
revision,
|
|
249
|
+
first: tail.seq + 1,
|
|
250
|
+
last: tail.seq + events.length,
|
|
251
|
+
at,
|
|
252
|
+
kinds: [...new Set(events.map((event) => event.kind))],
|
|
253
|
+
nonce: ours
|
|
254
|
+
}, state = yield* Option.match(commit.state, {
|
|
255
|
+
onNone: () => Effect.succeed(tail.state),
|
|
256
|
+
onSome: (next) => storedJson(next).pipe(Effect.mapError((error) => new SessionLogError({ code: "store.encode", message: `the state is not JSON: ${error.message}`, cause: error })))
|
|
257
|
+
}), temporary = join(dirOf(id), "tmp", `${revisionFile(revision)}.${ours}`), published = yield* Effect.uninterruptible(Effect.gen(function* () {
|
|
258
|
+
if (!(yield* call("create", temporary, () => io.create(temporary, encodeCommit({ meta, state, events }))).pipe(Effect.as(!0), Effect.catchIf((error) => isCode(error, "ENOENT", "ENOTDIR"), () => Effect.succeed(!1)), Effect.mapError(storeError("store.io", "write a commit")))))
|
|
259
|
+
return { _tag: "Missing" };
|
|
260
|
+
if (!(yield* headerOf(id).pipe(Effect.map((found) => found.nonce === header.nonce), Effect.catchTag("SessionMissing", () => Effect.succeed(!1))))) {
|
|
261
|
+
yield* bestEffort("unlink", temporary, () => io.unlink(temporary));
|
|
262
|
+
return { _tag: "Replaced" };
|
|
263
|
+
}
|
|
264
|
+
const linking = yield* Clock.currentTimeMillis;
|
|
265
|
+
if (Option.isSome(commit.notAfter) && linking > commit.notAfter.value) {
|
|
266
|
+
yield* bestEffort("unlink", temporary, () => io.unlink(temporary));
|
|
267
|
+
return { _tag: "Expired", notAfter: commit.notAfter.value, now: linking };
|
|
268
|
+
}
|
|
269
|
+
const outcome = yield* publish(id, temporary, revision, ours);
|
|
270
|
+
yield* bestEffort("unlink", temporary, () => io.unlink(temporary));
|
|
271
|
+
if (outcome._tag === "Published")
|
|
272
|
+
yield* bestEffort("sync", commitsDir(id), () => io.syncDirectory(commitsDir(id)));
|
|
273
|
+
return outcome;
|
|
274
|
+
}));
|
|
275
|
+
return yield* Match.valueTags(published, {
|
|
276
|
+
Published: () => remember(id, header.nonce, { revision, seq: meta.last, at, state }, [meta]).pipe(Effect.andThen(sweepTemporaries(id, revision)), Effect.as({ revision, seq: meta.last, events: events.map(eventOf(id, at)), at })),
|
|
277
|
+
Taken: () => current(id).pipe(Effect.flatMap((after) => Effect.fail(new RevisionConflict({ session: id, expected: commit.expect, actual: after.tail.revision })))),
|
|
278
|
+
Missing: () => Effect.fail(new SessionMissing({ session: id })),
|
|
279
|
+
Expired: (expired) => Effect.fail(new LeaseExpired({ session: id, notAfter: expired.notAfter, now: expired.now })),
|
|
280
|
+
Replaced: () => commit.incarnation !== void 0 ? Effect.fail(new SessionMissing({ session: id })) : current(id).pipe(Effect.flatMap((after) => Effect.fail(new RevisionConflict({ session: id, expected: commit.expect, actual: after.tail.revision }))))
|
|
281
|
+
});
|
|
282
|
+
}, onSession),
|
|
283
|
+
remove: Effect.fn("session_log.remove")(function* (id) {
|
|
284
|
+
yield* removeTree(id);
|
|
285
|
+
}, onSession)
|
|
286
|
+
});
|
|
287
|
+
}), SessionLogFilesLive = (root, options) => Layer.effect(SessionLog, makeSessionLogFiles(root, options));
|
package/package.json
CHANGED
|
@@ -1,6 +1,31 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xandreed/plugin-session-files",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
"version": "0.15.1",
|
|
4
|
+
"engines": {
|
|
5
|
+
"node": ">=24",
|
|
6
|
+
"bun": ">=1.3.0"
|
|
7
|
+
},
|
|
8
|
+
"type": "module",
|
|
9
|
+
"main": "./dist/index.js",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"default": "./dist/index.js"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {},
|
|
17
|
+
"private": false,
|
|
18
|
+
"types": "./dist/index.d.ts",
|
|
19
|
+
"peerDependencies": {
|
|
20
|
+
"@xandreed/core": "^0.15.1",
|
|
21
|
+
"effect": "^4.0.7"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"dist",
|
|
25
|
+
"skills",
|
|
26
|
+
"profiles",
|
|
27
|
+
"README.md",
|
|
28
|
+
"LICENSE"
|
|
29
|
+
],
|
|
30
|
+
"license": "MIT"
|
|
31
|
+
}
|