effect-actors 0.0.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.
Files changed (119) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +467 -0
  3. package/dist/actor/client.d.ts +11 -0
  4. package/dist/actor/client.js +68 -0
  5. package/dist/actor/definition.d.ts +77 -0
  6. package/dist/actor/definition.js +110 -0
  7. package/dist/actor/state/codec.d.ts +10 -0
  8. package/dist/actor/state/codec.js +117 -0
  9. package/dist/actor/state/compiled.d.ts +17 -0
  10. package/dist/actor/state/compiled.js +1 -0
  11. package/dist/actor/state/layer.d.ts +7 -0
  12. package/dist/actor/state/layer.js +167 -0
  13. package/dist/client/http/errors.d.ts +30 -0
  14. package/dist/client/http/errors.js +72 -0
  15. package/dist/client/http/events.d.ts +10 -0
  16. package/dist/client/http/events.js +30 -0
  17. package/dist/client/http/layer.d.ts +5 -0
  18. package/dist/client/http/layer.js +133 -0
  19. package/dist/client/http.d.ts +1 -0
  20. package/dist/client/http.js +1 -0
  21. package/dist/client/outcome.d.ts +4 -0
  22. package/dist/client/outcome.js +10 -0
  23. package/dist/client/service.d.ts +28 -0
  24. package/dist/client/service.js +4 -0
  25. package/dist/errors/index.d.ts +93 -0
  26. package/dist/errors/index.js +93 -0
  27. package/dist/gateway/access.d.ts +11 -0
  28. package/dist/gateway/access.js +38 -0
  29. package/dist/gateway/dispatch.d.ts +13 -0
  30. package/dist/gateway/dispatch.js +45 -0
  31. package/dist/gateway/fault.d.ts +32 -0
  32. package/dist/gateway/fault.js +85 -0
  33. package/dist/gateway/http/events.d.ts +8 -0
  34. package/dist/gateway/http/events.js +32 -0
  35. package/dist/gateway/http/response.d.ts +11 -0
  36. package/dist/gateway/http/response.js +57 -0
  37. package/dist/gateway/http/routes.d.ts +6 -0
  38. package/dist/gateway/http/routes.js +184 -0
  39. package/dist/gateway/http/validation.d.ts +19 -0
  40. package/dist/gateway/http/validation.js +92 -0
  41. package/dist/gateway/options.d.ts +31 -0
  42. package/dist/gateway/options.js +107 -0
  43. package/dist/gateway/security.d.ts +25 -0
  44. package/dist/gateway/security.js +4 -0
  45. package/dist/gateway/service.d.ts +113 -0
  46. package/dist/gateway/service.js +5 -0
  47. package/dist/gateway/websocket/operations.d.ts +20 -0
  48. package/dist/gateway/websocket/operations.js +96 -0
  49. package/dist/gateway/websocket.d.ts +8 -0
  50. package/dist/gateway/websocket.js +96 -0
  51. package/dist/gateway/wire.d.ts +172 -0
  52. package/dist/gateway/wire.js +102 -0
  53. package/dist/host/client.d.ts +9 -0
  54. package/dist/host/client.js +46 -0
  55. package/dist/host/definition.d.ts +71 -0
  56. package/dist/host/definition.js +68 -0
  57. package/dist/host/entity.d.ts +9 -0
  58. package/dist/host/entity.js +302 -0
  59. package/dist/index.d.ts +9 -0
  60. package/dist/index.js +9 -0
  61. package/dist/protocol/encoding.d.ts +29 -0
  62. package/dist/protocol/encoding.js +165 -0
  63. package/dist/protocol/index.d.ts +90 -0
  64. package/dist/protocol/index.js +1 -0
  65. package/dist/protocol/schemas.d.ts +49 -0
  66. package/dist/protocol/schemas.js +30 -0
  67. package/dist/runtime/cluster/address.d.ts +3 -0
  68. package/dist/runtime/cluster/address.js +21 -0
  69. package/dist/runtime/cluster/client.d.ts +9 -0
  70. package/dist/runtime/cluster/client.js +131 -0
  71. package/dist/runtime/cluster/protocol.d.ts +104 -0
  72. package/dist/runtime/cluster/protocol.js +69 -0
  73. package/dist/runtime/configuration.d.ts +4 -0
  74. package/dist/runtime/configuration.js +34 -0
  75. package/dist/runtime/dispatchers/layer.d.ts +6 -0
  76. package/dist/runtime/dispatchers/layer.js +53 -0
  77. package/dist/runtime/dispatchers/outbox.d.ts +4 -0
  78. package/dist/runtime/dispatchers/outbox.js +57 -0
  79. package/dist/runtime/layer.d.ts +6 -0
  80. package/dist/runtime/layer.js +43 -0
  81. package/dist/runtime/service.d.ts +40 -0
  82. package/dist/runtime/service.js +5 -0
  83. package/dist/store/memory/background.d.ts +39 -0
  84. package/dist/store/memory/background.js +206 -0
  85. package/dist/store/memory/model.d.ts +72 -0
  86. package/dist/store/memory/model.js +149 -0
  87. package/dist/store/memory/ownership.d.ts +11 -0
  88. package/dist/store/memory/ownership.js +82 -0
  89. package/dist/store/memory/service.d.ts +3 -0
  90. package/dist/store/memory/service.js +32 -0
  91. package/dist/store/memory/state.d.ts +13 -0
  92. package/dist/store/memory/state.js +109 -0
  93. package/dist/store/service.d.ts +89 -0
  94. package/dist/store/service.js +9 -0
  95. package/dist/store/sql/events.d.ts +13 -0
  96. package/dist/store/sql/events.js +89 -0
  97. package/dist/store/sql/model.d.ts +39 -0
  98. package/dist/store/sql/model.js +126 -0
  99. package/dist/store/sql/outbox.d.ts +15 -0
  100. package/dist/store/sql/outbox.js +130 -0
  101. package/dist/store/sql/ownership.d.ts +44 -0
  102. package/dist/store/sql/ownership.js +130 -0
  103. package/dist/store/sql/receipts.d.ts +25 -0
  104. package/dist/store/sql/receipts.js +93 -0
  105. package/dist/store/sql/schema.d.ts +9 -0
  106. package/dist/store/sql/schema.js +124 -0
  107. package/dist/store/sql/service.d.ts +5 -0
  108. package/dist/store/sql/service.js +42 -0
  109. package/dist/store/sql/state.d.ts +13 -0
  110. package/dist/store/sql/state.js +109 -0
  111. package/dist/store/sql/timers.d.ts +10 -0
  112. package/dist/store/sql/timers.js +90 -0
  113. package/dist/store/sql/transaction.d.ts +6 -0
  114. package/dist/store/sql/transaction.js +57 -0
  115. package/dist/store/sql/transition.d.ts +15 -0
  116. package/dist/store/sql/transition.js +154 -0
  117. package/dist/testing/index.d.ts +6 -0
  118. package/dist/testing/index.js +27 -0
  119. package/package.json +80 -0
