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.
- package/LICENSE +21 -0
- package/README.md +467 -0
- package/dist/actor/client.d.ts +11 -0
- package/dist/actor/client.js +68 -0
- package/dist/actor/definition.d.ts +77 -0
- package/dist/actor/definition.js +110 -0
- package/dist/actor/state/codec.d.ts +10 -0
- package/dist/actor/state/codec.js +117 -0
- package/dist/actor/state/compiled.d.ts +17 -0
- package/dist/actor/state/compiled.js +1 -0
- package/dist/actor/state/layer.d.ts +7 -0
- package/dist/actor/state/layer.js +167 -0
- package/dist/client/http/errors.d.ts +30 -0
- package/dist/client/http/errors.js +72 -0
- package/dist/client/http/events.d.ts +10 -0
- package/dist/client/http/events.js +30 -0
- package/dist/client/http/layer.d.ts +5 -0
- package/dist/client/http/layer.js +133 -0
- package/dist/client/http.d.ts +1 -0
- package/dist/client/http.js +1 -0
- package/dist/client/outcome.d.ts +4 -0
- package/dist/client/outcome.js +10 -0
- package/dist/client/service.d.ts +28 -0
- package/dist/client/service.js +4 -0
- package/dist/errors/index.d.ts +93 -0
- package/dist/errors/index.js +93 -0
- package/dist/gateway/access.d.ts +11 -0
- package/dist/gateway/access.js +38 -0
- package/dist/gateway/dispatch.d.ts +13 -0
- package/dist/gateway/dispatch.js +45 -0
- package/dist/gateway/fault.d.ts +32 -0
- package/dist/gateway/fault.js +85 -0
- package/dist/gateway/http/events.d.ts +8 -0
- package/dist/gateway/http/events.js +32 -0
- package/dist/gateway/http/response.d.ts +11 -0
- package/dist/gateway/http/response.js +57 -0
- package/dist/gateway/http/routes.d.ts +6 -0
- package/dist/gateway/http/routes.js +184 -0
- package/dist/gateway/http/validation.d.ts +19 -0
- package/dist/gateway/http/validation.js +92 -0
- package/dist/gateway/options.d.ts +31 -0
- package/dist/gateway/options.js +107 -0
- package/dist/gateway/security.d.ts +25 -0
- package/dist/gateway/security.js +4 -0
- package/dist/gateway/service.d.ts +113 -0
- package/dist/gateway/service.js +5 -0
- package/dist/gateway/websocket/operations.d.ts +20 -0
- package/dist/gateway/websocket/operations.js +96 -0
- package/dist/gateway/websocket.d.ts +8 -0
- package/dist/gateway/websocket.js +96 -0
- package/dist/gateway/wire.d.ts +172 -0
- package/dist/gateway/wire.js +102 -0
- package/dist/host/client.d.ts +9 -0
- package/dist/host/client.js +46 -0
- package/dist/host/definition.d.ts +71 -0
- package/dist/host/definition.js +68 -0
- package/dist/host/entity.d.ts +9 -0
- package/dist/host/entity.js +302 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +9 -0
- package/dist/protocol/encoding.d.ts +29 -0
- package/dist/protocol/encoding.js +165 -0
- package/dist/protocol/index.d.ts +90 -0
- package/dist/protocol/index.js +1 -0
- package/dist/protocol/schemas.d.ts +49 -0
- package/dist/protocol/schemas.js +30 -0
- package/dist/runtime/cluster/address.d.ts +3 -0
- package/dist/runtime/cluster/address.js +21 -0
- package/dist/runtime/cluster/client.d.ts +9 -0
- package/dist/runtime/cluster/client.js +131 -0
- package/dist/runtime/cluster/protocol.d.ts +104 -0
- package/dist/runtime/cluster/protocol.js +69 -0
- package/dist/runtime/configuration.d.ts +4 -0
- package/dist/runtime/configuration.js +34 -0
- package/dist/runtime/dispatchers/layer.d.ts +6 -0
- package/dist/runtime/dispatchers/layer.js +53 -0
- package/dist/runtime/dispatchers/outbox.d.ts +4 -0
- package/dist/runtime/dispatchers/outbox.js +57 -0
- package/dist/runtime/layer.d.ts +6 -0
- package/dist/runtime/layer.js +43 -0
- package/dist/runtime/service.d.ts +40 -0
- package/dist/runtime/service.js +5 -0
- package/dist/store/memory/background.d.ts +39 -0
- package/dist/store/memory/background.js +206 -0
- package/dist/store/memory/model.d.ts +72 -0
- package/dist/store/memory/model.js +149 -0
- package/dist/store/memory/ownership.d.ts +11 -0
- package/dist/store/memory/ownership.js +82 -0
- package/dist/store/memory/service.d.ts +3 -0
- package/dist/store/memory/service.js +32 -0
- package/dist/store/memory/state.d.ts +13 -0
- package/dist/store/memory/state.js +109 -0
- package/dist/store/service.d.ts +89 -0
- package/dist/store/service.js +9 -0
- package/dist/store/sql/events.d.ts +13 -0
- package/dist/store/sql/events.js +89 -0
- package/dist/store/sql/model.d.ts +39 -0
- package/dist/store/sql/model.js +126 -0
- package/dist/store/sql/outbox.d.ts +15 -0
- package/dist/store/sql/outbox.js +130 -0
- package/dist/store/sql/ownership.d.ts +44 -0
- package/dist/store/sql/ownership.js +130 -0
- package/dist/store/sql/receipts.d.ts +25 -0
- package/dist/store/sql/receipts.js +93 -0
- package/dist/store/sql/schema.d.ts +9 -0
- package/dist/store/sql/schema.js +124 -0
- package/dist/store/sql/service.d.ts +5 -0
- package/dist/store/sql/service.js +42 -0
- package/dist/store/sql/state.d.ts +13 -0
- package/dist/store/sql/state.js +109 -0
- package/dist/store/sql/timers.d.ts +10 -0
- package/dist/store/sql/timers.js +90 -0
- package/dist/store/sql/transaction.d.ts +6 -0
- package/dist/store/sql/transaction.js +57 -0
- package/dist/store/sql/transition.d.ts +15 -0
- package/dist/store/sql/transition.js +154 -0
- package/dist/testing/index.d.ts +6 -0
- package/dist/testing/index.js +27 -0
- package/package.json +80 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { CommandConflict, CursorExpired, InvalidInput, MigrationRejected, OwnershipLost, StorageUnavailable, UnsupportedVersion, } from "../../errors/index.js";
|
|
3
|
+
import { addressId, canonicalJson } from "../../protocol/index.js";
|
|
4
|
+
export const tablesFor = (tablePrefix) => ({
|
|
5
|
+
schema: `${tablePrefix}_store_schema`,
|
|
6
|
+
heads: `${tablePrefix}_actor_heads`,
|
|
7
|
+
receipts: `${tablePrefix}_receipts`,
|
|
8
|
+
events: `${tablePrefix}_events`,
|
|
9
|
+
timers: `${tablePrefix}_timers`,
|
|
10
|
+
outbox: `${tablePrefix}_outbox`,
|
|
11
|
+
});
|
|
12
|
+
export const validateTablePrefix = (tablePrefix) => {
|
|
13
|
+
if (!/^[a-z_][a-z0-9_]{0,31}$/.test(tablePrefix)) {
|
|
14
|
+
throw new InvalidInput({ message: "Invalid actor store table prefix" });
|
|
15
|
+
}
|
|
16
|
+
return tablesFor(tablePrefix);
|
|
17
|
+
};
|
|
18
|
+
export const dialectOf = (sql) => {
|
|
19
|
+
const dialect = sql.onDialectOrElse({
|
|
20
|
+
sqlite: () => "sqlite",
|
|
21
|
+
pg: () => "pg",
|
|
22
|
+
orElse: () => undefined,
|
|
23
|
+
});
|
|
24
|
+
if (dialect === undefined) {
|
|
25
|
+
throw new InvalidInput({ message: "Actor store supports only SQLite and PostgreSQL" });
|
|
26
|
+
}
|
|
27
|
+
return dialect;
|
|
28
|
+
};
|
|
29
|
+
export const databaseNow = (sql, dialect) => dialect === "pg"
|
|
30
|
+
? sql.literal("CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)")
|
|
31
|
+
: sql.literal("CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER)");
|
|
32
|
+
export const forUpdate = (sql, dialect) => sql.literal(dialect === "pg" ? " FOR UPDATE" : "");
|
|
33
|
+
export const forUpdateSkipLocked = (sql, dialect) => sql.literal(dialect === "pg" ? " FOR UPDATE SKIP LOCKED" : "");
|
|
34
|
+
export const storageUnavailable = (retryable) => new StorageUnavailable({ retryable });
|
|
35
|
+
const frameworkTags = new Set([
|
|
36
|
+
"effect-actors/InvalidInput",
|
|
37
|
+
"effect-actors/CommandConflict",
|
|
38
|
+
"effect-actors/CursorExpired",
|
|
39
|
+
"effect-actors/MigrationRejected",
|
|
40
|
+
"effect-actors/OwnershipLost",
|
|
41
|
+
"effect-actors/StorageUnavailable",
|
|
42
|
+
"effect-actors/UnsupportedVersion",
|
|
43
|
+
]);
|
|
44
|
+
export const isFrameworkError = (error) => typeof error === "object" &&
|
|
45
|
+
error !== null &&
|
|
46
|
+
"_tag" in error &&
|
|
47
|
+
typeof error._tag === "string" &&
|
|
48
|
+
frameworkTags.has(error._tag);
|
|
49
|
+
export const normalizeSqlError = (error) => isFrameworkError(error) ? error : storageUnavailable(true);
|
|
50
|
+
export const normalizeCallbackError = (error) => {
|
|
51
|
+
if (error instanceof InvalidInput ||
|
|
52
|
+
error instanceof MigrationRejected ||
|
|
53
|
+
error instanceof OwnershipLost ||
|
|
54
|
+
error instanceof StorageUnavailable ||
|
|
55
|
+
error instanceof UnsupportedVersion)
|
|
56
|
+
return error;
|
|
57
|
+
return storageUnavailable(false);
|
|
58
|
+
};
|
|
59
|
+
export const protectSql = (effect) => effect.pipe(Effect.mapError(normalizeSqlError), Effect.catchDefect(() => Effect.fail(storageUnavailable(true))));
|
|
60
|
+
export const protocolBoundary = (evaluate) => Effect.try({
|
|
61
|
+
try: evaluate,
|
|
62
|
+
catch: (error) => error instanceof InvalidInput
|
|
63
|
+
? error
|
|
64
|
+
: new InvalidInput({ message: "Invalid actor store value" }),
|
|
65
|
+
});
|
|
66
|
+
export const encodeJson = (value) => canonicalJson(value);
|
|
67
|
+
export const decodeJson = (value) => {
|
|
68
|
+
if (typeof value !== "string")
|
|
69
|
+
throw new Error("Invalid stored JSON");
|
|
70
|
+
const parsed = JSON.parse(value);
|
|
71
|
+
canonicalJson(parsed);
|
|
72
|
+
return parsed;
|
|
73
|
+
};
|
|
74
|
+
export const encodeAddress = (address) => ({
|
|
75
|
+
id: addressId(address),
|
|
76
|
+
json: canonicalJson(address),
|
|
77
|
+
});
|
|
78
|
+
export const decodeAddress = (json, expectedId) => {
|
|
79
|
+
const value = decodeJson(json);
|
|
80
|
+
const address = value;
|
|
81
|
+
if (addressId(address) !== expectedId)
|
|
82
|
+
throw new Error("Stored actor address does not match its key");
|
|
83
|
+
return address;
|
|
84
|
+
};
|
|
85
|
+
export const decimal = (value) => {
|
|
86
|
+
if (typeof value === "bigint")
|
|
87
|
+
return value.toString();
|
|
88
|
+
if (typeof value === "number" && Number.isSafeInteger(value) && value >= 0)
|
|
89
|
+
return String(value);
|
|
90
|
+
if (typeof value === "string" && /^(0|[1-9][0-9]*)$/.test(value))
|
|
91
|
+
return value;
|
|
92
|
+
throw new Error("Invalid stored decimal");
|
|
93
|
+
};
|
|
94
|
+
export const integer = (value) => {
|
|
95
|
+
if (typeof value === "number" && Number.isSafeInteger(value))
|
|
96
|
+
return value;
|
|
97
|
+
if (typeof value === "bigint" &&
|
|
98
|
+
value <= BigInt(Number.MAX_SAFE_INTEGER) &&
|
|
99
|
+
value >= BigInt(Number.MIN_SAFE_INTEGER)) {
|
|
100
|
+
return Number(value);
|
|
101
|
+
}
|
|
102
|
+
if (typeof value === "string" && /^-?(0|[1-9][0-9]*)$/.test(value)) {
|
|
103
|
+
const number = Number(value);
|
|
104
|
+
if (Number.isSafeInteger(number))
|
|
105
|
+
return number;
|
|
106
|
+
}
|
|
107
|
+
throw new Error("Invalid stored integer");
|
|
108
|
+
};
|
|
109
|
+
export const positiveInteger = (value, name) => {
|
|
110
|
+
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
111
|
+
throw new InvalidInput({ message: `${name} must be a positive integer` });
|
|
112
|
+
}
|
|
113
|
+
return value;
|
|
114
|
+
};
|
|
115
|
+
export const nonNegativeInteger = (value, name) => {
|
|
116
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
117
|
+
throw new InvalidInput({ message: `${name} must be a non-negative integer` });
|
|
118
|
+
}
|
|
119
|
+
return value;
|
|
120
|
+
};
|
|
121
|
+
export const decimalInput = (value, name) => {
|
|
122
|
+
if (!/^(0|[1-9][0-9]*)$/.test(value)) {
|
|
123
|
+
throw new InvalidInput({ message: `${name} must be a canonical decimal string` });
|
|
124
|
+
}
|
|
125
|
+
return value;
|
|
126
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Duration, Effect } from "effect";
|
|
2
|
+
import { type StoreError } from "../../errors/index.js";
|
|
3
|
+
import type { Outcome } from "../../protocol/index.js";
|
|
4
|
+
import type { OutboxClaim } from "../service.js";
|
|
5
|
+
import type { SqlStoreContext } from "./ownership.js";
|
|
6
|
+
export declare const claimOutbox: (context: SqlStoreContext, options: {
|
|
7
|
+
readonly namespace: string;
|
|
8
|
+
readonly limit: number;
|
|
9
|
+
readonly claimDuration: Duration.Input;
|
|
10
|
+
}) => Effect.Effect<ReadonlyArray<OutboxClaim>, StoreError>;
|
|
11
|
+
export declare const settleOutbox: (context: SqlStoreContext, claim: OutboxClaim, outcome: Outcome) => Effect.Effect<void, StoreError>;
|
|
12
|
+
export declare const retryOutbox: (context: SqlStoreContext, claim: OutboxClaim, options: {
|
|
13
|
+
readonly retryAtMillis: number;
|
|
14
|
+
readonly reasonCode: string;
|
|
15
|
+
}) => Effect.Effect<void, StoreError>;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { Duration, Effect } from "effect";
|
|
2
|
+
import { InvalidInput, StorageUnavailable } from "../../errors/index.js";
|
|
3
|
+
import { addressId, validateId } from "../../protocol/index.js";
|
|
4
|
+
import { databaseNow, decodeJson, encodeJson, forUpdateSkipLocked, integer, nonNegativeInteger, normalizeSqlError, positiveInteger, protocolBoundary, } from "./model.js";
|
|
5
|
+
const claimDurationMillis = (input) => protocolBoundary(() => {
|
|
6
|
+
const value = Math.ceil(Duration.toMillis(input));
|
|
7
|
+
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
8
|
+
throw new InvalidInput({ message: "claimDuration must be a finite positive duration" });
|
|
9
|
+
}
|
|
10
|
+
return value;
|
|
11
|
+
});
|
|
12
|
+
const decodeCommand = (value) => {
|
|
13
|
+
const command = decodeJson(value);
|
|
14
|
+
if (typeof command !== "object" ||
|
|
15
|
+
command === null ||
|
|
16
|
+
(command.kind !== "command" && command.kind !== "hosted") ||
|
|
17
|
+
typeof command.operation !== "string" ||
|
|
18
|
+
command.operation.length === 0 ||
|
|
19
|
+
!Number.isSafeInteger(command.protocolVersion) ||
|
|
20
|
+
command.protocolVersion < 0 ||
|
|
21
|
+
typeof command.commandId !== "string") {
|
|
22
|
+
throw new Error("Invalid stored outgoing command");
|
|
23
|
+
}
|
|
24
|
+
validateId(command.commandId);
|
|
25
|
+
addressId(command.target);
|
|
26
|
+
encodeJson(command.payload);
|
|
27
|
+
return command;
|
|
28
|
+
};
|
|
29
|
+
export const claimOutbox = (context, options) => Effect.gen(function* () {
|
|
30
|
+
const limit = yield* protocolBoundary(() => positiveInteger(options.limit, "outbox claim limit"));
|
|
31
|
+
const duration = yield* claimDurationMillis(options.claimDuration);
|
|
32
|
+
if (typeof options.namespace !== "string" || options.namespace.length === 0) {
|
|
33
|
+
return yield* Effect.fail(new InvalidInput({ message: "Invalid outbox namespace" }));
|
|
34
|
+
}
|
|
35
|
+
const { crypto, dialect, sql, tables } = context;
|
|
36
|
+
const program = Effect.gen(function* () {
|
|
37
|
+
const candidates = yield* sql `
|
|
38
|
+
SELECT delivery_id
|
|
39
|
+
FROM ${sql(tables.outbox)}
|
|
40
|
+
WHERE namespace = ${options.namespace}
|
|
41
|
+
AND (
|
|
42
|
+
(status = 'pending' AND retry_at_ms <= ${databaseNow(sql, dialect)})
|
|
43
|
+
OR (status = 'claimed' AND claim_until_ms <= ${databaseNow(sql, dialect)})
|
|
44
|
+
)
|
|
45
|
+
ORDER BY created_at_ms, delivery_id
|
|
46
|
+
LIMIT ${limit}
|
|
47
|
+
${forUpdateSkipLocked(sql, dialect)}
|
|
48
|
+
`;
|
|
49
|
+
const claims = [];
|
|
50
|
+
for (const candidate of candidates) {
|
|
51
|
+
if (typeof candidate.delivery_id !== "string")
|
|
52
|
+
throw new Error("Invalid outbox row");
|
|
53
|
+
const claimToken = yield* crypto.randomUUIDv4.pipe(Effect.mapError(() => new StorageUnavailable({ retryable: true })));
|
|
54
|
+
const rows = yield* sql `
|
|
55
|
+
UPDATE ${sql(tables.outbox)}
|
|
56
|
+
SET status = 'claimed',
|
|
57
|
+
attempt = attempt + 1,
|
|
58
|
+
claim_token = ${claimToken},
|
|
59
|
+
claim_until_ms = ${databaseNow(sql, dialect)} + ${duration}
|
|
60
|
+
WHERE delivery_id = ${candidate.delivery_id}
|
|
61
|
+
AND namespace = ${options.namespace}
|
|
62
|
+
AND (
|
|
63
|
+
(status = 'pending' AND retry_at_ms <= ${databaseNow(sql, dialect)})
|
|
64
|
+
OR (status = 'claimed' AND claim_until_ms <= ${databaseNow(sql, dialect)})
|
|
65
|
+
)
|
|
66
|
+
RETURNING attempt, target_call
|
|
67
|
+
`;
|
|
68
|
+
const row = rows[0];
|
|
69
|
+
if (row === undefined)
|
|
70
|
+
continue;
|
|
71
|
+
claims.push({
|
|
72
|
+
id: candidate.delivery_id,
|
|
73
|
+
claimToken,
|
|
74
|
+
attempt: integer(row.attempt),
|
|
75
|
+
command: decodeCommand(row.target_call),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
return claims;
|
|
79
|
+
});
|
|
80
|
+
return yield* sql.withTransaction(program);
|
|
81
|
+
}).pipe(Effect.mapError(normalizeSqlError), Effect.catchDefect(() => Effect.fail(new StorageUnavailable({ retryable: true }))));
|
|
82
|
+
const validClaim = (claim) => protocolBoundary(() => {
|
|
83
|
+
if (typeof claim.id !== "string" ||
|
|
84
|
+
claim.id.length === 0 ||
|
|
85
|
+
typeof claim.claimToken !== "string" ||
|
|
86
|
+
claim.claimToken.length === 0) {
|
|
87
|
+
throw new InvalidInput({ message: "Invalid outbox claim" });
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
export const settleOutbox = (context, claim, outcome) => Effect.gen(function* () {
|
|
91
|
+
yield* validClaim(claim);
|
|
92
|
+
const encoded = yield* protocolBoundary(() => encodeJson(outcome));
|
|
93
|
+
const { dialect, sql, tables } = context;
|
|
94
|
+
yield* sql `
|
|
95
|
+
UPDATE ${sql(tables.outbox)}
|
|
96
|
+
SET status = 'settled',
|
|
97
|
+
outcome = ${encoded},
|
|
98
|
+
claim_token = NULL,
|
|
99
|
+
claim_until_ms = NULL,
|
|
100
|
+
reason_code = NULL
|
|
101
|
+
WHERE delivery_id = ${claim.id}
|
|
102
|
+
AND status = 'claimed'
|
|
103
|
+
AND claim_token = ${claim.claimToken}
|
|
104
|
+
AND claim_until_ms > ${databaseNow(sql, dialect)}
|
|
105
|
+
`;
|
|
106
|
+
}).pipe(Effect.mapError(normalizeSqlError), Effect.catchDefect(() => Effect.fail(new StorageUnavailable({ retryable: true }))));
|
|
107
|
+
export const retryOutbox = (context, claim, options) => Effect.gen(function* () {
|
|
108
|
+
yield* validClaim(claim);
|
|
109
|
+
yield* protocolBoundary(() => {
|
|
110
|
+
nonNegativeInteger(options.retryAtMillis, "retryAtMillis");
|
|
111
|
+
if (typeof options.reasonCode !== "string" ||
|
|
112
|
+
options.reasonCode.length === 0 ||
|
|
113
|
+
options.reasonCode.length > 256) {
|
|
114
|
+
throw new InvalidInput({ message: "Invalid outbox retry reason" });
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
const { dialect, sql, tables } = context;
|
|
118
|
+
yield* sql `
|
|
119
|
+
UPDATE ${sql(tables.outbox)}
|
|
120
|
+
SET status = 'pending',
|
|
121
|
+
retry_at_ms = ${options.retryAtMillis},
|
|
122
|
+
reason_code = ${options.reasonCode},
|
|
123
|
+
claim_token = NULL,
|
|
124
|
+
claim_until_ms = NULL
|
|
125
|
+
WHERE delivery_id = ${claim.id}
|
|
126
|
+
AND status = 'claimed'
|
|
127
|
+
AND claim_token = ${claim.claimToken}
|
|
128
|
+
AND claim_until_ms > ${databaseNow(sql, dialect)}
|
|
129
|
+
`;
|
|
130
|
+
}).pipe(Effect.mapError(normalizeSqlError), Effect.catchDefect(() => Effect.fail(new StorageUnavailable({ retryable: true }))));
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { Deferred, Duration, Effect, Semaphore } from "effect";
|
|
2
|
+
import type { Crypto } from "effect";
|
|
3
|
+
import type { SqlClient } from "effect/unstable/sql";
|
|
4
|
+
import { OwnershipLost, type StoreError } from "../../errors/index.js";
|
|
5
|
+
import type { Address } from "../../protocol/index.js";
|
|
6
|
+
import type { Ownership } from "../service.js";
|
|
7
|
+
import { type Dialect, type Tables } from "./model.js";
|
|
8
|
+
export interface SqlStoreContext {
|
|
9
|
+
readonly sql: SqlClient.SqlClient;
|
|
10
|
+
readonly crypto: Crypto.Crypto;
|
|
11
|
+
readonly dialect: Dialect;
|
|
12
|
+
readonly tables: Tables;
|
|
13
|
+
readonly owners: WeakMap<Ownership, OwnerState>;
|
|
14
|
+
}
|
|
15
|
+
export interface HeadRow {
|
|
16
|
+
readonly address: unknown;
|
|
17
|
+
readonly address_json: unknown;
|
|
18
|
+
readonly namespace: unknown;
|
|
19
|
+
readonly fence: unknown;
|
|
20
|
+
readonly lease_until_ms: unknown;
|
|
21
|
+
readonly schema_version: unknown;
|
|
22
|
+
readonly state: unknown;
|
|
23
|
+
readonly revision: unknown;
|
|
24
|
+
readonly event_sequence: unknown;
|
|
25
|
+
readonly event_floor: unknown;
|
|
26
|
+
readonly now_ms: unknown;
|
|
27
|
+
}
|
|
28
|
+
export interface OwnerState {
|
|
29
|
+
readonly address: Address;
|
|
30
|
+
readonly addressId: string;
|
|
31
|
+
readonly fence: string;
|
|
32
|
+
readonly gate: Semaphore.Semaphore;
|
|
33
|
+
readonly lost: Deferred.Deferred<never, StoreError>;
|
|
34
|
+
active: boolean;
|
|
35
|
+
}
|
|
36
|
+
export declare const loseOwner: (state: OwnerState, error: StoreError) => Effect.Effect<void>;
|
|
37
|
+
export declare const ownerState: (context: SqlStoreContext, owner: Ownership) => Effect.Effect<OwnerState, OwnershipLost>;
|
|
38
|
+
export declare const withOwner: <A, E, R>(context: SqlStoreContext, owner: Ownership, use: (state: OwnerState) => Effect.Effect<A, E, R>) => Effect.Effect<A, E | OwnershipLost, R>;
|
|
39
|
+
export declare const ownedHead: (context: SqlStoreContext, state: OwnerState, lock: boolean) => Effect.Effect<HeadRow, StoreError>;
|
|
40
|
+
export declare const finalFence: (context: SqlStoreContext, state: OwnerState) => Effect.Effect<void, StoreError>;
|
|
41
|
+
export declare const acquire: (context: SqlStoreContext, address: Address, options: {
|
|
42
|
+
readonly leaseDuration: Duration.Input;
|
|
43
|
+
readonly renewEvery: Duration.Input;
|
|
44
|
+
}) => Effect.Effect<Ownership, StoreError, import("effect").Scope.Scope>;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { Deferred, Duration, Effect, Semaphore } from "effect";
|
|
2
|
+
import { InvalidInput, OwnershipLost } from "../../errors/index.js";
|
|
3
|
+
import { databaseNow, decimal, encodeAddress, forUpdate, normalizeSqlError, protocolBoundary, } from "./model.js";
|
|
4
|
+
const ownershipLost = (address) => new OwnershipLost({ actorType: address.actorType });
|
|
5
|
+
const durationMillis = (input, name) => protocolBoundary(() => {
|
|
6
|
+
const value = Math.ceil(Duration.toMillis(input));
|
|
7
|
+
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
8
|
+
throw new InvalidInput({ message: `${name} must be a finite positive duration` });
|
|
9
|
+
}
|
|
10
|
+
return value;
|
|
11
|
+
});
|
|
12
|
+
export const loseOwner = (state, error) => Effect.gen(function* () {
|
|
13
|
+
state.active = false;
|
|
14
|
+
yield* Deferred.fail(state.lost, error);
|
|
15
|
+
});
|
|
16
|
+
export const ownerState = (context, owner) => {
|
|
17
|
+
const state = context.owners.get(owner);
|
|
18
|
+
if (state === undefined || owner.fence !== state.fence || owner.address !== state.address) {
|
|
19
|
+
return Effect.fail(ownershipLost(owner.address));
|
|
20
|
+
}
|
|
21
|
+
return Effect.succeed(state);
|
|
22
|
+
};
|
|
23
|
+
export const withOwner = (context, owner, use) => Effect.flatMap(ownerState(context, owner), (state) => state.gate.withPermit(Effect.flatMap(Effect.sync(() => state.active), (active) => active ? use(state) : Effect.fail(ownershipLost(state.address)))));
|
|
24
|
+
export const ownedHead = (context, state, lock) => {
|
|
25
|
+
const { dialect, sql, tables } = context;
|
|
26
|
+
const query = sql `
|
|
27
|
+
SELECT address, address_json, namespace, fence, lease_until_ms, schema_version, state,
|
|
28
|
+
revision, event_sequence, event_floor, ${databaseNow(sql, dialect)} AS now_ms
|
|
29
|
+
FROM ${sql(tables.heads)}
|
|
30
|
+
WHERE address = ${state.addressId}
|
|
31
|
+
AND fence = ${state.fence}
|
|
32
|
+
AND lease_until_ms > ${databaseNow(sql, dialect)}
|
|
33
|
+
${lock ? forUpdate(sql, dialect) : sql.literal("")}
|
|
34
|
+
`;
|
|
35
|
+
return query.pipe(Effect.mapError(normalizeSqlError), Effect.flatMap((rows) => {
|
|
36
|
+
const row = rows[0];
|
|
37
|
+
if (row !== undefined)
|
|
38
|
+
return Effect.succeed(row);
|
|
39
|
+
const error = ownershipLost(state.address);
|
|
40
|
+
return loseOwner(state, error).pipe(Effect.andThen(Effect.fail(error)));
|
|
41
|
+
}));
|
|
42
|
+
};
|
|
43
|
+
export const finalFence = (context, state) => {
|
|
44
|
+
if (!state.active)
|
|
45
|
+
return Effect.fail(ownershipLost(state.address));
|
|
46
|
+
const { dialect, sql, tables } = context;
|
|
47
|
+
return sql `
|
|
48
|
+
UPDATE ${sql(tables.heads)}
|
|
49
|
+
SET lease_until_ms = lease_until_ms
|
|
50
|
+
WHERE address = ${state.addressId}
|
|
51
|
+
AND fence = ${state.fence}
|
|
52
|
+
AND lease_until_ms > ${databaseNow(sql, dialect)}
|
|
53
|
+
RETURNING fence
|
|
54
|
+
`.pipe(Effect.mapError(normalizeSqlError), Effect.flatMap((rows) => {
|
|
55
|
+
if (rows.length === 1 && decimal(rows[0].fence) === state.fence)
|
|
56
|
+
return Effect.void;
|
|
57
|
+
const error = ownershipLost(state.address);
|
|
58
|
+
return loseOwner(state, error).pipe(Effect.andThen(Effect.fail(error)));
|
|
59
|
+
}));
|
|
60
|
+
};
|
|
61
|
+
const renew = (context, state, leaseMillis) => state.gate.withPermit(Effect.suspend(() => {
|
|
62
|
+
if (!state.active)
|
|
63
|
+
return Effect.fail(ownershipLost(state.address));
|
|
64
|
+
const { dialect, sql, tables } = context;
|
|
65
|
+
return sql `
|
|
66
|
+
UPDATE ${sql(tables.heads)}
|
|
67
|
+
SET lease_until_ms = ${databaseNow(sql, dialect)} + ${leaseMillis}
|
|
68
|
+
WHERE address = ${state.addressId}
|
|
69
|
+
AND fence = ${state.fence}
|
|
70
|
+
AND lease_until_ms > ${databaseNow(sql, dialect)}
|
|
71
|
+
RETURNING fence
|
|
72
|
+
`.pipe(Effect.mapError(normalizeSqlError), Effect.flatMap((rows) => rows.length === 1 ? Effect.void : Effect.fail(ownershipLost(state.address))));
|
|
73
|
+
}));
|
|
74
|
+
const closeOwner = (context, state) => state.gate.withPermit(Effect.gen(function* () {
|
|
75
|
+
if (!state.active)
|
|
76
|
+
return;
|
|
77
|
+
const error = ownershipLost(state.address);
|
|
78
|
+
yield* loseOwner(state, error);
|
|
79
|
+
const { sql, tables } = context;
|
|
80
|
+
yield* sql `
|
|
81
|
+
UPDATE ${sql(tables.heads)}
|
|
82
|
+
SET lease_until_ms = 0
|
|
83
|
+
WHERE address = ${state.addressId} AND fence = ${state.fence}
|
|
84
|
+
`.pipe(Effect.catch(() => Effect.void), Effect.catchDefect(() => Effect.void));
|
|
85
|
+
}));
|
|
86
|
+
export const acquire = (context, address, options) => Effect.gen(function* () {
|
|
87
|
+
const leaseMillis = yield* durationMillis(options.leaseDuration, "leaseDuration");
|
|
88
|
+
const renewMillis = yield* durationMillis(options.renewEvery, "renewEvery");
|
|
89
|
+
if (renewMillis >= leaseMillis) {
|
|
90
|
+
return yield* Effect.fail(new InvalidInput({ message: "renewEvery must be shorter than leaseDuration" }));
|
|
91
|
+
}
|
|
92
|
+
const encoded = yield* protocolBoundary(() => encodeAddress(address));
|
|
93
|
+
const { dialect, sql, tables } = context;
|
|
94
|
+
const fence = yield* sql
|
|
95
|
+
.withTransaction(Effect.gen(function* () {
|
|
96
|
+
yield* sql `
|
|
97
|
+
INSERT INTO ${sql(tables.heads)}
|
|
98
|
+
(address, address_json, namespace, fence, lease_until_ms, revision, event_sequence, event_floor)
|
|
99
|
+
VALUES (${encoded.id}, ${encoded.json}, ${address.namespace}, 0, 0, 0, 0, 0)
|
|
100
|
+
ON CONFLICT (address) DO NOTHING
|
|
101
|
+
`;
|
|
102
|
+
const rows = yield* sql `
|
|
103
|
+
UPDATE ${sql(tables.heads)}
|
|
104
|
+
SET fence = fence + 1,
|
|
105
|
+
lease_until_ms = ${databaseNow(sql, dialect)} + ${leaseMillis}
|
|
106
|
+
WHERE address = ${encoded.id}
|
|
107
|
+
AND lease_until_ms <= ${databaseNow(sql, dialect)}
|
|
108
|
+
RETURNING fence
|
|
109
|
+
`;
|
|
110
|
+
if (rows.length !== 1)
|
|
111
|
+
return yield* Effect.fail(ownershipLost(address));
|
|
112
|
+
return decimal(rows[0].fence);
|
|
113
|
+
}))
|
|
114
|
+
.pipe(Effect.mapError(normalizeSqlError));
|
|
115
|
+
const deferred = yield* Deferred.make();
|
|
116
|
+
const gate = yield* Semaphore.make(1);
|
|
117
|
+
const state = {
|
|
118
|
+
address,
|
|
119
|
+
addressId: encoded.id,
|
|
120
|
+
fence,
|
|
121
|
+
gate,
|
|
122
|
+
lost: deferred,
|
|
123
|
+
active: true,
|
|
124
|
+
};
|
|
125
|
+
const owner = Object.freeze({ address, fence, lost: Deferred.await(deferred) });
|
|
126
|
+
context.owners.set(owner, state);
|
|
127
|
+
yield* Effect.addFinalizer(() => closeOwner(context, state));
|
|
128
|
+
yield* Effect.forever(Effect.sleep(renewMillis).pipe(Effect.andThen(renew(context, state, leaseMillis)))).pipe(Effect.catch((error) => loseOwner(state, error)), Effect.catchDefect(() => loseOwner(state, normalizeSqlError(undefined))), Effect.forkScoped);
|
|
129
|
+
return owner;
|
|
130
|
+
}).pipe(Effect.mapError(normalizeSqlError), Effect.catchDefect(() => Effect.fail(normalizeSqlError(undefined))));
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Effect, Option } from "effect";
|
|
2
|
+
import { type StoreError } from "../../errors/index.js";
|
|
3
|
+
import type { Address, Json, Outcome, WireReceipt } from "../../protocol/index.js";
|
|
4
|
+
import type { OwnerState, SqlStoreContext } from "./ownership.js";
|
|
5
|
+
export declare const storedReceipt: (context: SqlStoreContext, state: OwnerState, commandId: string) => Effect.Effect<Readonly<{
|
|
6
|
+
receipt: WireReceipt;
|
|
7
|
+
fingerprint: string;
|
|
8
|
+
}> | undefined, StoreError>;
|
|
9
|
+
export declare const committedAt: (context: SqlStoreContext) => Effect.Effect<number, StoreError>;
|
|
10
|
+
export declare const insertReceipt: (context: SqlStoreContext, state: OwnerState, input: {
|
|
11
|
+
readonly commandId: string;
|
|
12
|
+
readonly fingerprint: string;
|
|
13
|
+
readonly outcome: Outcome;
|
|
14
|
+
readonly revision: string;
|
|
15
|
+
readonly eventSequence: string;
|
|
16
|
+
readonly committedAtMillis: number;
|
|
17
|
+
}) => Effect.Effect<WireReceipt, StoreError>;
|
|
18
|
+
export declare const recordFailure: (context: SqlStoreContext, state: OwnerState, input: {
|
|
19
|
+
readonly commandId: string;
|
|
20
|
+
readonly fingerprint: string;
|
|
21
|
+
readonly error: Json;
|
|
22
|
+
readonly revision: string;
|
|
23
|
+
readonly eventSequence: string;
|
|
24
|
+
}) => Effect.Effect<WireReceipt, StoreError>;
|
|
25
|
+
export declare const receipt: (context: SqlStoreContext, address: Address, commandId: string) => Effect.Effect<Option.Option<WireReceipt>, StoreError>;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { Effect, Option } from "effect";
|
|
2
|
+
import { StorageUnavailable } from "../../errors/index.js";
|
|
3
|
+
import { addressId, makeCursor, validateId } from "../../protocol/index.js";
|
|
4
|
+
import { databaseNow, decimal, decodeJson, encodeJson, integer, normalizeSqlError, protocolBoundary, } from "./model.js";
|
|
5
|
+
import { finalFence } from "./ownership.js";
|
|
6
|
+
const invalidStoredData = () => new StorageUnavailable({ retryable: false });
|
|
7
|
+
const readReceipt = (address, row) => {
|
|
8
|
+
if (typeof row.command_id !== "string" || typeof row.fingerprint !== "string")
|
|
9
|
+
throw new Error("Invalid receipt");
|
|
10
|
+
const outcome = decodeJson(row.outcome);
|
|
11
|
+
if (typeof outcome !== "object" ||
|
|
12
|
+
outcome === null ||
|
|
13
|
+
Array.isArray(outcome) ||
|
|
14
|
+
!("_tag" in outcome)) {
|
|
15
|
+
throw new Error("Invalid receipt outcome");
|
|
16
|
+
}
|
|
17
|
+
if (outcome._tag === "Success") {
|
|
18
|
+
if (!Object.hasOwn(outcome, "value"))
|
|
19
|
+
throw new Error("Invalid success receipt");
|
|
20
|
+
}
|
|
21
|
+
else if (outcome._tag === "Failure") {
|
|
22
|
+
if (!Object.hasOwn(outcome, "error"))
|
|
23
|
+
throw new Error("Invalid failure receipt");
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
throw new Error("Invalid receipt outcome");
|
|
27
|
+
}
|
|
28
|
+
const sequence = decimal(row.event_sequence);
|
|
29
|
+
return {
|
|
30
|
+
commandId: row.command_id,
|
|
31
|
+
revision: decimal(row.revision),
|
|
32
|
+
cursor: makeCursor(address, sequence),
|
|
33
|
+
committedAtMillis: integer(row.committed_at_ms),
|
|
34
|
+
value: outcome,
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
export const storedReceipt = (context, state, commandId) => {
|
|
38
|
+
const { sql, tables } = context;
|
|
39
|
+
return sql `
|
|
40
|
+
SELECT command_id, fingerprint, outcome, revision, event_sequence, committed_at_ms
|
|
41
|
+
FROM ${sql(tables.receipts)}
|
|
42
|
+
WHERE address = ${state.addressId} AND command_id = ${commandId}
|
|
43
|
+
`.pipe(Effect.mapError(normalizeSqlError), Effect.flatMap((rows) => protocolBoundary(() => {
|
|
44
|
+
const row = rows[0];
|
|
45
|
+
return row === undefined
|
|
46
|
+
? undefined
|
|
47
|
+
: { receipt: readReceipt(state.address, row), fingerprint: String(row.fingerprint) };
|
|
48
|
+
}).pipe(Effect.mapError(() => invalidStoredData()))));
|
|
49
|
+
};
|
|
50
|
+
export const committedAt = (context) => {
|
|
51
|
+
const { dialect, sql } = context;
|
|
52
|
+
return sql `SELECT ${databaseNow(sql, dialect)} AS now_ms`.pipe(Effect.mapError(normalizeSqlError), Effect.flatMap((rows) => protocolBoundary(() => integer(rows[0]?.now_ms)).pipe(Effect.mapError(() => invalidStoredData()))));
|
|
53
|
+
};
|
|
54
|
+
export const insertReceipt = (context, state, input) => Effect.gen(function* () {
|
|
55
|
+
const { sql, tables } = context;
|
|
56
|
+
const encoded = yield* protocolBoundary(() => encodeJson(input.outcome));
|
|
57
|
+
yield* sql `
|
|
58
|
+
INSERT INTO ${sql(tables.receipts)}
|
|
59
|
+
(address, command_id, fingerprint, outcome, revision, event_sequence, committed_at_ms)
|
|
60
|
+
VALUES (${state.addressId}, ${input.commandId}, ${input.fingerprint}, ${encoded}, ${input.revision},
|
|
61
|
+
${input.eventSequence}, ${input.committedAtMillis})
|
|
62
|
+
`;
|
|
63
|
+
return {
|
|
64
|
+
commandId: input.commandId,
|
|
65
|
+
revision: input.revision,
|
|
66
|
+
cursor: yield* protocolBoundary(() => makeCursor(state.address, input.eventSequence)),
|
|
67
|
+
committedAtMillis: input.committedAtMillis,
|
|
68
|
+
value: input.outcome,
|
|
69
|
+
};
|
|
70
|
+
}).pipe(Effect.mapError(normalizeSqlError));
|
|
71
|
+
export const recordFailure = (context, state, input) => Effect.gen(function* () {
|
|
72
|
+
yield* protocolBoundary(() => encodeJson(input.error));
|
|
73
|
+
const nowMillis = yield* committedAt(context);
|
|
74
|
+
const receipt = yield* insertReceipt(context, state, {
|
|
75
|
+
...input,
|
|
76
|
+
outcome: { _tag: "Failure", error: input.error },
|
|
77
|
+
committedAtMillis: nowMillis,
|
|
78
|
+
});
|
|
79
|
+
yield* finalFence(context, state);
|
|
80
|
+
return receipt;
|
|
81
|
+
});
|
|
82
|
+
export const receipt = (context, address, commandId) => Effect.gen(function* () {
|
|
83
|
+
const actorId = yield* protocolBoundary(() => addressId(address));
|
|
84
|
+
yield* protocolBoundary(() => validateId(commandId, 1024));
|
|
85
|
+
const rows = yield* context.sql `
|
|
86
|
+
SELECT command_id, fingerprint, outcome, revision, event_sequence, committed_at_ms
|
|
87
|
+
FROM ${context.sql(context.tables.receipts)}
|
|
88
|
+
WHERE address = ${actorId} AND command_id = ${commandId}
|
|
89
|
+
`;
|
|
90
|
+
if (rows.length === 0)
|
|
91
|
+
return Option.none();
|
|
92
|
+
return Option.some(yield* protocolBoundary(() => readReceipt(address, rows[0])).pipe(Effect.mapError(() => invalidStoredData())));
|
|
93
|
+
}).pipe(Effect.mapError(normalizeSqlError), Effect.catchDefect(() => Effect.fail(new StorageUnavailable({ retryable: true }))));
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { SqlClient } from "effect/unstable/sql";
|
|
3
|
+
import { InvalidInput, UnsupportedVersion } from "../../errors/index.js";
|
|
4
|
+
import { type Tables } from "./model.js";
|
|
5
|
+
export declare const schemaVersion = 1;
|
|
6
|
+
export declare const validateSchema: (sql: SqlClient.SqlClient, tables: Tables) => Effect.Effect<undefined, import("../../errors/index.js").StorageUnavailable | UnsupportedVersion, never>;
|
|
7
|
+
export declare const migrate: (options: {
|
|
8
|
+
readonly tablePrefix: string;
|
|
9
|
+
}) => Effect.Effect<void, InvalidInput | import("../../errors/index.js").StorageUnavailable | UnsupportedVersion, SqlClient.SqlClient>;
|