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,149 @@
1
+ import { Clock, Duration, Effect, Result } from "effect";
2
+ import { InvalidInput, UnsupportedVersion } from "../../errors/index.js";
3
+ import { addressId, canonicalJson, makeCursor, validateId } from "../../protocol/index.js";
4
+ import { nonNegativeInteger, protocolBoundary } from "../sql/model.js";
5
+ export const cloneJson = (value) => JSON.parse(canonicalJson(value));
6
+ export const durationMillis = (input, name) => protocolBoundary(() => {
7
+ const value = Math.ceil(Duration.toMillis(input));
8
+ if (!Number.isSafeInteger(value) || value <= 0) {
9
+ throw new InvalidInput({ message: `${name} must be a finite positive duration` });
10
+ }
11
+ return value;
12
+ });
13
+ export const snapshot = (head) => {
14
+ if (head.schemaVersion === undefined || head.state === undefined) {
15
+ throw new UnsupportedVersion({ actorType: head.address.actorType, version: 0 });
16
+ }
17
+ return {
18
+ schemaVersion: head.schemaVersion,
19
+ state: cloneJson(head.state),
20
+ revision: head.revision.toString(),
21
+ cursor: makeCursor(head.address, head.eventSequence.toString()),
22
+ };
23
+ };
24
+ export const receiptValue = (address, commandId, revision, sequence, committedAtMillis, outcome) => ({
25
+ commandId,
26
+ revision: revision.toString(),
27
+ cursor: makeCursor(address, sequence.toString()),
28
+ committedAtMillis,
29
+ value: cloneJson(outcome),
30
+ });
31
+ export const cloneReceipt = (receipt) => ({
32
+ ...receipt,
33
+ value: cloneJson(receipt.value),
34
+ });
35
+ export const receiptMap = (context, actorId) => {
36
+ let receipts = context.receipts.get(actorId);
37
+ if (receipts === undefined) {
38
+ receipts = new Map();
39
+ context.receipts.set(actorId, receipts);
40
+ }
41
+ return receipts;
42
+ };
43
+ const prepare = (source, transition) => protocolBoundary(() => {
44
+ const encoded = cloneJson(transition);
45
+ if (!Array.isArray(encoded.events) ||
46
+ !Array.isArray(encoded.timers) ||
47
+ !Array.isArray(encoded.outbox)) {
48
+ throw new InvalidInput({ message: "Invalid encoded transition" });
49
+ }
50
+ for (const timer of encoded.timers) {
51
+ validateId(timer.timerId);
52
+ if (timer._tag === "Set") {
53
+ nonNegativeInteger(timer.dueAtMillis, "timer dueAtMillis");
54
+ if (typeof timer.command !== "string" || timer.command.length === 0) {
55
+ throw new InvalidInput({ message: "Invalid timer command" });
56
+ }
57
+ }
58
+ else if (timer._tag !== "Cancel") {
59
+ throw new InvalidInput({ message: "Invalid timer change" });
60
+ }
61
+ }
62
+ for (const command of encoded.outbox) {
63
+ addressId(command.target);
64
+ if (command.target.namespace !== source.namespace ||
65
+ command.target.tenant !== source.tenant ||
66
+ (command.kind !== "command" && command.kind !== "hosted") ||
67
+ typeof command.commandId !== "string" ||
68
+ typeof command.operation !== "string" ||
69
+ command.operation.length === 0 ||
70
+ !Number.isSafeInteger(command.protocolVersion) ||
71
+ command.protocolVersion < 0) {
72
+ throw new InvalidInput({ message: "Invalid outgoing command" });
73
+ }
74
+ validateId(command.commandId);
75
+ }
76
+ return encoded;
77
+ });
78
+ const applyTimers = (context, actorId, address, changes) => {
79
+ for (const change of changes) {
80
+ const id = canonicalJson([actorId, change.timerId]);
81
+ let timer = context.timers.get(id);
82
+ if (timer === undefined) {
83
+ timer = { address, timerId: change.timerId, generation: 0n, status: "cancelled" };
84
+ context.timers.set(id, timer);
85
+ }
86
+ timer.generation += 1n;
87
+ if (change._tag === "Cancel") {
88
+ timer.status = "cancelled";
89
+ timer.dueAtMillis = undefined;
90
+ timer.command = undefined;
91
+ timer.payload = undefined;
92
+ }
93
+ else {
94
+ timer.status = "scheduled";
95
+ timer.dueAtMillis = change.dueAtMillis;
96
+ timer.command = change.command;
97
+ timer.payload = cloneJson(change.payload);
98
+ }
99
+ }
100
+ };
101
+ const applyOutbox = (context, actorId, namespace, commandId, calls, nowMillis) => {
102
+ calls.forEach((command, index) => {
103
+ const id = canonicalJson(["effect-actors/outbox", actorId, commandId, index]);
104
+ context.outbox.set(id, {
105
+ id,
106
+ namespace,
107
+ command: cloneJson(command),
108
+ createdAtMillis: nowMillis,
109
+ status: "pending",
110
+ attempt: 0,
111
+ retryAtMillis: 0,
112
+ });
113
+ });
114
+ };
115
+ export const applyEvaluation = (context, head, input) => Effect.gen(function* () {
116
+ const actorId = addressId(head.address);
117
+ const receipts = receiptMap(context, actorId);
118
+ if (Result.isFailure(input.evaluation)) {
119
+ const failure = input.evaluation.failure;
120
+ const error = yield* protocolBoundary(() => cloneJson(failure));
121
+ const nowMillis = yield* Clock.currentTimeMillis;
122
+ input.consume?.();
123
+ const receipt = receiptValue(head.address, input.commandId, head.revision, head.eventSequence, nowMillis, { _tag: "Failure", error });
124
+ receipts.set(input.commandId, { fingerprint: input.fingerprint, receipt });
125
+ return cloneReceipt(receipt);
126
+ }
127
+ const transition = yield* prepare(head.address, input.evaluation.success);
128
+ const nowMillis = yield* Clock.currentTimeMillis;
129
+ input.consume?.();
130
+ head.revision += 1n;
131
+ head.state = transition.state;
132
+ const actorEvents = context.events.get(actorId) ?? [];
133
+ for (const value of transition.events) {
134
+ head.eventSequence += 1n;
135
+ actorEvents.push({
136
+ sequence: head.eventSequence,
137
+ cursor: makeCursor(head.address, head.eventSequence.toString()),
138
+ commandId: input.commandId,
139
+ committedAtMillis: nowMillis,
140
+ value,
141
+ });
142
+ }
143
+ context.events.set(actorId, actorEvents);
144
+ applyTimers(context, actorId, head.address, transition.timers);
145
+ applyOutbox(context, actorId, head.address.namespace, input.commandId, transition.outbox, nowMillis);
146
+ const receipt = receiptValue(head.address, input.commandId, head.revision, head.eventSequence, nowMillis, { _tag: "Success", value: transition.value });
147
+ receipts.set(input.commandId, { fingerprint: input.fingerprint, receipt });
148
+ return cloneReceipt(receipt);
149
+ });
@@ -0,0 +1,11 @@
1
+ import { Effect } from "effect";
2
+ import type { Duration } from "effect";
3
+ import { OwnershipLost, type StoreError } from "../../errors/index.js";
4
+ import type { Address } from "../../protocol/index.js";
5
+ import type { Ownership } from "../service.js";
6
+ import { type MemoryContext, type MemoryHead, type MemoryOwner } from "./model.js";
7
+ export declare const withOwner: <A, E, R>(context: MemoryContext, ownership: Ownership, use: (owner: MemoryOwner, head: MemoryHead, nowMillis: number) => Effect.Effect<A, E, R>) => Effect.Effect<A, E | OwnershipLost, R>;
8
+ export declare const acquire: (context: MemoryContext, address: Address, options: {
9
+ readonly leaseDuration: Duration.Input;
10
+ readonly renewEvery: Duration.Input;
11
+ }) => Effect.Effect<Ownership, StoreError, import("effect").Scope.Scope>;
@@ -0,0 +1,82 @@
1
+ import { Clock, Deferred, Effect } from "effect";
2
+ import { InvalidInput, OwnershipLost, StorageUnavailable, } from "../../errors/index.js";
3
+ import { addressId } from "../../protocol/index.js";
4
+ import { protocolBoundary } from "../sql/model.js";
5
+ import { durationMillis } from "./model.js";
6
+ const lost = (address) => new OwnershipLost({ actorType: address.actorType });
7
+ const expireOwner = (owner, error) => Effect.gen(function* () {
8
+ owner.active = false;
9
+ yield* Deferred.fail(owner.lost, error);
10
+ });
11
+ const ownerFor = (context, ownership) => {
12
+ const owner = context.owners.get(ownership);
13
+ return owner === undefined ||
14
+ ownership.address !== owner.address ||
15
+ ownership.fence !== owner.fence
16
+ ? Effect.fail(lost(ownership.address))
17
+ : Effect.succeed(owner);
18
+ };
19
+ export const withOwner = (context, ownership, use) => Effect.flatMap(ownerFor(context, ownership), (owner) => context.gate.withPermit(Effect.gen(function* () {
20
+ const nowMillis = yield* Clock.currentTimeMillis;
21
+ const head = context.heads.get(owner.addressId);
22
+ if (!owner.active ||
23
+ head === undefined ||
24
+ head.fence.toString() !== owner.fence ||
25
+ head.leaseUntil <= nowMillis) {
26
+ const error = lost(owner.address);
27
+ yield* expireOwner(owner, error);
28
+ return yield* Effect.fail(error);
29
+ }
30
+ return yield* use(owner, head, nowMillis);
31
+ })));
32
+ export const acquire = (context, address, options) => Effect.gen(function* () {
33
+ const leaseMillis = yield* durationMillis(options.leaseDuration, "leaseDuration");
34
+ const renewMillis = yield* durationMillis(options.renewEvery, "renewEvery");
35
+ if (renewMillis >= leaseMillis) {
36
+ return yield* Effect.fail(new InvalidInput({ message: "renewEvery must be shorter than leaseDuration" }));
37
+ }
38
+ const actorId = yield* protocolBoundary(() => addressId(address));
39
+ const fence = yield* context.gate.withPermit(Effect.gen(function* () {
40
+ const nowMillis = yield* Clock.currentTimeMillis;
41
+ let head = context.heads.get(actorId);
42
+ if (head === undefined) {
43
+ head = {
44
+ address,
45
+ fence: 0n,
46
+ leaseUntil: 0,
47
+ revision: 0n,
48
+ eventSequence: 0n,
49
+ eventFloor: 0n,
50
+ };
51
+ context.heads.set(actorId, head);
52
+ }
53
+ if (head.leaseUntil > nowMillis)
54
+ return yield* Effect.fail(lost(address));
55
+ head.fence += 1n;
56
+ head.leaseUntil = nowMillis + leaseMillis;
57
+ return head.fence.toString();
58
+ }));
59
+ const deferred = yield* Deferred.make();
60
+ const state = { addressId: actorId, address, fence, lost: deferred, active: true };
61
+ const ownership = Object.freeze({ address, fence, lost: Deferred.await(deferred) });
62
+ context.owners.set(ownership, state);
63
+ yield* Effect.addFinalizer(() => context.gate.withPermit(Effect.gen(function* () {
64
+ if (!state.active)
65
+ return;
66
+ yield* expireOwner(state, lost(state.address));
67
+ const head = context.heads.get(state.addressId);
68
+ if (head !== undefined && head.fence.toString() === state.fence)
69
+ head.leaseUntil = 0;
70
+ })));
71
+ yield* Effect.forever(Effect.sleep(renewMillis).pipe(Effect.andThen(context.gate.withPermit(Effect.gen(function* () {
72
+ const nowMillis = yield* Clock.currentTimeMillis;
73
+ const head = context.heads.get(state.addressId);
74
+ if (!state.active ||
75
+ head === undefined ||
76
+ head.fence.toString() !== state.fence ||
77
+ head.leaseUntil <= nowMillis)
78
+ return yield* Effect.fail(lost(state.address));
79
+ head.leaseUntil = nowMillis + leaseMillis;
80
+ }))))).pipe(Effect.catch((error) => expireOwner(state, error)), Effect.catchDefect(() => expireOwner(state, new StorageUnavailable({ retryable: false }))), Effect.forkScoped);
81
+ return ownership;
82
+ });
@@ -0,0 +1,3 @@
1
+ import { Effect } from "effect";
2
+ import type { Service } from "../service.js";
3
+ export declare const makeMemory: Effect.Effect<Service>;
@@ -0,0 +1,32 @@
1
+ import { Effect, Semaphore } from "effect";
2
+ import { claimOutbox, collectEvents, dueTimers, events, fireTimer, receipt, retryOutbox, settleOutbox, } from "./background.js";
3
+ import { acquire } from "./ownership.js";
4
+ import { initialize, query, transact } from "./state.js";
5
+ export const makeMemory = Effect.gen(function* () {
6
+ const gate = yield* Semaphore.make(1);
7
+ const context = {
8
+ gate,
9
+ heads: new Map(),
10
+ receipts: new Map(),
11
+ events: new Map(),
12
+ timers: new Map(),
13
+ outbox: new Map(),
14
+ owners: new WeakMap(),
15
+ token: 0n,
16
+ };
17
+ const service = {
18
+ acquire: (address, options) => acquire(context, address, options),
19
+ initialize: (owner, options) => initialize(context, owner, options),
20
+ transact: (owner, input, evaluate) => transact(context, owner, input, evaluate),
21
+ query: (owner, evaluate) => query(context, owner, evaluate),
22
+ receipt: (address, commandId) => receipt(context, address, commandId),
23
+ events: (address, options) => events(context, address, options),
24
+ claimOutbox: (options) => claimOutbox(context, options),
25
+ settleOutbox: (claim, outcome) => settleOutbox(context, claim, outcome),
26
+ retryOutbox: (claim, options) => retryOutbox(context, claim, options),
27
+ dueTimers: (options) => dueTimers(context, options),
28
+ fireTimer: (owner, candidate, evaluate) => fireTimer(context, owner, candidate, evaluate),
29
+ collectEvents: (options) => collectEvents(context, options),
30
+ };
31
+ return service;
32
+ });
@@ -0,0 +1,13 @@
1
+ import { Effect, Result } from "effect";
2
+ import { CommandConflict, type StoreError } from "../../errors/index.js";
3
+ import type { Invocation, Json, WireReceipt } from "../../protocol/index.js";
4
+ import type { Migration } from "../../actor/definition.js";
5
+ import type { EncodedTransition, Ownership, Snapshot, Turn } from "../service.js";
6
+ import { type MemoryContext } from "./model.js";
7
+ export declare const initialize: (context: MemoryContext, ownership: Ownership, options: {
8
+ readonly currentVersion: number;
9
+ readonly initial: Json;
10
+ readonly migrations: ReadonlyArray<Migration>;
11
+ }) => Effect.Effect<Snapshot, StoreError>;
12
+ export declare const transact: (context: MemoryContext, ownership: Ownership, input: Invocation, evaluate: (turn: Turn) => Result.Result<EncodedTransition, Json>) => Effect.Effect<WireReceipt, StoreError | CommandConflict>;
13
+ export declare const query: <A>(context: MemoryContext, ownership: Ownership, evaluate: (snapshot: Snapshot) => A) => Effect.Effect<A, StoreError>;
@@ -0,0 +1,109 @@
1
+ import { Effect, Result } from "effect";
2
+ import { CommandConflict, InvalidInput, MigrationRejected, UnsupportedVersion, } from "../../errors/index.js";
3
+ import { addressId, validateId } from "../../protocol/index.js";
4
+ import { decimalInput, normalizeCallbackError, positiveInteger, protocolBoundary, } from "../sql/model.js";
5
+ import { applyEvaluation, cloneJson, cloneReceipt, receiptMap, receiptValue, snapshot, } from "./model.js";
6
+ import { withOwner } from "./ownership.js";
7
+ const safeSnapshot = (head) => Effect.try({
8
+ try: () => snapshot(head),
9
+ catch: normalizeCallbackError,
10
+ });
11
+ export const initialize = (context, ownership, options) => withOwner(context, ownership, (_owner, head) => Effect.gen(function* () {
12
+ const currentVersion = yield* protocolBoundary(() => positiveInteger(options.currentVersion, "currentVersion"));
13
+ const initial = yield* protocolBoundary(() => cloneJson(options.initial));
14
+ if (head.schemaVersion === undefined) {
15
+ head.schemaVersion = currentVersion;
16
+ head.state = initial;
17
+ return yield* safeSnapshot(head);
18
+ }
19
+ if (head.schemaVersion > currentVersion) {
20
+ return yield* Effect.fail(new UnsupportedVersion({
21
+ actorType: head.address.actorType,
22
+ version: head.schemaVersion,
23
+ }));
24
+ }
25
+ if (head.schemaVersion === currentVersion)
26
+ return yield* safeSnapshot(head);
27
+ let version = head.schemaVersion;
28
+ let encoded = cloneJson(head.state);
29
+ while (version < currentVersion) {
30
+ const candidates = options.migrations.filter((migration) => migration.fromVersion === version);
31
+ if (candidates.length !== 1) {
32
+ return yield* Effect.fail(new MigrationRejected({
33
+ fromVersion: version,
34
+ toVersion: currentVersion,
35
+ message: "Migration chain is incomplete or ambiguous",
36
+ }));
37
+ }
38
+ const migration = candidates[0];
39
+ if (!Number.isSafeInteger(migration.toVersion) ||
40
+ migration.toVersion <= version ||
41
+ migration.toVersion > currentVersion) {
42
+ return yield* Effect.fail(new MigrationRejected({
43
+ fromVersion: migration.fromVersion,
44
+ toVersion: migration.toVersion,
45
+ message: "Migration chain does not advance toward the current version",
46
+ }));
47
+ }
48
+ const result = yield* Effect.try({
49
+ try: () => migration.migrate(encoded),
50
+ catch: normalizeCallbackError,
51
+ });
52
+ if (Result.isFailure(result))
53
+ return yield* Effect.fail(result.failure);
54
+ encoded = yield* protocolBoundary(() => cloneJson(result.success));
55
+ version = migration.toVersion;
56
+ }
57
+ head.schemaVersion = currentVersion;
58
+ head.state = encoded;
59
+ return yield* safeSnapshot(head);
60
+ }));
61
+ export const transact = (context, ownership, input, evaluate) => withOwner(context, ownership, (owner, head, nowMillis) => Effect.gen(function* () {
62
+ const inputAddress = yield* protocolBoundary(() => addressId(input.address));
63
+ if (inputAddress !== owner.addressId) {
64
+ return yield* Effect.fail(new InvalidInput({ message: "Invocation does not match its owner" }));
65
+ }
66
+ yield* protocolBoundary(() => validateId(input.commandId));
67
+ if (typeof input.fingerprint !== "string")
68
+ return yield* Effect.fail(new InvalidInput({ message: "Invalid command identity" }));
69
+ const receipts = receiptMap(context, owner.addressId);
70
+ const duplicate = receipts.get(input.commandId);
71
+ if (duplicate !== undefined) {
72
+ if (duplicate.fingerprint !== input.fingerprint) {
73
+ return yield* Effect.fail(new CommandConflict({ commandId: input.commandId }));
74
+ }
75
+ return cloneReceipt(duplicate.receipt);
76
+ }
77
+ const current = yield* safeSnapshot(head);
78
+ if (input.expectedRevision !== undefined) {
79
+ yield* protocolBoundary(() => decimalInput(input.expectedRevision, "expectedRevision"));
80
+ if (input.expectedRevision !== current.revision) {
81
+ const receipt = receiptValue(head.address, input.commandId, head.revision, head.eventSequence, nowMillis, {
82
+ _tag: "Failure",
83
+ error: {
84
+ _tag: "effect-actors/RevisionConflict",
85
+ expected: input.expectedRevision,
86
+ actual: current.revision,
87
+ },
88
+ });
89
+ receipts.set(input.commandId, { fingerprint: input.fingerprint, receipt });
90
+ return cloneReceipt(receipt);
91
+ }
92
+ }
93
+ const evaluation = yield* Effect.try({
94
+ try: () => evaluate({ snapshot: current, nowMillis }),
95
+ catch: normalizeCallbackError,
96
+ });
97
+ return yield* applyEvaluation(context, head, {
98
+ commandId: input.commandId,
99
+ fingerprint: input.fingerprint,
100
+ evaluation,
101
+ });
102
+ }));
103
+ export const query = (context, ownership, evaluate) => withOwner(context, ownership, (_owner, head) => Effect.gen(function* () {
104
+ const current = yield* safeSnapshot(head);
105
+ return yield* Effect.try({
106
+ try: () => evaluate(current),
107
+ catch: normalizeCallbackError,
108
+ });
109
+ }));
@@ -0,0 +1,89 @@
1
+ import { Context, Layer } from "effect";
2
+ import type { Crypto, Duration, Effect, Option, Result, Scope } from "effect";
3
+ import type { SqlClient } from "effect/unstable/sql";
4
+ import type { Migration } from "../actor/definition.js";
5
+ import type { CommandConflict, CursorExpired, StoreError } from "../errors/index.js";
6
+ import type { Address, EventPage, Invocation, Json, OutgoingCommand, Outcome, TimerChange, WireReceipt } from "../protocol/index.js";
7
+ import { migrate as migrateSql } from "./sql/schema.js";
8
+ export interface Ownership {
9
+ readonly address: Address;
10
+ readonly fence: string;
11
+ readonly lost: Effect.Effect<never, StoreError>;
12
+ }
13
+ export interface Snapshot {
14
+ readonly schemaVersion: number;
15
+ readonly state: Json;
16
+ readonly revision: string;
17
+ readonly cursor: string;
18
+ }
19
+ export interface EncodedTransition {
20
+ readonly state: Json;
21
+ readonly value: Json;
22
+ readonly events: ReadonlyArray<Json>;
23
+ readonly timers: ReadonlyArray<TimerChange>;
24
+ readonly outbox: ReadonlyArray<OutgoingCommand>;
25
+ }
26
+ export interface Turn {
27
+ readonly snapshot: Snapshot;
28
+ readonly nowMillis: number;
29
+ }
30
+ export interface OutboxClaim {
31
+ readonly id: string;
32
+ readonly claimToken: string;
33
+ readonly attempt: number;
34
+ readonly command: OutgoingCommand;
35
+ }
36
+ export interface TimerCandidate {
37
+ readonly address: Address;
38
+ readonly timerId: string;
39
+ readonly generation: string;
40
+ readonly dueAtMillis: number;
41
+ }
42
+ export interface Service {
43
+ readonly acquire: (address: Address, options: {
44
+ readonly leaseDuration: Duration.Input;
45
+ readonly renewEvery: Duration.Input;
46
+ }) => Effect.Effect<Ownership, StoreError, Scope.Scope>;
47
+ readonly initialize: (owner: Ownership, options: {
48
+ readonly currentVersion: number;
49
+ readonly initial: Json;
50
+ readonly migrations: ReadonlyArray<Migration>;
51
+ }) => Effect.Effect<Snapshot, StoreError>;
52
+ readonly transact: (owner: Ownership, input: Invocation, evaluate: (turn: Turn) => Result.Result<EncodedTransition, Json>) => Effect.Effect<WireReceipt, StoreError | CommandConflict>;
53
+ readonly query: <A>(owner: Ownership, evaluate: (snapshot: Snapshot) => A) => Effect.Effect<A, StoreError>;
54
+ readonly receipt: (address: Address, commandId: string) => Effect.Effect<Option.Option<WireReceipt>, StoreError>;
55
+ readonly events: (address: Address, options: {
56
+ readonly after: string;
57
+ readonly limit: number;
58
+ }) => Effect.Effect<EventPage, StoreError | CursorExpired>;
59
+ readonly claimOutbox: (options: {
60
+ readonly namespace: string;
61
+ readonly limit: number;
62
+ readonly claimDuration: Duration.Input;
63
+ }) => Effect.Effect<ReadonlyArray<OutboxClaim>, StoreError>;
64
+ readonly settleOutbox: (claim: OutboxClaim, outcome: Outcome) => Effect.Effect<void, StoreError>;
65
+ readonly retryOutbox: (claim: OutboxClaim, options: {
66
+ readonly retryAtMillis: number;
67
+ readonly reasonCode: string;
68
+ }) => Effect.Effect<void, StoreError>;
69
+ readonly dueTimers: (options: {
70
+ readonly namespace: string;
71
+ readonly limit: number;
72
+ }) => Effect.Effect<ReadonlyArray<TimerCandidate>, StoreError>;
73
+ readonly fireTimer: (owner: Ownership, candidate: TimerCandidate, evaluate: (command: string, payload: Json, turn: Turn) => Result.Result<EncodedTransition, Json>) => Effect.Effect<Option.Option<WireReceipt>, StoreError>;
74
+ readonly collectEvents: (options: {
75
+ readonly namespace: string;
76
+ readonly beforeMillis: number;
77
+ readonly limit: number;
78
+ }) => Effect.Effect<number, StoreError>;
79
+ }
80
+ declare const Store_base: Context.ServiceClass<Store, "effect-actors/Store", Service>;
81
+ export declare class Store extends Store_base {
82
+ }
83
+ export interface SqlOptions {
84
+ readonly tablePrefix: string;
85
+ }
86
+ export declare const layerSql: (options: SqlOptions) => Layer.Layer<Store, StoreError, SqlClient.SqlClient | Crypto.Crypto>;
87
+ export declare const migrate: typeof migrateSql;
88
+ export declare const layerMemory: Layer.Layer<Store>;
89
+ export {};
@@ -0,0 +1,9 @@
1
+ import { Context, Layer } from "effect";
2
+ import { makeMemory } from "./memory/service.js";
3
+ import { migrate as migrateSql } from "./sql/schema.js";
4
+ import { make as makeSql } from "./sql/service.js";
5
+ export class Store extends Context.Service()("effect-actors/Store") {
6
+ }
7
+ export const layerSql = (options) => Layer.effect(Store, makeSql(options));
8
+ export const migrate = migrateSql;
9
+ export const layerMemory = Layer.effect(Store, makeMemory);
@@ -0,0 +1,13 @@
1
+ import { Effect } from "effect";
2
+ import { CursorExpired, type StoreError } from "../../errors/index.js";
3
+ import type { Address, EventPage } from "../../protocol/index.js";
4
+ import type { SqlStoreContext } from "./ownership.js";
5
+ export declare const events: (context: SqlStoreContext, address: Address, options: {
6
+ readonly after: string;
7
+ readonly limit: number;
8
+ }) => Effect.Effect<EventPage, StoreError | CursorExpired>;
9
+ export declare const collectEvents: (context: SqlStoreContext, options: {
10
+ readonly namespace: string;
11
+ readonly beforeMillis: number;
12
+ readonly limit: number;
13
+ }) => Effect.Effect<number, StoreError>;
@@ -0,0 +1,89 @@
1
+ import { Effect } from "effect";
2
+ import { CursorExpired, InvalidInput, StorageUnavailable, } from "../../errors/index.js";
3
+ import { addressId, makeCursor, readCursor } from "../../protocol/index.js";
4
+ import { decimal, decodeJson, forUpdate, integer, nonNegativeInteger, normalizeSqlError, positiveInteger, protocolBoundary, } from "./model.js";
5
+ export const events = (context, address, options) => Effect.gen(function* () {
6
+ const actorId = yield* protocolBoundary(() => addressId(address));
7
+ const after = yield* protocolBoundary(() => readCursor(address, options.after));
8
+ const limit = yield* protocolBoundary(() => positiveInteger(options.limit, "event page limit"));
9
+ const program = Effect.gen(function* () {
10
+ const heads = yield* context.sql `
11
+ SELECT event_sequence, event_floor
12
+ FROM ${context.sql(context.tables.heads)}
13
+ WHERE address = ${actorId}
14
+ ${forUpdate(context.sql, context.dialect)}
15
+ `;
16
+ const highwater = heads[0] === undefined ? "0" : decimal(heads[0].event_sequence);
17
+ const floor = heads[0] === undefined ? "0" : decimal(heads[0].event_floor);
18
+ if (BigInt(after) < BigInt(floor)) {
19
+ return yield* Effect.fail(new CursorExpired({ message: "Event cursor precedes retained history" }));
20
+ }
21
+ if (BigInt(after) > BigInt(highwater)) {
22
+ return yield* Effect.fail(new InvalidInput({ message: "Event cursor is ahead of the actor" }));
23
+ }
24
+ const rows = yield* context.sql `
25
+ SELECT sequence, command_id, payload, committed_at_ms
26
+ FROM ${context.sql(context.tables.events)}
27
+ WHERE address = ${actorId} AND sequence > ${after}
28
+ ORDER BY sequence
29
+ LIMIT ${limit + 1}
30
+ `;
31
+ const selected = rows.slice(0, limit);
32
+ const decoded = yield* protocolBoundary(() => selected.map((row) => {
33
+ if (typeof row.command_id !== "string")
34
+ throw new Error("Invalid event row");
35
+ const sequence = decimal(row.sequence);
36
+ return {
37
+ cursor: makeCursor(address, sequence),
38
+ commandId: row.command_id,
39
+ committedAtMillis: integer(row.committed_at_ms),
40
+ value: decodeJson(row.payload),
41
+ };
42
+ })).pipe(Effect.mapError(() => new StorageUnavailable({ retryable: false })));
43
+ const nextSequence = selected.length === 0 ? after : decimal(selected[selected.length - 1].sequence);
44
+ return {
45
+ events: decoded,
46
+ nextCursor: yield* protocolBoundary(() => makeCursor(address, nextSequence)),
47
+ hasMore: rows.length > limit,
48
+ };
49
+ });
50
+ return yield* context.sql.withTransaction(program);
51
+ }).pipe(Effect.mapError(normalizeSqlError), Effect.catchDefect(() => Effect.fail(new StorageUnavailable({ retryable: true }))));
52
+ export const collectEvents = (context, options) => Effect.gen(function* () {
53
+ const limit = yield* protocolBoundary(() => positiveInteger(options.limit, "event retention limit"));
54
+ yield* protocolBoundary(() => nonNegativeInteger(options.beforeMillis, "event retention cutoff"));
55
+ if (typeof options.namespace !== "string" || options.namespace.length === 0) {
56
+ return yield* Effect.fail(new InvalidInput({ message: "Invalid event retention namespace" }));
57
+ }
58
+ const program = Effect.gen(function* () {
59
+ const rows = yield* context.sql `
60
+ SELECT address, sequence
61
+ FROM ${context.sql(context.tables.events)}
62
+ WHERE namespace = ${options.namespace} AND committed_at_ms < ${options.beforeMillis}
63
+ ORDER BY committed_at_ms, address, sequence
64
+ LIMIT ${limit}
65
+ ${forUpdate(context.sql, context.dialect)}
66
+ `;
67
+ let deleted = 0;
68
+ for (const row of rows) {
69
+ if (typeof row.address !== "string")
70
+ throw new Error("Invalid event row");
71
+ const sequence = decimal(row.sequence);
72
+ const removed = yield* context.sql `
73
+ DELETE FROM ${context.sql(context.tables.events)}
74
+ WHERE address = ${row.address} AND sequence = ${sequence}
75
+ RETURNING sequence
76
+ `;
77
+ if (removed.length === 0)
78
+ continue;
79
+ yield* context.sql `
80
+ UPDATE ${context.sql(context.tables.heads)}
81
+ SET event_floor = CASE WHEN event_floor < ${sequence} THEN ${sequence} ELSE event_floor END
82
+ WHERE address = ${row.address}
83
+ `;
84
+ deleted += 1;
85
+ }
86
+ return deleted;
87
+ });
88
+ return yield* context.sql.withTransaction(program);
89
+ }).pipe(Effect.mapError(normalizeSqlError), Effect.catchDefect(() => Effect.fail(new StorageUnavailable({ retryable: true }))));
@@ -0,0 +1,39 @@
1
+ import { Effect } from "effect";
2
+ import type { SqlClient } from "effect/unstable/sql";
3
+ import { CommandConflict, CursorExpired, InvalidInput, MigrationRejected, OwnershipLost, StorageUnavailable, UnsupportedVersion } from "../../errors/index.js";
4
+ import type { Address, Json } from "../../protocol/index.js";
5
+ export type Dialect = "sqlite" | "pg";
6
+ export interface Tables {
7
+ readonly schema: string;
8
+ readonly heads: string;
9
+ readonly receipts: string;
10
+ readonly events: string;
11
+ readonly timers: string;
12
+ readonly outbox: string;
13
+ }
14
+ export declare const tablesFor: (tablePrefix: string) => Tables;
15
+ export declare const validateTablePrefix: (tablePrefix: string) => Tables;
16
+ export declare const dialectOf: (sql: SqlClient.SqlClient) => Dialect;
17
+ export declare const databaseNow: (sql: SqlClient.SqlClient, dialect: Dialect) => import("effect/unstable/sql/Statement").Fragment;
18
+ export declare const forUpdate: (sql: SqlClient.SqlClient, dialect: Dialect) => import("effect/unstable/sql/Statement").Fragment;
19
+ export declare const forUpdateSkipLocked: (sql: SqlClient.SqlClient, dialect: Dialect) => import("effect/unstable/sql/Statement").Fragment;
20
+ export declare const storageUnavailable: (retryable: boolean) => StorageUnavailable;
21
+ export declare const isFrameworkError: (error: unknown) => error is InvalidInput | CommandConflict | CursorExpired | MigrationRejected | OwnershipLost | StorageUnavailable | UnsupportedVersion;
22
+ type FrameworkError = InvalidInput | CommandConflict | CursorExpired | MigrationRejected | OwnershipLost | StorageUnavailable | UnsupportedVersion;
23
+ export declare const normalizeSqlError: <E>(error: E) => Extract<E, FrameworkError> | StorageUnavailable;
24
+ export declare const normalizeCallbackError: (error: unknown) => InvalidInput | MigrationRejected | OwnershipLost | StorageUnavailable | UnsupportedVersion;
25
+ export declare const protectSql: <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, StorageUnavailable | Extract<E, FrameworkError>, R>;
26
+ export declare const protocolBoundary: <A>(evaluate: () => A) => Effect.Effect<A, InvalidInput>;
27
+ export declare const encodeJson: (value: Json) => string;
28
+ export declare const decodeJson: (value: unknown) => Json;
29
+ export declare const encodeAddress: (address: Address) => {
30
+ readonly id: string;
31
+ readonly json: string;
32
+ };
33
+ export declare const decodeAddress: (json: unknown, expectedId: string) => Address;
34
+ export declare const decimal: (value: unknown) => string;
35
+ export declare const integer: (value: unknown) => number;
36
+ export declare const positiveInteger: (value: number, name: string) => number;
37
+ export declare const nonNegativeInteger: (value: number, name: string) => number;
38
+ export declare const decimalInput: (value: string, name: string) => string;
39
+ export {};