@@ -0,0 +1,69 @@
1
+ import { Schema } from "effect";
2
+ import { ClusterSchema, Entity } from "effect/unstable/cluster";
3
+ import { Rpc } from "effect/unstable/rpc";
4
+ import { CommandErrorSchema, EventErrorSchema, HostedErrorSchema, ReadErrorSchema, } from "../../errors/index.js";
5
+ import * as Wire from "../../protocol/schemas.js";
6
+ const Command = Rpc.make("Command", {
7
+ payload: {
8
+ commandId: Schema.String,
9
+ fingerprint: Schema.String,
10
+ protocolVersion: Schema.Int,
11
+ command: Schema.String,
12
+ payload: Schema.Json,
13
+ expectedRevision: Schema.optionalKey(Schema.String),
14
+ },
15
+ success: Wire.Receipt,
16
+ error: CommandErrorSchema,
17
+ primaryKey: ({ commandId, fingerprint }) => JSON.stringify([commandId, fingerprint]),
18
+ })
19
+ .annotate(ClusterSchema.Persisted, true)
20
+ .annotate(ClusterSchema.Uninterruptible, "server");
21
+ const Query = Rpc.make("Query", {
22
+ payload: { query: Schema.String, protocolVersion: Schema.Int, payload: Schema.Json },
23
+ success: Wire.QueryResult,
24
+ error: ReadErrorSchema,
25
+ });
26
+ const Receipt = Rpc.make("Receipt", {
27
+ payload: { commandId: Schema.String },
28
+ success: Schema.NullOr(Wire.Receipt),
29
+ error: ReadErrorSchema,
30
+ });
31
+ const Events = Rpc.make("Events", {
32
+ payload: { after: Schema.String, limit: Schema.Int },
33
+ success: Wire.EventPage,
34
+ error: EventErrorSchema,
35
+ });
36
+ const Wake = Rpc.make("Wake", {
37
+ payload: { timerId: Schema.String, generation: Schema.String, dueAtMillis: Schema.Number },
38
+ success: Schema.NullOr(Wire.Receipt),
39
+ error: CommandErrorSchema,
40
+ });
41
+ const actionFields = {
42
+ action: Schema.String,
43
+ protocolVersion: Schema.Int,
44
+ payload: Schema.Json,
45
+ requestKey: Schema.optionalKey(Schema.String),
46
+ fingerprint: Schema.String,
47
+ };
48
+ const Action = Rpc.make("Action", {
49
+ payload: actionFields,
50
+ success: Wire.Outcome,
51
+ error: HostedErrorSchema,
52
+ });
53
+ const DurableAction = Rpc.make("DurableAction", {
54
+ payload: actionFields,
55
+ success: Wire.Outcome,
56
+ error: HostedErrorSchema,
57
+ primaryKey: ({ requestKey, fingerprint }) => JSON.stringify([requestKey, fingerprint]),
58
+ })
59
+ .annotate(ClusterSchema.Persisted, true)
60
+ .annotate(ClusterSchema.Uninterruptible, "server");
61
+ export const makeEntity = (namespace, actorType) => Entity.make(`effect-actors/${JSON.stringify([namespace, actorType])}`, [
62
+ Command,
63
+ Query,
64
+ Receipt,
65
+ Events,
66
+ Wake,
67
+ Action,
68
+ DurableAction,
69
+ ]).annotate(ClusterSchema.ShardGroup, () => namespace);
@@ -0,0 +1,4 @@
1
+ import { Effect } from "effect";
2
+ import { RegistrationError } from "../errors/index.js";
3
+ import type { Configuration } from "./service.js";
4
+ export declare const validate: (configuration: Configuration) => Effect.Effect<Configuration, RegistrationError>;
@@ -0,0 +1,34 @@
1
+ import { Duration, Effect } from "effect";
2
+ import { RegistrationError } from "../errors/index.js";
3
+ export const validate = (configuration) => Effect.try({
4
+ try: () => {
5
+ if (typeof configuration.namespace !== "string" ||
6
+ configuration.namespace.length < 1 ||
7
+ configuration.namespace.length > 256 ||
8
+ /[\u0000-\u001f\u007f]/.test(configuration.namespace)) {
9
+ throw new Error("Runtime namespace must contain 1 to 256 characters");
10
+ }
11
+ for (const name of [
12
+ "maxCommandBytes",
13
+ "maxStateBytes",
14
+ "maxEventBytes",
15
+ "maxEventsPerTurn",
16
+ "maxOutboxCallsPerTurn",
17
+ "maxTimerChangesPerTurn",
18
+ ]) {
19
+ if (!Number.isSafeInteger(configuration[name]) || configuration[name] < 1)
20
+ throw new Error(`Invalid runtime limit: ${name}`);
21
+ }
22
+ const turn = Duration.toMillis(Duration.fromInputUnsafe(configuration.maxTurnDuration));
23
+ const lease = Duration.toMillis(Duration.fromInputUnsafe(configuration.actorLeaseDuration));
24
+ const renew = Duration.toMillis(Duration.fromInputUnsafe(configuration.actorLeaseRenewEvery));
25
+ if (![turn, lease, renew].every((value) => Number.isFinite(value) && value > 0) ||
26
+ renew >= lease / 2) {
27
+ throw new Error("Runtime durations must be positive and renewal must be shorter than half the lease");
28
+ }
29
+ return Object.freeze({ ...configuration });
30
+ },
31
+ catch: (cause) => new RegistrationError({
32
+ message: cause instanceof Error ? cause.message : "Invalid runtime configuration",
33
+ }),
34
+ });
@@ -0,0 +1,6 @@
1
+ import { Layer } from "effect";
2
+ import { Client } from "../../client/service.js";
3
+ import { type StoreError } from "../../errors/index.js";
4
+ import { Store } from "../../store/service.js";
5
+ import { Runtime, type DispatchOptions } from "../service.js";
6
+ export declare const layerDispatchers: (options: DispatchOptions) => Layer.Layer<never, StoreError, Runtime | Client | Store>;
@@ -0,0 +1,53 @@
1
+ import { Duration, Effect, Layer } from "effect";
2
+ import { Client } from "../../client/service.js";
3
+ import { InvalidInput } from "../../errors/index.js";
4
+ import { Store } from "../../store/service.js";
5
+ import { Runtime } from "../service.js";
6
+ import { deliver } from "./outbox.js";
7
+ export const layerDispatchers = (options) => Layer.effectDiscard(Effect.gen(function* () {
8
+ yield* Effect.try({
9
+ try: () => {
10
+ if (!Number.isSafeInteger(options.batchSize) ||
11
+ options.batchSize < 1 ||
12
+ options.batchSize > 1000 ||
13
+ !Number.isSafeInteger(options.concurrency) ||
14
+ options.concurrency < 1 ||
15
+ options.concurrency > options.batchSize ||
16
+ ![options.pollInterval, options.claimDuration].every((input) => {
17
+ const millis = Duration.toMillis(Duration.fromInputUnsafe(input));
18
+ return Number.isFinite(millis) && millis > 0;
19
+ }))
20
+ throw new Error("Invalid dispatcher configuration");
21
+ },
22
+ catch: () => new InvalidInput({ message: "Invalid dispatcher configuration" }),
23
+ });
24
+ const runtime = yield* Runtime;
25
+ const store = yield* Store;
26
+ const client = yield* Client;
27
+ const namespace = runtime.configuration.namespace;
28
+ const outbox = Effect.gen(function* () {
29
+ const claims = yield* store.claimOutbox({
30
+ namespace,
31
+ limit: options.batchSize,
32
+ claimDuration: options.claimDuration,
33
+ });
34
+ yield* Effect.forEach(claims, (claim) => deliver(store, client, claim), {
35
+ concurrency: options.concurrency,
36
+ discard: true,
37
+ });
38
+ });
39
+ const timers = Effect.gen(function* () {
40
+ const due = yield* store.dueTimers({ namespace, limit: options.batchSize });
41
+ yield* Effect.forEach(due, (candidate) => runtime.wake(candidate), {
42
+ concurrency: options.concurrency,
43
+ discard: true,
44
+ });
45
+ });
46
+ const passes = [
47
+ ["outbox", outbox],
48
+ ["timers", timers],
49
+ ];
50
+ for (const [name, pass] of passes) {
51
+ yield* pass.pipe(Effect.catch((error) => Effect.logWarning("Actor dispatcher pass failed").pipe(Effect.annotateLogs({ dispatcher: name, errorCode: error._tag }))), Effect.andThen(Effect.sleep(options.pollInterval)), Effect.forever, Effect.forkScoped);
52
+ }
53
+ }));
@@ -0,0 +1,4 @@
1
+ import { Effect } from "effect";
2
+ import type { Client } from "../../client/service.js";
3
+ import type { OutboxClaim, Store } from "../../store/service.js";
4
+ export declare const deliver: (store: Store["Service"], client: Client["Service"], claim: OutboxClaim) => Effect.Effect<void, import("../../errors/index.js").StoreError, never>;
@@ -0,0 +1,57 @@
1
+ import { Clock, Effect, Result } from "effect";
2
+ import { FrameworkErrorSchema, } from "../../errors/index.js";
3
+ import { fingerprint } from "../../protocol/encoding.js";
4
+ import { encodeJson } from "../../actor/state/codec.js";
5
+ const retryable = (error) => {
6
+ switch (error._tag) {
7
+ case "effect-actors/OwnershipLost":
8
+ case "effect-actors/Overloaded":
9
+ case "effect-actors/OutcomeUnknown":
10
+ return true;
11
+ case "effect-actors/StorageUnavailable":
12
+ case "effect-actors/HostUnavailable":
13
+ return error.retryable;
14
+ default:
15
+ return false;
16
+ }
17
+ };
18
+ export const deliver = (store, client, claim) => Effect.gen(function* () {
19
+ const call = claim.command;
20
+ const digest = fingerprint({
21
+ command: call.operation,
22
+ protocolVersion: call.protocolVersion,
23
+ payload: call.payload,
24
+ });
25
+ const send = call.kind === "command"
26
+ ? client
27
+ .command({
28
+ address: call.target,
29
+ command: call.operation,
30
+ protocolVersion: call.protocolVersion,
31
+ payload: call.payload,
32
+ commandId: call.commandId,
33
+ fingerprint: digest,
34
+ })
35
+ .pipe(Effect.map((receipt) => receipt.value))
36
+ : client.hosted({
37
+ address: call.target,
38
+ action: call.operation,
39
+ protocolVersion: call.protocolVersion,
40
+ payload: call.payload,
41
+ requestKey: call.commandId,
42
+ fingerprint: digest,
43
+ });
44
+ const result = yield* Effect.result(send);
45
+ if (Result.isSuccess(result))
46
+ return yield* store.settleOutbox(claim, result.success);
47
+ if (!retryable(result.failure))
48
+ return yield* store.settleOutbox(claim, {
49
+ _tag: "Failure",
50
+ error: encodeJson(FrameworkErrorSchema, result.failure),
51
+ });
52
+ const now = yield* Clock.currentTimeMillis;
53
+ yield* store.retryOutbox(claim, {
54
+ retryAtMillis: now + Math.min(60_000, 100 * 2 ** Math.min(claim.attempt, 10)),
55
+ reasonCode: result.failure._tag,
56
+ });
57
+ });
@@ -0,0 +1,6 @@
1
+ import { Layer } from "effect";
2
+ import { MessageStorage, Sharding } from "effect/unstable/cluster";
3
+ import { Client } from "../client/service.js";
4
+ import { RegistrationError } from "../errors/index.js";
5
+ import { Runtime, type Configuration } from "./service.js";
6
+ export declare const layer: (options: Configuration) => Layer.Layer<Runtime | Client, RegistrationError, Sharding.Sharding | MessageStorage.MessageStorage>;
@@ -0,0 +1,43 @@
1
+ import { Context, Effect, Layer } from "effect";
2
+ import { MessageStorage, Sharding } from "effect/unstable/cluster";
3
+ import { Client } from "../client/service.js";
4
+ import { RegistrationError } from "../errors/index.js";
5
+ import { assertIdentifier } from "../actor/state/codec.js";
6
+ import { make } from "./cluster/client.js";
7
+ import { validate } from "./configuration.js";
8
+ import { Runtime } from "./service.js";
9
+ export const layer = (options) => Layer.effectContext(Effect.gen(function* () {
10
+ const configuration = yield* validate(options);
11
+ const sharding = yield* Sharding.Sharding;
12
+ const storage = yield* MessageStorage.MessageStorage;
13
+ if (storage === MessageStorage.noop)
14
+ return yield* new RegistrationError({
15
+ message: "Actor runtimes require Cluster message storage",
16
+ });
17
+ const registered = new Set();
18
+ const clients = make(configuration, sharding);
19
+ const runtime = {
20
+ configuration,
21
+ wake: clients.wake,
22
+ register: (registration) => Effect.gen(function* () {
23
+ yield* Effect.try({
24
+ try: () => {
25
+ assertIdentifier(registration.actorType);
26
+ if (!Number.isSafeInteger(registration.protocolVersion) ||
27
+ registration.protocolVersion < 1) {
28
+ throw new RegistrationError({ message: "Invalid actor protocol version" });
29
+ }
30
+ if (registered.has(registration.actorType))
31
+ throw new RegistrationError({ message: "Actor type is already registered" });
32
+ registered.add(registration.actorType);
33
+ },
34
+ catch: () => new RegistrationError({ message: "Invalid or duplicate actor registration" }),
35
+ });
36
+ yield* Effect.addFinalizer(() => Effect.sync(() => {
37
+ registered.delete(registration.actorType);
38
+ }));
39
+ yield* Layer.build(registration.entity).pipe(Effect.provideService(Sharding.Sharding, sharding));
40
+ }),
41
+ };
42
+ return Context.make(Runtime, runtime).pipe(Context.add(Client, clients.client));
43
+ }));
@@ -0,0 +1,40 @@
1
+ import { Context } from "effect";
2
+ import type { Duration, Effect, Layer, Option, Scope } from "effect";
3
+ import type { Sharding } from "effect/unstable/cluster";
4
+ import type { CommandError, RegistrationError } from "../errors/index.js";
5
+ import type { TimerCandidate } from "../store/service.js";
6
+ import type { WireReceipt } from "../protocol/index.js";
7
+ export interface Configuration {
8
+ readonly namespace: string;
9
+ readonly maxCommandBytes: number;
10
+ readonly maxStateBytes: number;
11
+ readonly maxEventBytes: number;
12
+ readonly maxEventsPerTurn: number;
13
+ readonly maxOutboxCallsPerTurn: number;
14
+ readonly maxTimerChangesPerTurn: number;
15
+ readonly maxTurnDuration: Duration.Input;
16
+ readonly actorLeaseDuration: Duration.Input;
17
+ readonly actorLeaseRenewEvery: Duration.Input;
18
+ }
19
+ export interface Registration {
20
+ readonly actorType: string;
21
+ readonly protocolVersion: number;
22
+ readonly kind: "state" | "hosted";
23
+ readonly entity: Layer.Layer<never, never, Sharding.Sharding>;
24
+ }
25
+ export interface Service {
26
+ readonly configuration: Configuration;
27
+ readonly wake: (candidate: TimerCandidate) => Effect.Effect<Option.Option<WireReceipt>, CommandError>;
28
+ readonly register: (registration: Registration) => Effect.Effect<void, RegistrationError, Scope.Scope>;
29
+ }
30
+ declare const Runtime_base: Context.ServiceClass<Runtime, "effect-actors/Runtime", Service>;
31
+ export declare class Runtime extends Runtime_base {
32
+ }
33
+ export { layer } from "./layer.js";
34
+ export interface DispatchOptions {
35
+ readonly pollInterval: Duration.Input;
36
+ readonly batchSize: number;
37
+ readonly concurrency: number;
38
+ readonly claimDuration: Duration.Input;
39
+ }
40
+ export { layerDispatchers } from "./dispatchers/layer.js";
@@ -0,0 +1,5 @@
1
+ import { Context } from "effect";
2
+ export class Runtime extends Context.Service()("effect-actors/Runtime") {
3
+ }
4
+ export { layer } from "./layer.js";
5
+ export { layerDispatchers } from "./dispatchers/layer.js";
@@ -0,0 +1,39 @@
1
+ import { Effect, Option, Result } from "effect";
2
+ import { CursorExpired, InvalidInput, type StoreError } from "../../errors/index.js";
3
+ import type { Address, Json, Outcome, WireReceipt } from "../../protocol/index.js";
4
+ import type { EncodedTransition, OutboxClaim, Ownership, TimerCandidate, Turn } from "../service.js";
5
+ import { type MemoryContext } from "./model.js";
6
+ export declare const receipt: (context: MemoryContext, address: Address, commandId: string) => Effect.Effect<Option.Option<WireReceipt>, StoreError>;
7
+ export declare const events: (context: MemoryContext, address: Address, options: {
8
+ readonly after: string;
9
+ readonly limit: number;
10
+ }) => Effect.Effect<{
11
+ events: {
12
+ cursor: string;
13
+ commandId: string;
14
+ committedAtMillis: number;
15
+ value: import("effect/Schema").Json;
16
+ }[];
17
+ nextCursor: string;
18
+ hasMore: boolean;
19
+ }, CursorExpired | InvalidInput, never>;
20
+ export declare const claimOutbox: (context: MemoryContext, options: {
21
+ readonly namespace: string;
22
+ readonly limit: number;
23
+ readonly claimDuration: import("effect").Duration.Input;
24
+ }) => Effect.Effect<ReadonlyArray<OutboxClaim>, StoreError>;
25
+ export declare const settleOutbox: (context: MemoryContext, claim: OutboxClaim, outcome: Outcome) => Effect.Effect<void, StoreError>;
26
+ export declare const retryOutbox: (context: MemoryContext, claim: OutboxClaim, options: {
27
+ readonly retryAtMillis: number;
28
+ readonly reasonCode: string;
29
+ }) => Effect.Effect<void, StoreError>;
30
+ export declare const dueTimers: (context: MemoryContext, options: {
31
+ readonly namespace: string;
32
+ readonly limit: number;
33
+ }) => Effect.Effect<ReadonlyArray<TimerCandidate>, StoreError>;
34
+ export declare const fireTimer: (context: MemoryContext, ownership: Ownership, candidate: TimerCandidate, evaluate: (command: string, payload: Json, turn: Turn) => Result.Result<EncodedTransition, Json>) => Effect.Effect<Option.Option<WireReceipt>, StoreError>;
35
+ export declare const collectEvents: (context: MemoryContext, options: {
36
+ readonly namespace: string;
37
+ readonly beforeMillis: number;
38
+ readonly limit: number;
39
+ }) => Effect.Effect<number, StoreError>;
@@ -0,0 +1,206 @@
1
+ import { Clock, Effect, Option, Result } from "effect";
2
+ import { CursorExpired, InvalidInput } from "../../errors/index.js";
3
+ import { addressId, canonicalJson, makeCursor, readCursor, validateId, } from "../../protocol/index.js";
4
+ import { decimalInput, normalizeCallbackError, nonNegativeInteger, positiveInteger, protocolBoundary, } from "../sql/model.js";
5
+ import { timerCommandIdentity, timerFingerprint } from "../sql/transition.js";
6
+ import { applyEvaluation, cloneJson, cloneReceipt, durationMillis, snapshot, } from "./model.js";
7
+ import { withOwner } from "./ownership.js";
8
+ export const receipt = (context, address, commandId) => protocolBoundary(() => {
9
+ validateId(commandId, 1024);
10
+ const stored = context.receipts.get(addressId(address))?.get(commandId);
11
+ return stored === undefined ? Option.none() : Option.some(cloneReceipt(stored.receipt));
12
+ });
13
+ export const events = (context, address, options) => Effect.gen(function* () {
14
+ const actorId = yield* protocolBoundary(() => addressId(address));
15
+ const after = yield* protocolBoundary(() => readCursor(address, options.after));
16
+ const limit = yield* protocolBoundary(() => positiveInteger(options.limit, "event page limit"));
17
+ return yield* context.gate.withPermit(Effect.gen(function* () {
18
+ const head = context.heads.get(actorId);
19
+ const highwater = head?.eventSequence ?? 0n;
20
+ const floor = head?.eventFloor ?? 0n;
21
+ if (BigInt(after) < floor) {
22
+ return yield* Effect.fail(new CursorExpired({ message: "Event cursor precedes retained history" }));
23
+ }
24
+ if (BigInt(after) > highwater) {
25
+ return yield* Effect.fail(new InvalidInput({ message: "Event cursor is ahead of the actor" }));
26
+ }
27
+ const selected = (context.events.get(actorId) ?? [])
28
+ .filter((event) => event.sequence > BigInt(after))
29
+ .slice(0, limit + 1);
30
+ const page = selected.slice(0, limit);
31
+ return {
32
+ events: page.map(({ sequence: _, ...event }) => ({
33
+ ...event,
34
+ value: cloneJson(event.value),
35
+ })),
36
+ nextCursor: makeCursor(address, page.at(-1)?.sequence.toString() ?? after),
37
+ hasMore: selected.length > limit,
38
+ };
39
+ }));
40
+ });
41
+ export const claimOutbox = (context, options) => Effect.gen(function* () {
42
+ const limit = yield* protocolBoundary(() => positiveInteger(options.limit, "outbox claim limit"));
43
+ const duration = yield* durationMillis(options.claimDuration, "claimDuration");
44
+ if (typeof options.namespace !== "string" || options.namespace.length === 0) {
45
+ return yield* Effect.fail(new InvalidInput({ message: "Invalid outbox namespace" }));
46
+ }
47
+ return yield* context.gate.withPermit(Effect.gen(function* () {
48
+ const nowMillis = yield* Clock.currentTimeMillis;
49
+ return [...context.outbox.values()]
50
+ .filter((call) => call.namespace === options.namespace &&
51
+ ((call.status === "pending" && call.retryAtMillis <= nowMillis) ||
52
+ (call.status === "claimed" && call.claimUntilMillis <= nowMillis)))
53
+ .sort((left, right) => left.createdAtMillis - right.createdAtMillis || left.id.localeCompare(right.id))
54
+ .slice(0, limit)
55
+ .map((call) => {
56
+ const claimToken = `memory-${++context.token}`;
57
+ call.status = "claimed";
58
+ call.attempt += 1;
59
+ call.claimToken = claimToken;
60
+ call.claimUntilMillis = nowMillis + duration;
61
+ return {
62
+ id: call.id,
63
+ claimToken,
64
+ attempt: call.attempt,
65
+ command: cloneJson(call.command),
66
+ };
67
+ });
68
+ }));
69
+ });
70
+ const validateClaim = (claim) => protocolBoundary(() => {
71
+ if (typeof claim.id !== "string" ||
72
+ claim.id.length === 0 ||
73
+ typeof claim.claimToken !== "string" ||
74
+ claim.claimToken.length === 0) {
75
+ throw new InvalidInput({ message: "Invalid outbox claim" });
76
+ }
77
+ });
78
+ export const settleOutbox = (context, claim, outcome) => Effect.gen(function* () {
79
+ yield* validateClaim(claim);
80
+ const encoded = yield* protocolBoundary(() => cloneJson(outcome));
81
+ yield* context.gate.withPermit(Effect.gen(function* () {
82
+ const nowMillis = yield* Clock.currentTimeMillis;
83
+ const call = context.outbox.get(claim.id);
84
+ if (call === undefined ||
85
+ call.status !== "claimed" ||
86
+ call.claimToken !== claim.claimToken ||
87
+ call.claimUntilMillis <= nowMillis)
88
+ return;
89
+ call.status = "settled";
90
+ call.outcome = encoded;
91
+ call.claimToken = undefined;
92
+ call.claimUntilMillis = undefined;
93
+ call.reasonCode = undefined;
94
+ }));
95
+ });
96
+ export const retryOutbox = (context, claim, options) => Effect.gen(function* () {
97
+ yield* validateClaim(claim);
98
+ yield* protocolBoundary(() => {
99
+ nonNegativeInteger(options.retryAtMillis, "retryAtMillis");
100
+ if (typeof options.reasonCode !== "string" ||
101
+ options.reasonCode.length === 0 ||
102
+ options.reasonCode.length > 256) {
103
+ throw new InvalidInput({ message: "Invalid outbox retry reason" });
104
+ }
105
+ });
106
+ yield* context.gate.withPermit(Effect.gen(function* () {
107
+ const nowMillis = yield* Clock.currentTimeMillis;
108
+ const call = context.outbox.get(claim.id);
109
+ if (call === undefined ||
110
+ call.status !== "claimed" ||
111
+ call.claimToken !== claim.claimToken ||
112
+ call.claimUntilMillis <= nowMillis)
113
+ return;
114
+ call.status = "pending";
115
+ call.retryAtMillis = options.retryAtMillis;
116
+ call.reasonCode = options.reasonCode;
117
+ call.claimToken = undefined;
118
+ call.claimUntilMillis = undefined;
119
+ }));
120
+ });
121
+ export const dueTimers = (context, options) => Effect.gen(function* () {
122
+ const limit = yield* protocolBoundary(() => positiveInteger(options.limit, "timer scan limit"));
123
+ if (typeof options.namespace !== "string" || options.namespace.length === 0) {
124
+ return yield* Effect.fail(new InvalidInput({ message: "Invalid timer namespace" }));
125
+ }
126
+ return yield* context.gate.withPermit(Effect.gen(function* () {
127
+ const nowMillis = yield* Clock.currentTimeMillis;
128
+ return [...context.timers.values()]
129
+ .filter((timer) => timer.address.namespace === options.namespace &&
130
+ timer.status === "scheduled" &&
131
+ timer.dueAtMillis <= nowMillis)
132
+ .sort((left, right) => left.dueAtMillis - right.dueAtMillis)
133
+ .slice(0, limit)
134
+ .map((timer) => ({
135
+ address: timer.address,
136
+ timerId: timer.timerId,
137
+ generation: timer.generation.toString(),
138
+ dueAtMillis: timer.dueAtMillis,
139
+ }));
140
+ }));
141
+ });
142
+ export const fireTimer = (context, ownership, candidate, evaluate) => withOwner(context, ownership, (owner, head, nowMillis) => Effect.gen(function* () {
143
+ const candidateAddress = yield* protocolBoundary(() => addressId(candidate.address));
144
+ if (candidateAddress !== owner.addressId) {
145
+ return yield* Effect.fail(new InvalidInput({ message: "Timer candidate does not match its owner" }));
146
+ }
147
+ yield* protocolBoundary(() => {
148
+ validateId(candidate.timerId);
149
+ decimalInput(candidate.generation, "timer generation");
150
+ nonNegativeInteger(candidate.dueAtMillis, "timer dueAtMillis");
151
+ });
152
+ const timer = context.timers.get(canonicalJson([owner.addressId, candidate.timerId]));
153
+ if (timer === undefined ||
154
+ timer.status !== "scheduled" ||
155
+ timer.generation.toString() !== candidate.generation ||
156
+ timer.dueAtMillis !== candidate.dueAtMillis ||
157
+ timer.dueAtMillis > nowMillis)
158
+ return Option.none();
159
+ const current = yield* Effect.try({
160
+ try: () => snapshot(head),
161
+ catch: normalizeCallbackError,
162
+ });
163
+ const evaluation = yield* Effect.try({
164
+ try: () => evaluate(timer.command, cloneJson(timer.payload), { snapshot: current, nowMillis }),
165
+ catch: normalizeCallbackError,
166
+ });
167
+ const result = yield* applyEvaluation(context, head, {
168
+ commandId: timerCommandIdentity(timer.timerId, timer.generation.toString()),
169
+ fingerprint: timerFingerprint(timer.timerId, timer.generation.toString(), timer.command, timer.payload),
170
+ evaluation,
171
+ consume: () => {
172
+ timer.status = "consumed";
173
+ },
174
+ });
175
+ return Option.some(result);
176
+ }));
177
+ export const collectEvents = (context, options) => protocolBoundary(() => {
178
+ positiveInteger(options.limit, "event retention limit");
179
+ nonNegativeInteger(options.beforeMillis, "event retention cutoff");
180
+ if (typeof options.namespace !== "string" || options.namespace.length === 0) {
181
+ throw new InvalidInput({ message: "Invalid event retention namespace" });
182
+ }
183
+ }).pipe(Effect.flatMap(() => context.gate.withPermit(Effect.sync(() => {
184
+ const candidates = [...context.events.entries()]
185
+ .flatMap(([actorId, actorEvents]) => actorEvents.map((event) => ({ actorId, event })))
186
+ .filter(({ event }) => event.committedAtMillis < options.beforeMillis)
187
+ .filter(({ actorId }) => context.heads.get(actorId)?.address.namespace === options.namespace)
188
+ .sort((left, right) => left.event.committedAtMillis - right.event.committedAtMillis ||
189
+ left.actorId.localeCompare(right.actorId) ||
190
+ (left.event.sequence < right.event.sequence
191
+ ? -1
192
+ : left.event.sequence > right.event.sequence
193
+ ? 1
194
+ : 0))
195
+ .slice(0, options.limit);
196
+ for (const { actorId, event } of candidates) {
197
+ const actorEvents = context.events.get(actorId);
198
+ const index = actorEvents.indexOf(event);
199
+ if (index >= 0)
200
+ actorEvents.splice(index, 1);
201
+ const head = context.heads.get(actorId);
202
+ if (head !== undefined && head.eventFloor < event.sequence)
203
+ head.eventFloor = event.sequence;
204
+ }
205
+ return candidates.length;
206
+ }))));
@@ -0,0 +1,72 @@
1
+ import { Duration, Effect, Result } from "effect";
2
+ import type { Deferred, Semaphore } from "effect";
3
+ import { InvalidInput, type StoreError } from "../../errors/index.js";
4
+ import type { Address, Event, Json, Outcome, OutgoingCommand, WireReceipt } from "../../protocol/index.js";
5
+ import type { EncodedTransition, Ownership, Snapshot } from "../service.js";
6
+ export interface MemoryHead {
7
+ readonly address: Address;
8
+ fence: bigint;
9
+ leaseUntil: number;
10
+ schemaVersion?: number;
11
+ state?: Json;
12
+ revision: bigint;
13
+ eventSequence: bigint;
14
+ eventFloor: bigint;
15
+ }
16
+ export interface MemoryReceipt {
17
+ readonly fingerprint: string;
18
+ readonly receipt: WireReceipt;
19
+ }
20
+ export interface MemoryTimer {
21
+ readonly address: Address;
22
+ readonly timerId: string;
23
+ generation: bigint;
24
+ status: "scheduled" | "cancelled" | "consumed";
25
+ dueAtMillis?: number;
26
+ command?: string;
27
+ payload?: Json;
28
+ }
29
+ export interface MemoryOutbox {
30
+ readonly id: string;
31
+ readonly namespace: string;
32
+ readonly command: OutgoingCommand;
33
+ readonly createdAtMillis: number;
34
+ status: "pending" | "claimed" | "settled";
35
+ attempt: number;
36
+ retryAtMillis: number;
37
+ claimToken?: string;
38
+ claimUntilMillis?: number;
39
+ outcome?: Outcome;
40
+ reasonCode?: string;
41
+ }
42
+ export interface MemoryOwner {
43
+ readonly addressId: string;
44
+ readonly address: Address;
45
+ readonly fence: string;
46
+ readonly lost: Deferred.Deferred<never, StoreError>;
47
+ active: boolean;
48
+ }
49
+ export interface MemoryContext {
50
+ readonly gate: Semaphore.Semaphore;
51
+ readonly heads: Map<string, MemoryHead>;
52
+ readonly receipts: Map<string, Map<string, MemoryReceipt>>;
53
+ readonly events: Map<string, Array<Event<Json> & {
54
+ readonly sequence: bigint;
55
+ }>>;
56
+ readonly timers: Map<string, MemoryTimer>;
57
+ readonly outbox: Map<string, MemoryOutbox>;
58
+ readonly owners: WeakMap<Ownership, MemoryOwner>;
59
+ token: bigint;
60
+ }
61
+ export declare const cloneJson: <A extends Json>(value: A) => A;
62
+ export declare const durationMillis: (input: Duration.Input, name: string) => Effect.Effect<number, InvalidInput>;
63
+ export declare const snapshot: (head: MemoryHead) => Snapshot;
64
+ export declare const receiptValue: (address: Address, commandId: string, revision: bigint, sequence: bigint, committedAtMillis: number, outcome: Outcome) => WireReceipt;
65
+ export declare const cloneReceipt: (receipt: WireReceipt) => WireReceipt;
66
+ export declare const receiptMap: (context: MemoryContext, actorId: string) => Map<string, MemoryReceipt>;
67
+ export declare const applyEvaluation: (context: MemoryContext, head: MemoryHead, input: {
68
+ readonly commandId: string;
69
+ readonly fingerprint: string;
70
+ readonly evaluation: Result.Result<EncodedTransition, Json>;
71
+ readonly consume?: () => void;
72
+ }) => Effect.Effect<WireReceipt, StoreError>;