@xandreed/plugin-sessions 0.8.0-next.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +5 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +4 -0
- package/dist/plugin.adapter.d.ts +70 -0
- package/dist/plugin.adapter.js +14 -0
- package/dist/process-liveness.adapter.d.ts +7 -0
- package/dist/process-liveness.adapter.js +16 -0
- package/dist/sessions-state.entity.d.ts +111 -0
- package/dist/sessions-state.entity.functions.d.ts +36 -0
- package/dist/sessions-state.entity.functions.js +50 -0
- package/dist/sessions-state.entity.js +38 -0
- package/dist/sessions.adapter.d.ts +15 -0
- package/dist/sessions.adapter.js +248 -0
- package/dist/sessions.adapter.test.d.ts +1 -0
- package/dist/turn-writer.adapter.d.ts +41 -0
- package/dist/turn-writer.adapter.js +142 -0
- package/package.json +29 -0
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
ADDED
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { sessionsPlugin, SessionsPluginLive, default } from "./plugin.adapter.js";
|
|
2
|
+
export { SessionsLive } from "./sessions.adapter.js";
|
|
3
|
+
export { processLiveness } from "./process-liveness.adapter.js";
|
|
4
|
+
export { HeldTurn, HolderProcess, Ownership, SessionsConfig, sessionsDefaults, SessionsState, InboxSlot } from "./sessions-state.entity.js";
|
|
5
|
+
export type { ProcessLiveness } from "./sessions-state.entity.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { sessionsPlugin, SessionsPluginLive, default } from "./plugin.adapter.js";
|
|
2
|
+
export { SessionsLive } from "./sessions.adapter.js";
|
|
3
|
+
export { processLiveness } from "./process-liveness.adapter.js";
|
|
4
|
+
export { HeldTurn, HolderProcess, Ownership, SessionsConfig, sessionsDefaults, SessionsState, InboxSlot } from "./sessions-state.entity.js";
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { SessionLog, Sessions, TurnAdmission } from "@xandreed/core";
|
|
2
|
+
/**
|
|
3
|
+
* Sessions over the host's SessionLog: one event log per session, one open
|
|
4
|
+
* turn at a time, leases by lease or by process, the inbox and forks. The
|
|
5
|
+
* host admits every turn (TurnAdmissionOpen admits all).
|
|
6
|
+
*/
|
|
7
|
+
export declare const sessionsPlugin: import("@xandreed/core").TypedPlugin<{
|
|
8
|
+
readonly ownership: {
|
|
9
|
+
readonly mode: "lease";
|
|
10
|
+
readonly ttlMs: number;
|
|
11
|
+
readonly renew: "none" | "on-commit" | {
|
|
12
|
+
readonly everyMs: number;
|
|
13
|
+
};
|
|
14
|
+
} | {
|
|
15
|
+
readonly mode: "process";
|
|
16
|
+
};
|
|
17
|
+
readonly titleChars: number;
|
|
18
|
+
readonly inbox: {
|
|
19
|
+
readonly maxPending: number;
|
|
20
|
+
readonly attempts: number;
|
|
21
|
+
};
|
|
22
|
+
readonly writer: {
|
|
23
|
+
readonly capacity: number;
|
|
24
|
+
readonly batch: number;
|
|
25
|
+
};
|
|
26
|
+
readonly retries: number;
|
|
27
|
+
}, {
|
|
28
|
+
readonly ownership: {
|
|
29
|
+
readonly mode: "lease";
|
|
30
|
+
readonly ttlMs: number;
|
|
31
|
+
readonly renew: "none" | "on-commit" | {
|
|
32
|
+
readonly everyMs: number;
|
|
33
|
+
};
|
|
34
|
+
} | {
|
|
35
|
+
readonly mode: "process";
|
|
36
|
+
};
|
|
37
|
+
readonly titleChars: number;
|
|
38
|
+
readonly inbox: {
|
|
39
|
+
readonly maxPending: number;
|
|
40
|
+
readonly attempts: number;
|
|
41
|
+
};
|
|
42
|
+
readonly writer: {
|
|
43
|
+
readonly capacity: number;
|
|
44
|
+
readonly batch: number;
|
|
45
|
+
};
|
|
46
|
+
readonly retries: number;
|
|
47
|
+
}, Sessions, never, SessionLog | TurnAdmission>;
|
|
48
|
+
/** Sessions as a typed layer: provides Sessions; requires SessionLog and TurnAdmission. */
|
|
49
|
+
export declare const SessionsPluginLive: (options?: Partial<{
|
|
50
|
+
readonly ownership: {
|
|
51
|
+
readonly mode: "lease";
|
|
52
|
+
readonly ttlMs: number;
|
|
53
|
+
readonly renew: "none" | "on-commit" | {
|
|
54
|
+
readonly everyMs: number;
|
|
55
|
+
};
|
|
56
|
+
} | {
|
|
57
|
+
readonly mode: "process";
|
|
58
|
+
};
|
|
59
|
+
readonly titleChars: number;
|
|
60
|
+
readonly inbox: {
|
|
61
|
+
readonly maxPending: number;
|
|
62
|
+
readonly attempts: number;
|
|
63
|
+
};
|
|
64
|
+
readonly writer: {
|
|
65
|
+
readonly capacity: number;
|
|
66
|
+
readonly batch: number;
|
|
67
|
+
};
|
|
68
|
+
readonly retries: number;
|
|
69
|
+
}> | undefined) => import("effect/Layer").Layer<Sessions, import("@xandreed/core").HarnessError, SessionLog | TurnAdmission>;
|
|
70
|
+
export default sessionsPlugin;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { definePlugin, SessionLog, Sessions, TurnAdmission } from "@xandreed/core";
|
|
2
|
+
import { SessionsConfig, sessionsDefaults } from "./sessions-state.entity.js";
|
|
3
|
+
import { SessionsLive } from "./sessions.adapter.js";
|
|
4
|
+
export const sessionsPlugin = definePlugin({
|
|
5
|
+
id: "@xandreed/plugin-sessions",
|
|
6
|
+
version: "0.8.0-next.0",
|
|
7
|
+
scope: "runtime",
|
|
8
|
+
config: SessionsConfig,
|
|
9
|
+
defaults: sessionsDefaults,
|
|
10
|
+
requires: [SessionLog, TurnAdmission],
|
|
11
|
+
provides: [Sessions],
|
|
12
|
+
layer: SessionsLive
|
|
13
|
+
}), SessionsPluginLive = sessionsPlugin.live;
|
|
14
|
+
export default sessionsPlugin;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ProcessLiveness } from "./sessions-state.entity.js";
|
|
2
|
+
/**
|
|
3
|
+
* Process ownership's default liveness: this process by its host, pid and
|
|
4
|
+
* start time, and a pid of this host runs while `kill(pid, 0)` finds it.
|
|
5
|
+
* Without a process (another runtime), turns name none and nothing is asked.
|
|
6
|
+
*/
|
|
7
|
+
export declare const processLiveness: ProcessLiveness;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Effect, Option } from "effect";
|
|
2
|
+
const running = Effect.sync(() => Option.fromNullishOr(typeof globalThis.process === "object" ? globalThis.process : void 0)), hostOf = (current) => {
|
|
3
|
+
const os = typeof current.getBuiltinModule === "function" ? Option.fromNullishOr(current.getBuiltinModule("node:os")) : Option.none();
|
|
4
|
+
return Option.flatMap(os, (module) => Option.fromNullishOr(module.hostname())).pipe(Option.orElse(() => Option.fromNullishOr(current.env.HOSTNAME)), Option.filter((host) => host.length > 0));
|
|
5
|
+
}, signalled = (error) => !(typeof error === "object" && error !== null && ("code" in error) && error.code === "ESRCH");
|
|
6
|
+
export const processLiveness = {
|
|
7
|
+
current: running.pipe(Effect.map(Option.flatMap((current) => Option.map(hostOf(current), (host) => ({
|
|
8
|
+
host,
|
|
9
|
+
pid: current.pid,
|
|
10
|
+
startedAt: Math.round(performance.timeOrigin)
|
|
11
|
+
}))))),
|
|
12
|
+
alive: (pid) => running.pipe(Effect.flatMap(Option.match({
|
|
13
|
+
onNone: () => Effect.succeed(!0),
|
|
14
|
+
onSome: (current) => pid <= 0 ? Effect.succeed(!1) : Effect.try({ try: () => current.kill(pid, 0), catch: signalled }).pipe(Effect.as(!0), Effect.catch((alive) => Effect.succeed(alive)))
|
|
15
|
+
})))
|
|
16
|
+
};
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
import type { Effect, Option } from "effect";
|
|
3
|
+
/** One inbox item that is not done yet: waiting, or taken by an open turn. */
|
|
4
|
+
export declare const InboxSlot: Schema.Struct<{
|
|
5
|
+
readonly id: Schema.String;
|
|
6
|
+
/** Turns that took it and did not finish it. */
|
|
7
|
+
readonly attempts: Schema.Int;
|
|
8
|
+
readonly claimedBy: Schema.OptionFromNullOr<Schema.Int>;
|
|
9
|
+
}>;
|
|
10
|
+
export type InboxSlot = typeof InboxSlot.Type;
|
|
11
|
+
/** Where a process-owned turn's holder runs: its host, its pid, and when that process started (a pid is reused). */
|
|
12
|
+
export declare const HolderProcess: Schema.Struct<{
|
|
13
|
+
readonly host: Schema.String;
|
|
14
|
+
readonly pid: Schema.Int;
|
|
15
|
+
readonly startedAt: Schema.Number;
|
|
16
|
+
}>;
|
|
17
|
+
export type HolderProcess = typeof HolderProcess.Type;
|
|
18
|
+
/** The open turn as kept: under process ownership, also where its holder runs (none when an older version wrote it). */
|
|
19
|
+
export declare const HeldTurn: Schema.Struct<{
|
|
20
|
+
readonly process: Schema.OptionFromOptionalKey<Schema.Struct<{
|
|
21
|
+
readonly host: Schema.String;
|
|
22
|
+
readonly pid: Schema.Int;
|
|
23
|
+
readonly startedAt: Schema.Number;
|
|
24
|
+
}>>;
|
|
25
|
+
readonly turn: Schema.Int;
|
|
26
|
+
readonly runId: Schema.String;
|
|
27
|
+
readonly key: Schema.String;
|
|
28
|
+
readonly origin: Schema.Literals<readonly ["user", "inbox"]>;
|
|
29
|
+
readonly holder: Schema.String;
|
|
30
|
+
readonly expiresAt: Schema.OptionFromNullOr<Schema.Number>;
|
|
31
|
+
}>;
|
|
32
|
+
export type HeldTurn = typeof HeldTurn.Type;
|
|
33
|
+
export declare const SessionsState: Schema.Struct<{
|
|
34
|
+
readonly v: Schema.Literal<1>;
|
|
35
|
+
/** The highest turn number begun (a fork starts from its parent's). */
|
|
36
|
+
readonly turns: Schema.Int;
|
|
37
|
+
readonly open: Schema.OptionFromNullOr<Schema.Struct<{
|
|
38
|
+
readonly process: Schema.OptionFromOptionalKey<Schema.Struct<{
|
|
39
|
+
readonly host: Schema.String;
|
|
40
|
+
readonly pid: Schema.Int;
|
|
41
|
+
readonly startedAt: Schema.Number;
|
|
42
|
+
}>>;
|
|
43
|
+
readonly turn: Schema.Int;
|
|
44
|
+
readonly runId: Schema.String;
|
|
45
|
+
readonly key: Schema.String;
|
|
46
|
+
readonly origin: Schema.Literals<readonly ["user", "inbox"]>;
|
|
47
|
+
readonly holder: Schema.String;
|
|
48
|
+
readonly expiresAt: Schema.OptionFromNullOr<Schema.Number>;
|
|
49
|
+
}>>;
|
|
50
|
+
readonly title: Schema.OptionFromNullOr<Schema.String>;
|
|
51
|
+
readonly inbox: Schema.$Array<Schema.Struct<{
|
|
52
|
+
readonly id: Schema.String;
|
|
53
|
+
/** Turns that took it and did not finish it. */
|
|
54
|
+
readonly attempts: Schema.Int;
|
|
55
|
+
readonly claimedBy: Schema.OptionFromNullOr<Schema.Int>;
|
|
56
|
+
}>>;
|
|
57
|
+
}>;
|
|
58
|
+
export type SessionsState = typeof SessionsState.Type;
|
|
59
|
+
/** How a session's turns are owned. */
|
|
60
|
+
export declare const Ownership: Schema.Union<readonly [Schema.Struct<{
|
|
61
|
+
readonly mode: Schema.Literal<"lease">;
|
|
62
|
+
/** How long an open turn is held without a write (storage time). */
|
|
63
|
+
readonly ttlMs: Schema.Int;
|
|
64
|
+
/** `none`: the lease is fixed at begin; `on-commit`: each write extends it; `everyMs`: a keep-alive also extends it. */
|
|
65
|
+
readonly renew: Schema.Union<readonly [Schema.Literals<readonly ["none", "on-commit"]>, Schema.Struct<{
|
|
66
|
+
readonly everyMs: Schema.Int;
|
|
67
|
+
}>]>;
|
|
68
|
+
}>, Schema.Struct<{
|
|
69
|
+
readonly mode: Schema.Literal<"process">;
|
|
70
|
+
}>]>;
|
|
71
|
+
export type Ownership = typeof Ownership.Type;
|
|
72
|
+
/**
|
|
73
|
+
* How process ownership tells whether a turn's holder still runs: this
|
|
74
|
+
* process as its turns record it (none where the runtime has no process to
|
|
75
|
+
* name: its turns are then reaped by others as before), and whether the
|
|
76
|
+
* process with a pid runs on this host.
|
|
77
|
+
*/
|
|
78
|
+
export interface ProcessLiveness {
|
|
79
|
+
readonly current: Effect.Effect<Option.Option<HolderProcess>>;
|
|
80
|
+
readonly alive: (pid: number) => Effect.Effect<boolean>;
|
|
81
|
+
}
|
|
82
|
+
export declare const SessionsConfig: Schema.Struct<{
|
|
83
|
+
readonly ownership: Schema.Union<readonly [Schema.Struct<{
|
|
84
|
+
readonly mode: Schema.Literal<"lease">;
|
|
85
|
+
/** How long an open turn is held without a write (storage time). */
|
|
86
|
+
readonly ttlMs: Schema.Int;
|
|
87
|
+
/** `none`: the lease is fixed at begin; `on-commit`: each write extends it; `everyMs`: a keep-alive also extends it. */
|
|
88
|
+
readonly renew: Schema.Union<readonly [Schema.Literals<readonly ["none", "on-commit"]>, Schema.Struct<{
|
|
89
|
+
readonly everyMs: Schema.Int;
|
|
90
|
+
}>]>;
|
|
91
|
+
}>, Schema.Struct<{
|
|
92
|
+
readonly mode: Schema.Literal<"process">;
|
|
93
|
+
}>]>;
|
|
94
|
+
/** The title is the first user message, cut to this many characters. */
|
|
95
|
+
readonly titleChars: Schema.Int;
|
|
96
|
+
readonly inbox: Schema.Struct<{
|
|
97
|
+
/** Items that may wait at once. */
|
|
98
|
+
readonly maxPending: Schema.Int;
|
|
99
|
+
/** Turns that may take an item and fail before it is dropped. */
|
|
100
|
+
readonly attempts: Schema.Int;
|
|
101
|
+
}>;
|
|
102
|
+
/** The turn's write-behind queue: items before a writer waits, items taken per commit. */
|
|
103
|
+
readonly writer: Schema.Struct<{
|
|
104
|
+
readonly capacity: Schema.Int;
|
|
105
|
+
readonly batch: Schema.Int;
|
|
106
|
+
}>;
|
|
107
|
+
/** Compare-and-swap attempts before a write gives up as contended. */
|
|
108
|
+
readonly retries: Schema.Int;
|
|
109
|
+
}>;
|
|
110
|
+
export type SessionsConfig = typeof SessionsConfig.Type;
|
|
111
|
+
export declare const sessionsDefaults: SessionsConfig;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Effect, Option, Schema } from "effect";
|
|
2
|
+
import type { JsonObject, SessionHead, SessionView, TurnEndReason } from "@xandreed/core";
|
|
3
|
+
import type { HeldTurn, HolderProcess, InboxSlot, Ownership, ProcessLiveness, SessionsState as State } from "./sessions-state.entity.js";
|
|
4
|
+
/** A session's state before its first turn: a fork counts from its parent's turns. */
|
|
5
|
+
export declare const initialState: (head: SessionHead) => State;
|
|
6
|
+
/** The plugin's state from a head (the initial state when there is none yet). */
|
|
7
|
+
export declare const stateOf: (head: SessionHead) => Effect.Effect<State, Schema.SchemaError>;
|
|
8
|
+
/** The state as the head stores it. */
|
|
9
|
+
export declare const stateJson: (state: State) => JsonObject;
|
|
10
|
+
/** Who asks whether a turn is held: this Sessions instance, and how it tells whether a process runs. */
|
|
11
|
+
export interface Holder {
|
|
12
|
+
readonly instance: string;
|
|
13
|
+
readonly liveness: ProcessLiveness;
|
|
14
|
+
/** This process, as its turns record it. */
|
|
15
|
+
readonly process: Option.Option<HolderProcess>;
|
|
16
|
+
}
|
|
17
|
+
/** Whether an open turn is still held: within its lease (storage time), or by a process that runs. */
|
|
18
|
+
export declare const isHeld: (open: HeldTurn, now: number, ownership: Ownership, holder: Holder) => Effect.Effect<boolean>;
|
|
19
|
+
/** The turn a head has open and still held, if any. */
|
|
20
|
+
export declare const heldTurn: (head: SessionHead, state: State, ownership: Ownership, holder: Holder) => Effect.Effect<Option.Option<HeldTurn>>;
|
|
21
|
+
/** Inbox items waiting for a turn. */
|
|
22
|
+
export declare const pendingOf: (state: State) => ReadonlyArray<InboxSlot>;
|
|
23
|
+
/**
|
|
24
|
+
* Close the open turn. Returns the state after it and the inbox items it
|
|
25
|
+
* dropped (taken by too many turns that did not finish them). A turn that
|
|
26
|
+
* did not try its items — interrupted before it wrote anything — returns
|
|
27
|
+
* them to wait without counting an attempt.
|
|
28
|
+
*/
|
|
29
|
+
export declare const closedState: (state: State, reason: TurnEndReason, maxAttempts: number, tried: boolean) => {
|
|
30
|
+
readonly state: State;
|
|
31
|
+
readonly dropped: ReadonlyArray<string>;
|
|
32
|
+
};
|
|
33
|
+
/** One session as a host sees it: an open turn nobody holds any more (its lease ran out, its process stopped) is shown as closed. */
|
|
34
|
+
export declare const viewOf: (head: SessionHead, state: State, ownership: Ownership, holder: Holder) => Effect.Effect<SessionView>;
|
|
35
|
+
/** The title a session takes from its first user message: its first `chars` characters. */
|
|
36
|
+
export declare const titleOf: (text: string, chars: number) => string;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { Effect, Option, Schema } from "effect";
|
|
2
|
+
import { SessionsState } from "./sessions-state.entity.js";
|
|
3
|
+
const decodeState = Schema.decodeUnknownEffect(SessionsState), encodeState = Schema.encodeSync(SessionsState);
|
|
4
|
+
export const initialState = (head) => ({
|
|
5
|
+
v: 1,
|
|
6
|
+
turns: Option.match(head.header.parent, { onNone: () => 0, onSome: (parent) => parent.turnAtFork }),
|
|
7
|
+
open: Option.none(),
|
|
8
|
+
title: Option.none(),
|
|
9
|
+
inbox: []
|
|
10
|
+
}), stateOf = (head) => head.state.sessions === void 0 ? Effect.succeed(initialState(head)) : decodeState(head.state.sessions), stateJson = (state) => ({ sessions: encodeState(state) });
|
|
11
|
+
const processHolds = (open, holder) => open.holder === holder.instance ? Effect.succeed(!0) : Option.match(open.process, {
|
|
12
|
+
onNone: () => Effect.succeed(!1),
|
|
13
|
+
onSome: (running) => Option.match(holder.process, {
|
|
14
|
+
onNone: () => Effect.succeed(!0),
|
|
15
|
+
onSome: (self) => running.host !== self.host ? Effect.succeed(!0) : running.pid === self.pid ? Effect.succeed(running.startedAt === self.startedAt) : holder.liveness.alive(running.pid)
|
|
16
|
+
})
|
|
17
|
+
});
|
|
18
|
+
export const isHeld = (open, now, ownership, holder) => ownership.mode === "process" ? processHolds(open, holder) : Effect.succeed(Option.match(open.expiresAt, { onNone: () => !0, onSome: (end) => now <= end })), heldTurn = (head, state, ownership, holder) => Option.match(state.open, {
|
|
19
|
+
onNone: () => Effect.succeed(Option.none()),
|
|
20
|
+
onSome: (open) => isHeld(open, head.now, ownership, holder).pipe(Effect.map((held) => held ? Option.some(open) : Option.none()))
|
|
21
|
+
});
|
|
22
|
+
const openTurnOf = (held) => ({
|
|
23
|
+
turn: held.turn,
|
|
24
|
+
runId: held.runId,
|
|
25
|
+
key: held.key,
|
|
26
|
+
origin: held.origin,
|
|
27
|
+
holder: held.holder,
|
|
28
|
+
expiresAt: held.expiresAt
|
|
29
|
+
});
|
|
30
|
+
export const pendingOf = (state) => state.inbox.filter((slot) => Option.isNone(slot.claimedBy));
|
|
31
|
+
const finished = (reason) => reason === "completed" || reason === "partial" || reason === "cancelled";
|
|
32
|
+
export const closedState = (state, reason, maxAttempts, tried) => {
|
|
33
|
+
const turn = Option.map(state.open, (open) => open.turn), taken = (slot) => Option.isSome(slot.claimedBy) && Option.contains(turn, slot.claimedBy.value), counted = tried || reason !== "interrupted", exhausted = (slot) => counted && slot.attempts + 1 >= maxAttempts;
|
|
34
|
+
return {
|
|
35
|
+
state: {
|
|
36
|
+
...state,
|
|
37
|
+
open: Option.none(),
|
|
38
|
+
inbox: state.inbox.flatMap((slot) => !taken(slot) ? [slot] : finished(reason) || exhausted(slot) ? [] : [{ ...slot, attempts: counted ? slot.attempts + 1 : slot.attempts, claimedBy: Option.none() }])
|
|
39
|
+
},
|
|
40
|
+
dropped: finished(reason) ? [] : state.inbox.filter((slot) => taken(slot) && exhausted(slot)).map((slot) => slot.id)
|
|
41
|
+
};
|
|
42
|
+
}, viewOf = (head, state, ownership, holder) => heldTurn(head, state, ownership, holder).pipe(Effect.map((open) => ({
|
|
43
|
+
header: head.header,
|
|
44
|
+
seq: head.seq,
|
|
45
|
+
updatedAt: head.updatedAt,
|
|
46
|
+
turns: state.turns,
|
|
47
|
+
title: state.title,
|
|
48
|
+
open: Option.map(open, openTurnOf),
|
|
49
|
+
pending: pendingOf(state).length
|
|
50
|
+
}))), titleOf = (text, chars) => Array.from(text).slice(0, chars).join("");
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
import { OpenTurn } from "@xandreed/core";
|
|
3
|
+
export const InboxSlot = Schema.Struct({
|
|
4
|
+
id: Schema.String,
|
|
5
|
+
attempts: Schema.Int,
|
|
6
|
+
claimedBy: Schema.OptionFromNullOr(Schema.Int)
|
|
7
|
+
}), HolderProcess = Schema.Struct({ host: Schema.String, pid: Schema.Int, startedAt: Schema.Number }), HeldTurn = Schema.Struct({ ...OpenTurn.fields, process: Schema.OptionFromOptionalKey(HolderProcess) }), SessionsState = Schema.Struct({
|
|
8
|
+
v: Schema.Literal(1),
|
|
9
|
+
turns: Schema.Int,
|
|
10
|
+
open: Schema.OptionFromNullOr(HeldTurn),
|
|
11
|
+
title: Schema.OptionFromNullOr(Schema.String),
|
|
12
|
+
inbox: Schema.Array(InboxSlot)
|
|
13
|
+
}), Ownership = Schema.Union([
|
|
14
|
+
Schema.Struct({
|
|
15
|
+
mode: Schema.Literal("lease"),
|
|
16
|
+
ttlMs: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1000)),
|
|
17
|
+
renew: Schema.Union([Schema.Literals(["none", "on-commit"]), Schema.Struct({ everyMs: Schema.Int.check(Schema.isGreaterThanOrEqualTo(100)) })])
|
|
18
|
+
}),
|
|
19
|
+
Schema.Struct({ mode: Schema.Literal("process") })
|
|
20
|
+
]), SessionsConfig = Schema.Struct({
|
|
21
|
+
ownership: Ownership,
|
|
22
|
+
titleChars: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 500 })),
|
|
23
|
+
inbox: Schema.Struct({
|
|
24
|
+
maxPending: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 1000 })),
|
|
25
|
+
attempts: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 10 }))
|
|
26
|
+
}),
|
|
27
|
+
writer: Schema.Struct({
|
|
28
|
+
capacity: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 1e5 })),
|
|
29
|
+
batch: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 1e4 }))
|
|
30
|
+
}),
|
|
31
|
+
retries: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 64 }))
|
|
32
|
+
}), sessionsDefaults = {
|
|
33
|
+
ownership: { mode: "lease", ttlMs: 60000, renew: "on-commit" },
|
|
34
|
+
titleChars: 80,
|
|
35
|
+
inbox: { maxPending: 20, attempts: 2 },
|
|
36
|
+
writer: { capacity: 1024, batch: 64 },
|
|
37
|
+
retries: 8
|
|
38
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Layer } from "effect";
|
|
2
|
+
import { SessionLog, Sessions, TurnAdmission } from "@xandreed/core";
|
|
3
|
+
import type { ProcessLiveness, SessionsConfig } from "./sessions-state.entity.js";
|
|
4
|
+
/**
|
|
5
|
+
* Sessions over a SessionLog. The state (turns, the open turn, the title,
|
|
6
|
+
* the inbox) is kept in the head and changed only by guarded commits, with
|
|
7
|
+
* the events that justify it, so every instance over the same log agrees.
|
|
8
|
+
* Turns are written by their `TurnWriter`; everything else here is one
|
|
9
|
+
* compare-and-swap loop that plans again after a conflict. Under process
|
|
10
|
+
* ownership, `liveness` names this process in the turns it holds and tells
|
|
11
|
+
* whether another's still runs (by default, by host, pid and `kill(pid, 0)`).
|
|
12
|
+
*/
|
|
13
|
+
export declare const SessionsLive: (config: SessionsConfig, options?: {
|
|
14
|
+
readonly liveness?: ProcessLiveness;
|
|
15
|
+
}) => Layer.Layer<Sessions, never, SessionLog | TurnAdmission>;
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { Clock, Effect, Exit, Layer, Option, PubSub, Ref, Scope, Stream } from "effect";
|
|
2
|
+
import {
|
|
3
|
+
canonicalJson,
|
|
4
|
+
ConversationId,
|
|
5
|
+
EntryId,
|
|
6
|
+
InboxFull,
|
|
7
|
+
KeyConflict,
|
|
8
|
+
NothingPending,
|
|
9
|
+
RESERVED_KINDS,
|
|
10
|
+
ReservedKind,
|
|
11
|
+
SessionBusy,
|
|
12
|
+
SessionLog,
|
|
13
|
+
SessionLogError,
|
|
14
|
+
SessionMissing,
|
|
15
|
+
Sessions,
|
|
16
|
+
TurnAdmission,
|
|
17
|
+
TurnDuplicate,
|
|
18
|
+
turnEndedDraft,
|
|
19
|
+
turnStartedDraft,
|
|
20
|
+
turnStartedOf,
|
|
21
|
+
UserMessage
|
|
22
|
+
} from "@xandreed/core";
|
|
23
|
+
import { processLiveness } from "./process-liveness.adapter.js";
|
|
24
|
+
import { closedState, heldTurn, pendingOf, stateJson, stateOf, titleOf, viewOf } from "./sessions-state.entity.functions.js";
|
|
25
|
+
import { makeTurnWriter } from "./turn-writer.adapter.js";
|
|
26
|
+
const storage = (code) => (error) => new SessionLogError({ code, message: error.message }), standalone = (drafts) => drafts.map((draft) => ({ kind: draft.kind, turn: Option.none(), data: draft.data }));
|
|
27
|
+
export const SessionsLive = (config, options) => Layer.effect(Sessions, Effect.gen(function* () {
|
|
28
|
+
const log = yield* SessionLog, admission = yield* TurnAdmission, ownership = config.ownership, instance = yield* Effect.sync(() => crypto.randomUUID()), liveness = options?.liveness ?? processLiveness, holder = { instance, liveness, process: ownership.mode === "process" ? yield* liveness.current : Option.none() }, signals = yield* PubSub.unbounded(), writers = yield* Ref.make(new Map), wake = (id) => PubSub.publish(signals, id).pipe(Effect.asVoid), freshId = Effect.sync(() => ConversationId.make(crypto.randomUUID())), stateFrom = (head) => stateOf(head).pipe(Effect.mapError(storage("session.state"))), owned = (address) => log.head(address.id).pipe(Effect.flatMap((head) => head.header.owner === address.owner ? Effect.succeed(head) : Effect.fail(new SessionMissing({ session: address.id })))), viewFrom = (head) => stateFrom(head).pipe(Effect.flatMap((state) => viewOf(head, state, ownership, holder))), guarded = (address, plan, stored = () => Effect.void) => {
|
|
29
|
+
const attempt = (tries) => Effect.gen(function* () {
|
|
30
|
+
const head = yield* owned(address), planned = yield* plan(head, yield* stateFrom(head));
|
|
31
|
+
if (planned.drafts.length === 0 && Option.isNone(planned.next))
|
|
32
|
+
return { result: planned.result, head, committed: Option.none() };
|
|
33
|
+
const outcome = yield* Effect.uninterruptible(Effect.result(log.commit(address.id, {
|
|
34
|
+
expect: head.revision,
|
|
35
|
+
notAfter: Option.none(),
|
|
36
|
+
events: planned.drafts,
|
|
37
|
+
state: Option.map(planned.next, stateJson)
|
|
38
|
+
})).pipe(Effect.tap((result) => result._tag === "Success" ? stored(planned.result) : Effect.void)));
|
|
39
|
+
if (outcome._tag === "Success") {
|
|
40
|
+
yield* wake(address.id);
|
|
41
|
+
return { result: planned.result, head, committed: Option.some(outcome.success) };
|
|
42
|
+
}
|
|
43
|
+
const error = outcome.failure;
|
|
44
|
+
if (error._tag === "RevisionConflict")
|
|
45
|
+
return tries + 1 >= config.retries ? yield* Effect.fail(new SessionLogError({ code: "session.contended", message: `${config.retries} conflicting writes to ${address.id}` })) : yield* attempt(tries + 1);
|
|
46
|
+
return yield* Effect.fail(error._tag === "LeaseExpired" ? new SessionLogError({ code: "session.expired", message: "an unguarded commit expired" }) : error);
|
|
47
|
+
});
|
|
48
|
+
return attempt(0);
|
|
49
|
+
}, reaped = (head, state) => Effect.gen(function* () {
|
|
50
|
+
if (Option.isNone(state.open) || Option.isSome(yield* heldTurn(head, state, ownership, holder)))
|
|
51
|
+
return { drafts: [], state };
|
|
52
|
+
const turn = state.open.value.turn, after = closedState(state, "interrupted", config.inbox.attempts, !0), closing = yield* turnEndedDraft(turn, { reason: "interrupted", failure: Option.some({ code: "turn.lease", message: "the turn's holder stopped writing" }) }).pipe(Effect.mapError(storage("session.encode"))), dropped = after.dropped.map((id) => ({ kind: "inbox.dropped", turn: Option.some(turn), data: { id, reason: "attempts" } }));
|
|
53
|
+
return { drafts: [closing, ...dropped], state: after.state };
|
|
54
|
+
}), keyed = (id, key) => log.read(id, { after: 0, limit: Option.none(), kinds: ["turn.started"] }).pipe(Effect.flatMap((events) => Option.match(Option.fromNullishOr(events.find((event) => event.data.key === key)), {
|
|
55
|
+
onNone: () => Effect.succeed(Option.none()),
|
|
56
|
+
onSome: (event) => Effect.succeed(Option.some({ turn: Option.getOrElse(event.turn, () => 0), event }))
|
|
57
|
+
}))), inboxItems = (id, ids) => log.read(id, { after: 0, limit: Option.none(), kinds: ["inbox.queued"] }).pipe(Effect.map((events) => ids.flatMap((wanted) => Option.toArray(Option.fromNullishOr(events.find((event) => event.data.id === wanted))).map((event) => ({
|
|
58
|
+
id: wanted,
|
|
59
|
+
source: typeof event.data.source === "object" && event.data.source !== null ? event.data.source : {},
|
|
60
|
+
content: typeof event.data.content === "string" && event.data.content.length > 0 ? event.data.content : `(inbox item ${wanted})`
|
|
61
|
+
}))))), unhanded = (address, turn, exit) => guarded(address, (_head, state) => Effect.gen(function* () {
|
|
62
|
+
if (!Option.exists(state.open, (open) => open.turn === turn && open.holder === instance))
|
|
63
|
+
return { drafts: [], next: Option.none(), result: void 0 };
|
|
64
|
+
const reason = Exit.hasInterrupts(exit) ? "interrupted" : "failed", after = closedState(state, reason, config.inbox.attempts, !1), closing = yield* turnEndedDraft(turn, { reason, failure: Option.some({ code: "turn.unended", message: "the turn's scope closed before it ended" }) }).pipe(Effect.mapError(storage("session.encode"))), dropped = after.dropped.map((id) => ({ kind: "inbox.dropped", turn: Option.some(turn), data: { id, reason: "attempts" } }));
|
|
65
|
+
return { drafts: [closing, ...dropped], next: Option.some(after.state), result: void 0 };
|
|
66
|
+
})).pipe(Effect.timeout("10 seconds"), Effect.ignore), begin = (address, input) => Effect.uninterruptibleMask((restore) => Effect.gen(function* () {
|
|
67
|
+
const scope = yield* Effect.scope, at = yield* Clock.currentTimeMillis, toAdmit = { session: address, origin: input._tag === "User" ? "user" : "inbox", runId: input.runId, key: input._tag === "User" ? input.key : input.runId }, unowned = yield* Ref.make(Option.none());
|
|
68
|
+
yield* Scope.addFinalizerExit(scope, (exit) => Ref.get(unowned).pipe(Effect.flatMap(Option.match({ onNone: () => Effect.void, onSome: (turn) => unhanded(address, turn, exit) }))));
|
|
69
|
+
const begun = yield* restore(admission.admit(toAdmit, guarded(address, (head, stored) => Effect.gen(function* () {
|
|
70
|
+
const reap = yield* reaped(head, stored), state = reap.state;
|
|
71
|
+
if (input._tag === "User") {
|
|
72
|
+
const previous = yield* keyed(address.id, input.key);
|
|
73
|
+
if (Option.isSome(previous)) {
|
|
74
|
+
const started = yield* turnStartedOf(previous.value.event).pipe(Effect.mapError(storage("session.decode"))), same = started.userMessage.text === input.userMessage.text && canonicalJson(started.command) === canonicalJson(input.command);
|
|
75
|
+
return yield* Effect.fail(same ? new TurnDuplicate({ session: address.id, turn: previous.value.turn, open: Option.exists(state.open, (open) => open.turn === previous.value.turn) }) : new KeyConflict({ session: address.id, key: input.key, turn: previous.value.turn }));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (Option.isSome(state.open))
|
|
79
|
+
return yield* Effect.fail(new SessionBusy({ session: address.id, turn: state.open.value.turn }));
|
|
80
|
+
const waiting = input._tag === "Inbox" ? pendingOf(state).map((slot) => slot.id) : [];
|
|
81
|
+
if (input._tag === "Inbox" && waiting.length === 0)
|
|
82
|
+
return yield* Effect.fail(new NothingPending({ session: address.id }));
|
|
83
|
+
const claimed = yield* inboxItems(address.id, waiting), userMessage = input._tag === "User" ? input.userMessage : new UserMessage({ text: claimed.map((item) => item.content).join(`
|
|
84
|
+
|
|
85
|
+
`) }), turn = state.turns + 1, key = input._tag === "User" ? input.key : input.runId, open = {
|
|
86
|
+
turn,
|
|
87
|
+
runId: input.runId,
|
|
88
|
+
key,
|
|
89
|
+
origin: input._tag === "User" ? "user" : "inbox",
|
|
90
|
+
holder: instance,
|
|
91
|
+
expiresAt: ownership.mode === "lease" ? Option.some(head.now + ownership.ttlMs) : Option.none(),
|
|
92
|
+
process: holder.process
|
|
93
|
+
}, next = {
|
|
94
|
+
...state,
|
|
95
|
+
turns: turn,
|
|
96
|
+
open: Option.some(open),
|
|
97
|
+
title: Option.orElse(state.title, () => input._tag === "User" ? Option.some(titleOf(input.userMessage.text, config.titleChars)) : Option.none()),
|
|
98
|
+
inbox: state.inbox.map((slot) => waiting.includes(slot.id) ? { ...slot, claimedBy: Option.some(turn) } : slot)
|
|
99
|
+
}, starting = yield* turnStartedDraft(turn, {
|
|
100
|
+
runId: input.runId,
|
|
101
|
+
key,
|
|
102
|
+
origin: open.origin,
|
|
103
|
+
userMessage,
|
|
104
|
+
command: input._tag === "User" ? input.command : {},
|
|
105
|
+
claimed: waiting,
|
|
106
|
+
entry: EntryId.make(`${input.runId}:0`),
|
|
107
|
+
at
|
|
108
|
+
}).pipe(Effect.mapError(storage("session.encode"))), admitted = {
|
|
109
|
+
session: address,
|
|
110
|
+
turn,
|
|
111
|
+
runId: input.runId,
|
|
112
|
+
key,
|
|
113
|
+
origin: open.origin,
|
|
114
|
+
userMessage,
|
|
115
|
+
command: input._tag === "User" ? input.command : {},
|
|
116
|
+
claimed
|
|
117
|
+
};
|
|
118
|
+
return { drafts: [...reap.drafts, starting], next: Option.some(next), result: { admitted, next } };
|
|
119
|
+
}), (opened) => Ref.set(unowned, Option.some(opened.admitted.turn))))), committed = yield* Option.match(begun.committed, {
|
|
120
|
+
onNone: () => Effect.fail(new SessionLogError({ code: "session.begin", message: "the turn was not committed" })),
|
|
121
|
+
onSome: Effect.succeed
|
|
122
|
+
}), started = yield* Option.match(Option.fromNullishOr(committed.events.at(-1)), {
|
|
123
|
+
onNone: () => Effect.fail(new SessionLogError({ code: "session.begin", message: "the turn's start was not stored" })),
|
|
124
|
+
onSome: Effect.succeed
|
|
125
|
+
});
|
|
126
|
+
yield* Effect.addFinalizer(() => Ref.update(writers, (all) => new Map([...all].filter(([id, open]) => id !== address.id || open.turn !== begun.result.admitted.turn))));
|
|
127
|
+
const open = yield* makeTurnWriter({
|
|
128
|
+
log,
|
|
129
|
+
config,
|
|
130
|
+
instance,
|
|
131
|
+
header: begun.head.header,
|
|
132
|
+
admitted: begun.result.admitted,
|
|
133
|
+
started,
|
|
134
|
+
position: { revision: committed.revision, seq: committed.seq, state: begun.result.next, storageAt: committed.at },
|
|
135
|
+
wake: wake(address.id)
|
|
136
|
+
}, scope);
|
|
137
|
+
yield* Ref.set(unowned, Option.none());
|
|
138
|
+
yield* Ref.update(writers, (all) => new Map([...all, [address.id, { ...open, turn: begun.result.admitted.turn }]]));
|
|
139
|
+
return open.writer;
|
|
140
|
+
})), closeLocal = (id, reason, turn) => Ref.get(writers).pipe(Effect.flatMap((all) => Option.match(Option.fromNullishOr(all.get(id)), {
|
|
141
|
+
onNone: () => Effect.void,
|
|
142
|
+
onSome: (open) => Option.match(turn, { onNone: () => !0, onSome: (wanted) => wanted === open.turn }) ? open.close(reason) : Effect.void
|
|
143
|
+
}))), drain = (address, run, options) => {
|
|
144
|
+
const maxTurns = options?.maxTurns ?? 3, once = Effect.scoped(Effect.gen(function* () {
|
|
145
|
+
const writer = yield* begin(address, { _tag: "Inbox", runId: yield* Effect.sync(() => crypto.randomUUID()) }), exit = yield* Effect.exit(run(writer));
|
|
146
|
+
if (exit._tag === "Failure")
|
|
147
|
+
yield* Effect.logWarning(`inbox turn ${writer.admitted.turn} of ${address.id} failed`);
|
|
148
|
+
yield* writer.end({
|
|
149
|
+
reason: exit._tag === "Success" ? "completed" : "failed",
|
|
150
|
+
failure: exit._tag === "Success" ? Option.none() : Option.some({ code: "inbox.turn", message: "the inbox turn failed" })
|
|
151
|
+
}).pipe(Effect.ignore);
|
|
152
|
+
return !0;
|
|
153
|
+
})).pipe(Effect.catchTags({
|
|
154
|
+
NothingPending: () => Effect.succeed(!1),
|
|
155
|
+
SessionBusy: () => Effect.succeed(!1),
|
|
156
|
+
TurnDuplicate: () => Effect.succeed(!1),
|
|
157
|
+
KeyConflict: () => Effect.succeed(!1),
|
|
158
|
+
TurnRefused: (refused) => Effect.logWarning(`inbox turn of ${address.id} refused: ${refused.message}`).pipe(Effect.as(!1))
|
|
159
|
+
})), loop = (turns) => turns >= maxTurns ? Effect.succeed({ turns }) : once.pipe(Effect.flatMap((ran) => ran ? loop(turns + 1) : Effect.succeed({ turns })));
|
|
160
|
+
return loop(0);
|
|
161
|
+
};
|
|
162
|
+
return Sessions.of({
|
|
163
|
+
create: (input) => Effect.gen(function* () {
|
|
164
|
+
const head = yield* log.create({
|
|
165
|
+
id: input.id ?? (yield* freshId),
|
|
166
|
+
owner: input.owner,
|
|
167
|
+
origin: input.origin ?? "user",
|
|
168
|
+
createdAt: yield* Clock.currentTimeMillis,
|
|
169
|
+
meta: input.meta ?? {},
|
|
170
|
+
parent: Option.none()
|
|
171
|
+
}).pipe(Effect.catchTag("SessionMissing", (error) => Effect.fail(new SessionLogError({ code: "session.create", message: `no parent ${error.session}` }))));
|
|
172
|
+
return yield* viewFrom(head);
|
|
173
|
+
}),
|
|
174
|
+
fork: (parent, input) => Effect.gen(function* () {
|
|
175
|
+
yield* owned(parent);
|
|
176
|
+
const endings = input.inherit ? yield* log.read(parent.id, { after: 0, limit: Option.none(), kinds: ["turn.ended"] }) : [], last = Option.fromNullishOr(endings.at(-1)), head = yield* log.create({
|
|
177
|
+
id: input.id ?? (yield* freshId),
|
|
178
|
+
owner: parent.owner,
|
|
179
|
+
origin: input.origin,
|
|
180
|
+
createdAt: yield* Clock.currentTimeMillis,
|
|
181
|
+
meta: input.meta ?? {},
|
|
182
|
+
parent: Option.some({
|
|
183
|
+
id: parent.id,
|
|
184
|
+
through: Option.match(last, { onNone: () => 0, onSome: (event) => event.seq }),
|
|
185
|
+
turnAtFork: Option.match(last, { onNone: () => 0, onSome: (event) => Option.getOrElse(event.turn, () => 0) })
|
|
186
|
+
})
|
|
187
|
+
});
|
|
188
|
+
return yield* viewFrom(head);
|
|
189
|
+
}),
|
|
190
|
+
get: (address) => owned(address).pipe(Effect.flatMap(viewFrom)),
|
|
191
|
+
list: (query) => Effect.gen(function* () {
|
|
192
|
+
const limit = query.limit ?? 50, heads = yield* log.list({
|
|
193
|
+
owner: query.owner,
|
|
194
|
+
limit,
|
|
195
|
+
before: Option.fromNullishOr(query.before),
|
|
196
|
+
parent: Option.fromNullishOr(query.parent)
|
|
197
|
+
}), sessions = yield* Effect.forEach(heads, viewFrom), last = Option.fromNullishOr(heads.at(-1));
|
|
198
|
+
return {
|
|
199
|
+
sessions,
|
|
200
|
+
next: heads.length < limit ? Option.none() : Option.map(last, (head) => ({ updatedAt: head.updatedAt, id: head.header.id }))
|
|
201
|
+
};
|
|
202
|
+
}),
|
|
203
|
+
remove: (address) => Effect.gen(function* () {
|
|
204
|
+
yield* owned(address);
|
|
205
|
+
yield* log.remove(address.id);
|
|
206
|
+
yield* closeLocal(address.id, "removed", Option.none());
|
|
207
|
+
yield* wake(address.id);
|
|
208
|
+
}),
|
|
209
|
+
read: (address, query) => owned(address).pipe(Effect.flatMap(() => log.read(address.id, {
|
|
210
|
+
after: query?.after ?? 0,
|
|
211
|
+
limit: Option.fromNullishOr(query?.limit),
|
|
212
|
+
kinds: query?.kinds ?? []
|
|
213
|
+
}))),
|
|
214
|
+
changes: (address) => Stream.fromPubSub(signals).pipe(Stream.filter((id) => id === address.id), Stream.map(() => {
|
|
215
|
+
return;
|
|
216
|
+
})),
|
|
217
|
+
lookup: (address, key) => owned(address).pipe(Effect.flatMap(() => keyed(address.id, key)), Effect.map(Option.map((found) => found.turn))),
|
|
218
|
+
transact: (address, decide) => guarded(address, (head, state) => Effect.gen(function* () {
|
|
219
|
+
const decision = yield* decide(yield* viewOf(head, state, ownership, holder)), reserved = decision.drafts.find((draft) => RESERVED_KINDS.includes(draft.kind));
|
|
220
|
+
if (reserved !== void 0)
|
|
221
|
+
return yield* Effect.fail(new ReservedKind({ kind: reserved.kind }));
|
|
222
|
+
return { drafts: standalone(decision.drafts), next: Option.none(), result: decision.result };
|
|
223
|
+
})).pipe(Effect.map((done) => ({ result: done.result, events: Option.match(done.committed, { onNone: () => [], onSome: (committed) => committed.events }) }))),
|
|
224
|
+
cancel: (address, turn) => guarded(address, (_head, state) => Effect.gen(function* () {
|
|
225
|
+
if (!Option.exists(state.open, (open) => open.turn === turn))
|
|
226
|
+
return { drafts: [], next: Option.none(), result: { cancelled: !1, pending: pendingOf(state).length } };
|
|
227
|
+
const after = closedState(state, "cancelled", config.inbox.attempts, !0);
|
|
228
|
+
return { drafts: [yield* turnEndedDraft(turn, { reason: "cancelled", failure: Option.none() }).pipe(Effect.mapError(storage("session.encode")))], next: Option.some(after.state), result: { cancelled: !0, pending: pendingOf(after.state).length } };
|
|
229
|
+
})).pipe(Effect.tap((done) => done.result.cancelled ? closeLocal(address.id, "cancelled", Option.some(turn)) : Effect.void), Effect.map((done) => done.result)),
|
|
230
|
+
begin,
|
|
231
|
+
deliver: (address, item) => guarded(address, (head, stored) => Effect.gen(function* () {
|
|
232
|
+
const reap = yield* reaped(head, stored), state = reap.state, reapedOnly = { drafts: reap.drafts, next: reap.drafts.length === 0 ? Option.none() : Option.some(state) };
|
|
233
|
+
if (state.inbox.some((slot) => slot.id === item.id))
|
|
234
|
+
return { ...reapedOnly, result: { delivered: !1, pending: pendingOf(state).length } };
|
|
235
|
+
if ((yield* log.read(address.id, { after: 0, limit: Option.none(), kinds: ["inbox.queued"] })).some((event) => event.data.id === item.id))
|
|
236
|
+
return { ...reapedOnly, result: { delivered: !1, pending: pendingOf(state).length } };
|
|
237
|
+
if (pendingOf(state).length >= config.inbox.maxPending)
|
|
238
|
+
return yield* Effect.fail(new InboxFull({ session: address.id, pending: pendingOf(state).length }));
|
|
239
|
+
const next = { ...state, inbox: [...state.inbox, { id: item.id, attempts: 0, claimedBy: Option.none() }] };
|
|
240
|
+
return {
|
|
241
|
+
drafts: [...reap.drafts, { kind: "inbox.queued", turn: Option.none(), data: { id: item.id, source: item.source, content: item.content } }],
|
|
242
|
+
next: Option.some(next),
|
|
243
|
+
result: { delivered: !0, pending: pendingOf(next).length }
|
|
244
|
+
};
|
|
245
|
+
})).pipe(Effect.map((done) => done.result)),
|
|
246
|
+
drain
|
|
247
|
+
});
|
|
248
|
+
}));
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { Effect, Scope } from "effect";
|
|
2
|
+
import { TurnClosed } from "@xandreed/core";
|
|
3
|
+
import type { AdmittedTurn, SessionHeader, SessionLog, SessionLogEvent, TurnWriter } from "@xandreed/core";
|
|
4
|
+
import type { Context } from "effect";
|
|
5
|
+
import type { SessionsConfig, SessionsState } from "./sessions-state.entity.js";
|
|
6
|
+
type Log = Context.Service.Shape<typeof SessionLog>;
|
|
7
|
+
export interface TurnWriterInput {
|
|
8
|
+
readonly log: Log;
|
|
9
|
+
readonly config: SessionsConfig;
|
|
10
|
+
readonly instance: string;
|
|
11
|
+
readonly header: SessionHeader;
|
|
12
|
+
readonly admitted: AdmittedTurn;
|
|
13
|
+
readonly started: SessionLogEvent;
|
|
14
|
+
/** The begin commit: the head after it. */
|
|
15
|
+
readonly position: {
|
|
16
|
+
readonly revision: number;
|
|
17
|
+
readonly seq: number;
|
|
18
|
+
readonly state: SessionsState;
|
|
19
|
+
readonly storageAt: number;
|
|
20
|
+
};
|
|
21
|
+
/** Signal followers on this instance after each commit. */
|
|
22
|
+
readonly wake: Effect.Effect<void>;
|
|
23
|
+
}
|
|
24
|
+
/** The writer, and how the sessions service closes it from outside (a cancel or removal on this instance). */
|
|
25
|
+
export interface OpenWriter {
|
|
26
|
+
readonly writer: TurnWriter;
|
|
27
|
+
readonly close: (reason: TurnClosed["reason"]) => Effect.Effect<void>;
|
|
28
|
+
readonly ended: Effect.Effect<boolean>;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The open turn's writer. Items are stored strictly in queue order by one
|
|
32
|
+
* fiber forked in `scope`; consecutive appends share one commit. Every
|
|
33
|
+
* commit expects the revision the writer last saw and, under a lease, is
|
|
34
|
+
* refused by the store once the lease has run out. On a revision conflict
|
|
35
|
+
* the writer reads the head again: while the turn is still its own it takes
|
|
36
|
+
* the others' events (handing them to later decisions) and retries,
|
|
37
|
+
* otherwise the turn was closed elsewhere and every later write fails with
|
|
38
|
+
* `turn.closed`.
|
|
39
|
+
*/
|
|
40
|
+
export declare const makeTurnWriter: (input: TurnWriterInput, scope: Scope.Scope) => Effect.Effect<OpenWriter>;
|
|
41
|
+
export {};
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { Clock, Deferred, Effect, Exit, HashSet, Option, Queue, Ref, Schedule, Scope, Semaphore } from "effect";
|
|
2
|
+
import { HarnessError, turnEndedDraft, TurnClosed } from "@xandreed/core";
|
|
3
|
+
import { closedState, pendingOf, stateJson, stateOf } from "./sessions-state.entity.functions.js";
|
|
4
|
+
const harness = (code, message) => new HarnessError({ code, message }), groupsOf = (items) => items.reduce((groups, item) => {
|
|
5
|
+
const last = groups.at(-1);
|
|
6
|
+
return last?._tag === "Append" && item._tag === "Append" ? [...groups.slice(0, -1), { _tag: "Append", drafts: [...last.drafts, ...item.drafts] }] : [...groups, item];
|
|
7
|
+
}, []);
|
|
8
|
+
export const makeTurnWriter = (input, scope) => Effect.gen(function* () {
|
|
9
|
+
const { log, config, admitted } = input, id = admitted.session.id, ownership = config.ownership, queue = yield* Queue.bounded(config.writer.capacity), failed = yield* Ref.make(Option.none()), ended = yield* Ref.make(!1), sealed = yield* Ref.make(!1), tried = yield* Ref.make(!1), admission = yield* Semaphore.make(1), ending = yield* Ref.make(Option.none()), waiting = yield* Ref.make(HashSet.empty()), closed = yield* Deferred.make(), foreign = yield* Ref.make([]), position = yield* Ref.make({ ...input.position, localAt: yield* Clock.currentTimeMillis }), refuseWaiting = (error) => Ref.get(waiting).pipe(Effect.flatMap((all) => Effect.forEach(all, (refuse) => refuse(error), { discard: !0 }))), close = (reason) => Effect.gen(function* () {
|
|
10
|
+
if (yield* Ref.get(ended))
|
|
11
|
+
return;
|
|
12
|
+
const error = harness("turn.closed", `turn ${admitted.turn} was ${reason}`);
|
|
13
|
+
yield* Ref.update(failed, Option.orElse(() => Option.some(error)));
|
|
14
|
+
yield* Deferred.succeed(closed, new TurnClosed({ session: id, turn: admitted.turn, reason }));
|
|
15
|
+
yield* refuseWaiting(error);
|
|
16
|
+
}), refuseIfFailed = Ref.get(failed).pipe(Effect.flatMap(Option.match({ onNone: () => Effect.void, onSome: Effect.fail }))), refuseIfSealed = Effect.all([Ref.get(sealed), Ref.get(ended)]).pipe(Effect.flatMap(([closing, done]) => !closing ? Effect.void : Effect.fail(harness("turn.closed", done ? `turn ${admitted.turn} has ended` : `turn ${admitted.turn} is ending`)))), storageNow = Effect.gen(function* () {
|
|
17
|
+
const at = yield* Ref.get(position);
|
|
18
|
+
return at.storageAt + ((yield* Clock.currentTimeMillis) - at.localAt);
|
|
19
|
+
}), renewed = (state, now, always) => ownership.mode === "lease" && (always || ownership.renew !== "none") ? { ...state, open: Option.map(state.open, (open) => ({ ...open, expiresAt: Option.some(now + ownership.ttlMs) })) } : state, stamped = (drafts) => drafts.map((draft) => ({ kind: draft.kind, turn: Option.some(admitted.turn), data: draft.data })), reasonIn = (events) => events.some((event) => event.kind === "turn.ended" && Option.contains(event.turn, admitted.turn) && event.data.reason === "cancelled") ? "cancelled" : "interrupted", rebase = (seen) => Effect.gen(function* () {
|
|
20
|
+
const removed = () => close("removed").pipe(Effect.andThen(Effect.fail(harness("turn.closed", "the session was removed")))), head = yield* log.head(id).pipe(Effect.catchTag("SessionMissing", removed)), state = yield* stateOf(head).pipe(Effect.mapError((error) => harness("session.state", error.message))), since = log.read(id, { after: seen.seq, limit: Option.none(), kinds: [] }).pipe(Effect.catchTag("SessionMissing", removed), Effect.map((events) => events.filter((event) => event.seq <= head.seq)));
|
|
21
|
+
if (!Option.exists(state.open, (open) => open.turn === admitted.turn && open.holder === input.instance)) {
|
|
22
|
+
yield* close(reasonIn(yield* since.pipe(Effect.catchTag("SessionLogError", () => Effect.succeed([])))));
|
|
23
|
+
return !1;
|
|
24
|
+
}
|
|
25
|
+
const events = yield* since;
|
|
26
|
+
yield* Ref.update(foreign, (all) => [...all, ...events]);
|
|
27
|
+
yield* Ref.set(position, { revision: head.revision, seq: head.seq, state, storageAt: head.now, localAt: yield* Clock.currentTimeMillis });
|
|
28
|
+
return !0;
|
|
29
|
+
}).pipe(Effect.catchTag("SessionLogError", (error) => Effect.fail(harness("session.log", error.message)))), commitPlan = (plan, options) => {
|
|
30
|
+
const attempt = (tries) => Effect.gen(function* () {
|
|
31
|
+
yield* refuseIfFailed;
|
|
32
|
+
const at = yield* Ref.get(position), decided = yield* plan(at, yield* Ref.get(foreign)), now = yield* storageNow, next = options.renew ? renewed(decided.next, now, !1) : decided.next, notAfter = ownership.mode === "lease" ? Option.flatMap(at.state.open, (open) => open.expiresAt) : Option.none(), outcome = yield* Effect.result(log.commit(id, {
|
|
33
|
+
expect: at.revision,
|
|
34
|
+
notAfter,
|
|
35
|
+
events: stamped(decided.drafts),
|
|
36
|
+
state: next === at.state ? Option.none() : Option.some(stateJson(next))
|
|
37
|
+
}));
|
|
38
|
+
if (outcome._tag === "Success") {
|
|
39
|
+
const committed = outcome.success;
|
|
40
|
+
yield* Ref.set(position, { revision: committed.revision, seq: committed.seq, state: next, storageAt: committed.at, localAt: yield* Clock.currentTimeMillis });
|
|
41
|
+
yield* input.wake;
|
|
42
|
+
return { result: decided.result, events: committed.events };
|
|
43
|
+
}
|
|
44
|
+
const error = outcome.failure;
|
|
45
|
+
if (error._tag === "RevisionConflict") {
|
|
46
|
+
if (!(yield* rebase(at)))
|
|
47
|
+
return yield* Effect.fail(harness("turn.closed", `turn ${admitted.turn} was closed elsewhere`));
|
|
48
|
+
return tries + 1 >= config.retries ? yield* Effect.fail(harness("session.contended", `turn ${admitted.turn}: ${config.retries} conflicting writes`)) : yield* attempt(tries + 1);
|
|
49
|
+
}
|
|
50
|
+
if (error._tag === "LeaseExpired") {
|
|
51
|
+
yield* close("interrupted");
|
|
52
|
+
return yield* Effect.fail(harness("turn.closed", `turn ${admitted.turn}'s lease ran out`));
|
|
53
|
+
}
|
|
54
|
+
if (error._tag === "SessionMissing") {
|
|
55
|
+
yield* close("removed");
|
|
56
|
+
return yield* Effect.fail(harness("turn.closed", "the session was removed"));
|
|
57
|
+
}
|
|
58
|
+
return yield* Effect.fail(harness("session.log", error.message));
|
|
59
|
+
});
|
|
60
|
+
return attempt(0);
|
|
61
|
+
}, latch = (effect) => effect.pipe(Effect.map(Option.some), Effect.catch((error) => Ref.update(failed, Option.orElse(() => Option.some(error))).pipe(Effect.as(Option.none())))), unchanged = (result, drafts) => (at) => Effect.succeed({ drafts, next: at.state, result }), process = (item) => item._tag === "Tracked" ? process(item.item).pipe(Effect.ensuring(item.settled)) : Ref.get(failed).pipe(Effect.flatMap((failure) => {
|
|
62
|
+
if (item._tag === "Marker")
|
|
63
|
+
return Option.match(failure, { onNone: () => Deferred.succeed(item.done, void 0), onSome: (error) => Deferred.fail(item.done, error) }).pipe(Effect.asVoid);
|
|
64
|
+
if (item._tag === "Run" || item._tag === "Commit")
|
|
65
|
+
return item._tag === "Run" ? item.run : item.commit;
|
|
66
|
+
return Option.isSome(failure) ? Effect.void : latch(commitPlan(unchanged(void 0, item.drafts), { renew: !0 })).pipe(Effect.asVoid);
|
|
67
|
+
}));
|
|
68
|
+
yield* Effect.forkIn(Queue.takeBetween(queue, 1, config.writer.batch).pipe(Effect.flatMap((chunk) => Effect.forEach(groupsOf(chunk), process, { discard: !0 })), Effect.forever), scope);
|
|
69
|
+
const offer = (item) => (item._tag === "Append" ? Ref.set(tried, !0) : Effect.void).pipe(Effect.andThen(Queue.offer(queue, item)), Effect.flatMap((accepted) => accepted ? Effect.void : Effect.fail(harness("turn.closed", `turn ${admitted.turn}'s scope closed`)))), enqueue = (result, item) => Effect.uninterruptibleMask((restore) => Effect.gen(function* () {
|
|
70
|
+
const refuse = (error) => Deferred.fail(result, error).pipe(Effect.asVoid), settled = Ref.update(waiting, HashSet.remove(refuse));
|
|
71
|
+
yield* Ref.update(waiting, HashSet.add(refuse));
|
|
72
|
+
yield* restore(offer({ _tag: "Tracked", item, settled })).pipe(Effect.onInterrupt(() => settled), Effect.tapError((error) => refuse(error).pipe(Effect.andThen(settled))));
|
|
73
|
+
})), admit = (result, item, tries) => admission.withPermits(1)(refuseIfFailed.pipe(Effect.andThen(refuseIfSealed), Effect.andThen(tries ? Ref.set(tried, !0) : Effect.void), Effect.andThen(enqueue(result, item)))), inOrder = (commit, tries) => Effect.gen(function* () {
|
|
74
|
+
const result = yield* Deferred.make();
|
|
75
|
+
yield* admit(result, { _tag: "Commit", commit: Effect.exit(commit).pipe(Effect.flatMap((exit) => Deferred.done(result, exit)), Effect.asVoid) }, tries);
|
|
76
|
+
return yield* Deferred.await(result);
|
|
77
|
+
}), flush = Deferred.make().pipe(Effect.flatMap((done) => admission.withPermits(1)(refuseIfFailed.pipe(Effect.andThen(enqueue(done, { _tag: "Marker", done })))).pipe(Effect.andThen(Deferred.await(done))))), endNow = (ending) => Effect.gen(function* () {
|
|
78
|
+
const done = yield* commitPlan((at) => Effect.gen(function* () {
|
|
79
|
+
const after = closedState(at.state, ending.reason, config.inbox.attempts, yield* Ref.get(tried)), closing = yield* turnEndedDraft(admitted.turn, ending).pipe(Effect.mapError((error) => harness("session.encode", error.message))), dropped = after.dropped.map((item) => ({ kind: "inbox.dropped", data: { id: item, reason: "attempts" } }));
|
|
80
|
+
return { drafts: [{ kind: closing.kind, data: closing.data }, ...dropped], next: after.state, result: pendingOf(after.state).length };
|
|
81
|
+
}), { renew: !1 });
|
|
82
|
+
yield* Ref.set(ended, !0);
|
|
83
|
+
return { pending: done.result };
|
|
84
|
+
}), reopen = Ref.set(ending, Option.none()).pipe(Effect.andThen(Ref.set(sealed, !1))), settleEnd = (done, exit) => (Exit.isSuccess(exit) || Option.exists(Exit.findErrorOption(exit), (error) => error.code === "turn.closed") ? Effect.void : reopen).pipe(Effect.andThen(Deferred.done(done, exit)), Effect.asVoid), end = (value) => Effect.gen(function* () {
|
|
85
|
+
const result = yield* admission.withPermits(1)(Effect.uninterruptibleMask((restore) => Effect.gen(function* () {
|
|
86
|
+
const current = yield* Ref.get(ending);
|
|
87
|
+
if (Option.isSome(current))
|
|
88
|
+
return current.value;
|
|
89
|
+
yield* refuseIfFailed;
|
|
90
|
+
const done = yield* Deferred.make();
|
|
91
|
+
yield* Ref.set(sealed, !0);
|
|
92
|
+
yield* Ref.set(ending, Option.some(done));
|
|
93
|
+
yield* restore(enqueue(done, { _tag: "Commit", commit: Effect.exit(endNow(value)).pipe(Effect.flatMap((exit) => settleEnd(done, exit))) })).pipe(Effect.onInterrupt(() => reopen));
|
|
94
|
+
return done;
|
|
95
|
+
})));
|
|
96
|
+
return yield* Deferred.await(result);
|
|
97
|
+
});
|
|
98
|
+
if (ownership.mode === "lease" && typeof ownership.renew === "object") {
|
|
99
|
+
const renew = inOrder(commitPlan((at) => Effect.gen(function* () {
|
|
100
|
+
return { drafts: [], next: renewed(at.state, yield* storageNow, !0), result: void 0 };
|
|
101
|
+
}), { renew: !1 }), !1).pipe(Effect.ignore);
|
|
102
|
+
yield* Effect.forkIn(renew.pipe(Effect.repeat(Schedule.spaced(`${ownership.renew.everyMs} millis`)), Effect.asVoid), scope);
|
|
103
|
+
}
|
|
104
|
+
yield* Scope.addFinalizerExit(scope, (exit) => Ref.get(ended).pipe(Effect.flatMap((done) => done ? Effect.void : end({
|
|
105
|
+
reason: Exit.hasInterrupts(exit) ? "interrupted" : "failed",
|
|
106
|
+
failure: Option.some({ code: "turn.unended", message: "the turn's scope closed before it ended" })
|
|
107
|
+
}).pipe(Effect.timeout("10 seconds"), Effect.ignore)), Effect.ensuring(Effect.gen(function* () {
|
|
108
|
+
const error = harness("turn.closed", `turn ${admitted.turn}'s scope closed`);
|
|
109
|
+
yield* Ref.update(failed, Option.orElse(() => Option.some(error)));
|
|
110
|
+
yield* Queue.shutdown(queue);
|
|
111
|
+
yield* refuseWaiting(error);
|
|
112
|
+
}))));
|
|
113
|
+
return { writer: {
|
|
114
|
+
admitted,
|
|
115
|
+
started: input.started,
|
|
116
|
+
history: (kinds) => historyOf(log, input.header, input.started, kinds),
|
|
117
|
+
snapshot: (kinds, after) => flush.pipe(Effect.andThen(after === void 0 ? historyOf(log, input.header, input.started, kinds, !0) : log.read(id, { after, limit: Option.none(), kinds }).pipe(Effect.mapError((error) => harness("session.log", error._tag))))),
|
|
118
|
+
append: (drafts) => admission.withPermits(1)(refuseIfFailed.pipe(Effect.andThen(refuseIfSealed), Effect.andThen(drafts.length === 0 ? Effect.void : offer({ _tag: "Append", drafts })))),
|
|
119
|
+
write: (op) => Effect.gen(function* () {
|
|
120
|
+
const result = yield* Deferred.make(), run = Effect.exit(Effect.raceFirst(refuseIfFailed.pipe(Effect.andThen(op)), Deferred.await(closed).pipe(Effect.flatMap((note) => Effect.fail(harness("turn.closed", `turn ${admitted.turn} was ${note.reason}`)))))).pipe(Effect.flatMap((exit) => Deferred.done(result, exit)), Effect.asVoid);
|
|
121
|
+
yield* admit(result, { _tag: "Run", run }, !0);
|
|
122
|
+
return yield* Deferred.await(result);
|
|
123
|
+
}),
|
|
124
|
+
transact: (decide) => inOrder(commitPlan((at, others) => decide(others).pipe(Effect.map((decision) => ({ drafts: decision.drafts, next: at.state, result: decision.result }))), { renew: !0 }), !0),
|
|
125
|
+
flush,
|
|
126
|
+
end,
|
|
127
|
+
closed: Deferred.await(closed)
|
|
128
|
+
}, close, ended: Ref.get(ended) };
|
|
129
|
+
});
|
|
130
|
+
const historyOf = (log, header, started, kinds, includeCurrent = !1) => {
|
|
131
|
+
const ancestors = (lineage) => Option.match(lineage, {
|
|
132
|
+
onNone: () => Effect.succeed([]),
|
|
133
|
+
onSome: (parent) => parent.through <= 0 ? Effect.succeed([]) : Effect.gen(function* () {
|
|
134
|
+
const head = yield* log.head(parent.id).pipe(Effect.mapError((error) => harness("session.log", `the fork's parent: ${error._tag}`))), earlier = yield* ancestors(head.header.parent), events = yield* log.read(parent.id, { after: 0, limit: Option.none(), kinds }).pipe(Effect.mapError((error) => harness("session.log", error._tag)));
|
|
135
|
+
return [...earlier, ...events.filter((event) => event.seq <= parent.through)];
|
|
136
|
+
})
|
|
137
|
+
});
|
|
138
|
+
return Effect.gen(function* () {
|
|
139
|
+
const inherited = yield* ancestors(header.parent), own = yield* log.read(header.id, { after: 0, limit: Option.none(), kinds }).pipe(Effect.mapError((error) => harness("session.log", error._tag)));
|
|
140
|
+
return [...inherited, ...own.filter((event) => includeCurrent || event.seq < started.seq)];
|
|
141
|
+
});
|
|
142
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@xandreed/plugin-sessions",
|
|
3
|
+
"version": "0.8.0-next.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"default": "./dist/index.js"
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@xandreed/core": "0.8.0-next.0",
|
|
14
|
+
"effect": "4.0.0-rc.118"
|
|
15
|
+
},
|
|
16
|
+
"private": false,
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"skills",
|
|
21
|
+
"profiles",
|
|
22
|
+
"README.md",
|
|
23
|
+
"LICENSE"
|
|
24
|
+
],
|
|
25
|
+
"engines": {
|
|
26
|
+
"bun": ">=1.3.0"
|
|
27
|
+
},
|
|
28
|
+
"license": "MIT"
|
|
29
|
+
}
|