@effect/platform-cloudflare 0.0.0-stage → 4.1.3
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/AGENTS.md +434 -0
- package/CLAUDE.md +434 -0
- package/LICENSE +21 -0
- package/README.md +505 -2
- package/ai-docs/README.md +44 -0
- package/ai-docs/package.json +36 -0
- package/ai-docs/src/01_effect/01_basics/01_effect-gen.ts +30 -0
- package/ai-docs/src/01_effect/01_basics/02_effect-fn.ts +52 -0
- package/ai-docs/src/01_effect/01_basics/10_creating-effects.ts +74 -0
- package/ai-docs/src/01_effect/01_basics/index.md +7 -0
- package/ai-docs/src/01_effect/02_schema/10_schema-basics.ts +43 -0
- package/ai-docs/src/01_effect/02_schema/index.md +7 -0
- package/ai-docs/src/01_effect/03_services/01_service.ts +45 -0
- package/ai-docs/src/01_effect/03_services/10_reference.ts +10 -0
- package/ai-docs/src/01_effect/03_services/20_layer-composition.ts +70 -0
- package/ai-docs/src/01_effect/03_services/20_layer-unwrap.ts +66 -0
- package/ai-docs/src/01_effect/03_services/index.md +5 -0
- package/ai-docs/src/01_effect/04_errors/01_error-handling.ts +30 -0
- package/ai-docs/src/01_effect/04_errors/10_catch-tags.ts +24 -0
- package/ai-docs/src/01_effect/04_errors/20_reason-errors.ts +64 -0
- package/ai-docs/src/01_effect/04_errors/index.md +1 -0
- package/ai-docs/src/01_effect/05_resources/10_acquire-release.ts +105 -0
- package/ai-docs/src/01_effect/05_resources/20_layer-side-effects.ts +31 -0
- package/ai-docs/src/01_effect/05_resources/30_layer-map.ts +86 -0
- package/ai-docs/src/01_effect/05_resources/index.md +3 -0
- package/ai-docs/src/01_effect/06_running/10_run-main.ts +30 -0
- package/ai-docs/src/01_effect/06_running/20_layer-launch.ts +27 -0
- package/ai-docs/src/01_effect/06_running/index.md +1 -0
- package/ai-docs/src/01_effect/07_pubsub/10_pubsub.ts +56 -0
- package/ai-docs/src/01_effect/07_pubsub/index.md +3 -0
- package/ai-docs/src/03_stream/10_creating-streams.ts +103 -0
- package/ai-docs/src/03_stream/20_consuming-streams.ts +137 -0
- package/ai-docs/src/03_stream/30_encoding.ts +163 -0
- package/ai-docs/src/03_stream/index.md +4 -0
- package/ai-docs/src/04_integration/10_managed-runtime.ts +129 -0
- package/ai-docs/src/04_integration/index.md +5 -0
- package/ai-docs/src/05_batching/10_request-resolver.ts +89 -0
- package/ai-docs/src/05_batching/index.md +3 -0
- package/ai-docs/src/06_schedule/10_schedules.ts +111 -0
- package/ai-docs/src/06_schedule/index.md +3 -0
- package/ai-docs/src/07_datetime/10_creating-and-formatting.ts +30 -0
- package/ai-docs/src/07_datetime/20_time-zones.ts +44 -0
- package/ai-docs/src/07_datetime/index.md +5 -0
- package/ai-docs/src/08_observability/10_logging.ts +66 -0
- package/ai-docs/src/08_observability/20_otlp-tracing.ts +95 -0
- package/ai-docs/src/08_observability/index.md +7 -0
- package/ai-docs/src/09_testing/10_effect-tests.ts +55 -0
- package/ai-docs/src/09_testing/20_layer-tests.ts +138 -0
- package/ai-docs/src/09_testing/index.md +1 -0
- package/ai-docs/src/10_predicate/01_basics.ts +14 -0
- package/ai-docs/src/10_predicate/index.md +9 -0
- package/ai-docs/src/40_sql/10_basics.ts +175 -0
- package/ai-docs/src/40_sql/index.md +6 -0
- package/ai-docs/src/50_http-client/10_basics.ts +102 -0
- package/ai-docs/src/50_http-client/index.md +3 -0
- package/ai-docs/src/51_http-server/10_basics.ts +118 -0
- package/ai-docs/src/51_http-server/20_testing.ts +100 -0
- package/ai-docs/src/51_http-server/fixtures/api/Api.ts +14 -0
- package/ai-docs/src/51_http-server/fixtures/api/Authorization.ts +36 -0
- package/ai-docs/src/51_http-server/fixtures/api/System.ts +10 -0
- package/ai-docs/src/51_http-server/fixtures/api/Users.ts +107 -0
- package/ai-docs/src/51_http-server/fixtures/domain/User.ts +21 -0
- package/ai-docs/src/51_http-server/fixtures/domain/UserErrors.ts +22 -0
- package/ai-docs/src/51_http-server/fixtures/server/Authorization.ts +37 -0
- package/ai-docs/src/51_http-server/fixtures/server/Users/http.ts +80 -0
- package/ai-docs/src/51_http-server/fixtures/server/Users.ts +183 -0
- package/ai-docs/src/51_http-server/index.md +3 -0
- package/ai-docs/src/52_graphql/10_basics.ts +117 -0
- package/ai-docs/src/52_graphql/20_testing.ts +45 -0
- package/ai-docs/src/52_graphql/30_batching.ts +118 -0
- package/ai-docs/src/52_graphql/40_aliased-batching.ts +124 -0
- package/ai-docs/src/52_graphql/50_errors-and-retries.ts +74 -0
- package/ai-docs/src/52_graphql/index.md +6 -0
- package/ai-docs/src/60_child-process/10_working-with-child-processes.ts +117 -0
- package/ai-docs/src/60_child-process/index.md +3 -0
- package/ai-docs/src/70_cli/10_basics.ts +162 -0
- package/ai-docs/src/70_cli/index.md +5 -0
- package/ai-docs/src/71_ai/10_language-model.ts +156 -0
- package/ai-docs/src/71_ai/20_tools.ts +226 -0
- package/ai-docs/src/71_ai/30_chat.ts +158 -0
- package/ai-docs/src/71_ai/fixtures/domain/LaunchPlan.ts +9 -0
- package/ai-docs/src/71_ai/index.md +5 -0
- package/ai-docs/src/80_cluster/10_entities.ts +97 -0
- package/ai-docs/src/80_cluster/index.md +4 -0
- package/ai-docs/src/index.md +10 -0
- package/ai-docs/tsconfig.json +28 -0
- package/dist/AlchemyCloudflareCluster.d.ts +134 -0
- package/dist/AlchemyCloudflareCluster.d.ts.map +1 -0
- package/dist/AlchemyCloudflareCluster.js +111 -0
- package/dist/AlchemyCloudflareCluster.js.map +1 -0
- package/dist/CloudflareCluster.d.ts +196 -0
- package/dist/CloudflareCluster.d.ts.map +1 -0
- package/dist/CloudflareCluster.js +533 -0
- package/dist/CloudflareCluster.js.map +1 -0
- package/dist/CloudflareDurableObjectPrograms.d.ts +279 -0
- package/dist/CloudflareDurableObjectPrograms.d.ts.map +1 -0
- package/dist/CloudflareDurableObjectPrograms.js +295 -0
- package/dist/CloudflareDurableObjectPrograms.js.map +1 -0
- package/dist/CloudflareDurableObjects.d.ts +149 -0
- package/dist/CloudflareDurableObjects.d.ts.map +1 -0
- package/dist/CloudflareDurableObjects.js +337 -0
- package/dist/CloudflareDurableObjects.js.map +1 -0
- package/dist/CloudflarePersistedQueue.d.ts +36 -0
- package/dist/CloudflarePersistedQueue.d.ts.map +1 -0
- package/dist/CloudflarePersistedQueue.js +122 -0
- package/dist/CloudflarePersistedQueue.js.map +1 -0
- package/dist/CloudflareWorkflowEngine.d.ts +72 -0
- package/dist/CloudflareWorkflowEngine.d.ts.map +1 -0
- package/dist/CloudflareWorkflowEngine.js +187 -0
- package/dist/CloudflareWorkflowEngine.js.map +1 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +33 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/alchemyCluster.d.ts +2 -0
- package/dist/internal/alchemyCluster.d.ts.map +1 -0
- package/dist/internal/alchemyCluster.js +75 -0
- package/dist/internal/alchemyCluster.js.map +1 -0
- package/dist/internal/boundedMap.d.ts +3 -0
- package/dist/internal/boundedMap.d.ts.map +1 -0
- package/dist/internal/boundedMap.js +15 -0
- package/dist/internal/boundedMap.js.map +1 -0
- package/dist/internal/cluster.d.ts +57 -0
- package/dist/internal/cluster.d.ts.map +1 -0
- package/dist/internal/cluster.js +2 -0
- package/dist/internal/cluster.js.map +1 -0
- package/dist/internal/clusterName.d.ts +2 -0
- package/dist/internal/clusterName.d.ts.map +1 -0
- package/dist/internal/clusterName.js +16 -0
- package/dist/internal/clusterName.js.map +1 -0
- package/dist/internal/entityKeepAlive.d.ts +2 -0
- package/dist/internal/entityKeepAlive.d.ts.map +1 -0
- package/dist/internal/entityKeepAlive.js +73 -0
- package/dist/internal/entityKeepAlive.js.map +1 -0
- package/dist/internal/entityMailbox.d.ts +2 -0
- package/dist/internal/entityMailbox.d.ts.map +1 -0
- package/dist/internal/entityMailbox.js +160 -0
- package/dist/internal/entityMailbox.js.map +1 -0
- package/dist/internal/entityRegistry.d.ts +16 -0
- package/dist/internal/entityRegistry.d.ts.map +1 -0
- package/dist/internal/entityRegistry.js +22 -0
- package/dist/internal/entityRegistry.js.map +1 -0
- package/dist/internal/entityReply.d.ts +2 -0
- package/dist/internal/entityReply.d.ts.map +1 -0
- package/dist/internal/entityReply.js +35 -0
- package/dist/internal/entityReply.js.map +1 -0
- package/dist/internal/entityRuntime.d.ts +2 -0
- package/dist/internal/entityRuntime.d.ts.map +1 -0
- package/dist/internal/entityRuntime.js +823 -0
- package/dist/internal/entityRuntime.js.map +1 -0
- package/dist/internal/entitySql.d.ts +2 -0
- package/dist/internal/entitySql.d.ts.map +1 -0
- package/dist/internal/entitySql.js +29 -0
- package/dist/internal/entitySql.js.map +1 -0
- package/dist/internal/entityStorage.d.ts +2 -0
- package/dist/internal/entityStorage.d.ts.map +1 -0
- package/dist/internal/entityStorage.js +98 -0
- package/dist/internal/entityStorage.js.map +1 -0
- package/dist/internal/entityWire.d.ts +2 -0
- package/dist/internal/entityWire.d.ts.map +1 -0
- package/dist/internal/entityWire.js +146 -0
- package/dist/internal/entityWire.js.map +1 -0
- package/dist/internal/queueRuntime.d.ts +2 -0
- package/dist/internal/queueRuntime.d.ts.map +1 -0
- package/dist/internal/queueRuntime.js +88 -0
- package/dist/internal/queueRuntime.js.map +1 -0
- package/dist/internal/queueStorage.d.ts +2 -0
- package/dist/internal/queueStorage.d.ts.map +1 -0
- package/dist/internal/queueStorage.js +129 -0
- package/dist/internal/queueStorage.js.map +1 -0
- package/dist/internal/registry.d.ts +10 -0
- package/dist/internal/registry.d.ts.map +1 -0
- package/dist/internal/registry.js +24 -0
- package/dist/internal/registry.js.map +1 -0
- package/dist/internal/singletonRegistry.d.ts +2 -0
- package/dist/internal/singletonRegistry.d.ts.map +1 -0
- package/dist/internal/singletonRegistry.js +9 -0
- package/dist/internal/singletonRegistry.js.map +1 -0
- package/dist/internal/singletonRuntime.d.ts +2 -0
- package/dist/internal/singletonRuntime.d.ts.map +1 -0
- package/dist/internal/singletonRuntime.js +42 -0
- package/dist/internal/singletonRuntime.js.map +1 -0
- package/dist/internal/singletonStorage.d.ts +2 -0
- package/dist/internal/singletonStorage.d.ts.map +1 -0
- package/dist/internal/singletonStorage.js +34 -0
- package/dist/internal/singletonStorage.js.map +1 -0
- package/dist/internal/transport.d.ts +2 -0
- package/dist/internal/transport.d.ts.map +1 -0
- package/dist/internal/transport.js +49 -0
- package/dist/internal/transport.js.map +1 -0
- package/dist/internal/workflowRegistry.d.ts +2 -0
- package/dist/internal/workflowRegistry.d.ts.map +1 -0
- package/dist/internal/workflowRegistry.js +29 -0
- package/dist/internal/workflowRegistry.js.map +1 -0
- package/dist/internal/workflowRuntime.d.ts +2 -0
- package/dist/internal/workflowRuntime.d.ts.map +1 -0
- package/dist/internal/workflowRuntime.js +322 -0
- package/dist/internal/workflowRuntime.js.map +1 -0
- package/dist/internal/workflowStorage.d.ts +2 -0
- package/dist/internal/workflowStorage.d.ts.map +1 -0
- package/dist/internal/workflowStorage.js +113 -0
- package/dist/internal/workflowStorage.js.map +1 -0
- package/dist/internal/workflowWire.d.ts +2 -0
- package/dist/internal/workflowWire.d.ts.map +1 -0
- package/dist/internal/workflowWire.js +52 -0
- package/dist/internal/workflowWire.js.map +1 -0
- package/package.json +74 -3
- package/src/AlchemyCloudflareCluster.ts +200 -0
- package/src/CloudflareCluster.ts +704 -0
- package/src/CloudflareDurableObjectPrograms.ts +543 -0
- package/src/CloudflareDurableObjects.ts +430 -0
- package/src/CloudflarePersistedQueue.ts +189 -0
- package/src/CloudflareWorkflowEngine.ts +220 -0
- package/src/index.ts +38 -0
- package/src/internal/alchemyCluster.ts +120 -0
- package/src/internal/boundedMap.ts +15 -0
- package/src/internal/cluster.ts +57 -0
- package/src/internal/clusterName.ts +19 -0
- package/src/internal/entityKeepAlive.ts +121 -0
- package/src/internal/entityMailbox.ts +324 -0
- package/src/internal/entityRegistry.ts +46 -0
- package/src/internal/entityReply.ts +54 -0
- package/src/internal/entityRuntime.ts +1318 -0
- package/src/internal/entitySql.ts +60 -0
- package/src/internal/entityStorage.ts +168 -0
- package/src/internal/entityWire.ts +204 -0
- package/src/internal/queueRuntime.ts +174 -0
- package/src/internal/queueStorage.ts +255 -0
- package/src/internal/registry.ts +31 -0
- package/src/internal/singletonRegistry.ts +21 -0
- package/src/internal/singletonRuntime.ts +88 -0
- package/src/internal/singletonStorage.ts +64 -0
- package/src/internal/transport.ts +67 -0
- package/src/internal/workflowRegistry.ts +78 -0
- package/src/internal/workflowRuntime.ts +443 -0
- package/src/internal/workflowStorage.ts +234 -0
- package/src/internal/workflowWire.ts +123 -0
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SQLite mailbox primitives for a single entity Durable Object. Every
|
|
3
|
+
* operation returns an Effect whose body runs synchronously, so callers can
|
|
4
|
+
* compose them and wrap the composition in a storage transaction.
|
|
5
|
+
*
|
|
6
|
+
* @internal
|
|
7
|
+
*/
|
|
8
|
+
import type { SqlStorage } from "@cloudflare/workers-types"
|
|
9
|
+
import * as Effect from "effect/Effect"
|
|
10
|
+
|
|
11
|
+
/** @internal */
|
|
12
|
+
export const mailboxCapacity = 4096
|
|
13
|
+
|
|
14
|
+
/** @internal */
|
|
15
|
+
export const maximumEncodedSize = 2 * 1024 * 1024
|
|
16
|
+
|
|
17
|
+
/** @internal */
|
|
18
|
+
export class MailboxFullError extends Error {
|
|
19
|
+
readonly _tag = "MailboxFull"
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** @internal */
|
|
23
|
+
export class EncodedMessageTooLargeError extends Error {
|
|
24
|
+
readonly _tag = "EncodedMessageTooLarge"
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** @internal */
|
|
28
|
+
export type PersistResult = {
|
|
29
|
+
readonly _tag: "Success"
|
|
30
|
+
} | {
|
|
31
|
+
readonly _tag: "Duplicate"
|
|
32
|
+
readonly originalId: string
|
|
33
|
+
readonly processed: boolean
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type ExistingMessageRow = {
|
|
37
|
+
readonly request_id: string
|
|
38
|
+
readonly discard: number
|
|
39
|
+
readonly processed: number
|
|
40
|
+
readonly reply_to: string | null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
type CountRow = {
|
|
44
|
+
readonly count: number
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const textEncoder = new TextEncoder()
|
|
48
|
+
|
|
49
|
+
// A UTF-16 code unit encodes to at most 3 UTF-8 bytes, so most strings skip
|
|
50
|
+
// the byte-length copy.
|
|
51
|
+
const exceedsMaximumEncodedSize = (text: string): boolean =>
|
|
52
|
+
text.length * 3 > maximumEncodedSize && textEncoder.encode(text).byteLength > maximumEncodedSize
|
|
53
|
+
|
|
54
|
+
/** @internal */
|
|
55
|
+
export const persistRequest = (
|
|
56
|
+
sql: SqlStorage,
|
|
57
|
+
envelopeText: string,
|
|
58
|
+
primaryKey: string | null,
|
|
59
|
+
discard = false,
|
|
60
|
+
deliverAt: number | null = null,
|
|
61
|
+
replyTo: string | null = null
|
|
62
|
+
): Effect.Effect<PersistResult, MailboxFullError | EncodedMessageTooLargeError> =>
|
|
63
|
+
Effect.suspend((): Effect.Effect<PersistResult, MailboxFullError | EncodedMessageTooLargeError> => {
|
|
64
|
+
if (exceedsMaximumEncodedSize(envelopeText)) {
|
|
65
|
+
return Effect.fail(new EncodedMessageTooLargeError("Encoded entity request exceeds 2 MB"))
|
|
66
|
+
}
|
|
67
|
+
const envelope = JSON.parse(envelopeText) as { readonly _tag?: unknown; readonly requestId?: unknown }
|
|
68
|
+
if (envelope._tag !== "Request" || typeof envelope.requestId !== "string") {
|
|
69
|
+
throw new TypeError("Expected an encoded Request envelope")
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const existing = sql.exec<ExistingMessageRow>(
|
|
73
|
+
`SELECT m.request_id, m.discard, m.processed, m.reply_to
|
|
74
|
+
FROM cluster_messages m
|
|
75
|
+
WHERE m.request_id = ? OR (? IS NOT NULL AND m.message_id = ?)
|
|
76
|
+
LIMIT 1`,
|
|
77
|
+
envelope.requestId,
|
|
78
|
+
primaryKey,
|
|
79
|
+
primaryKey
|
|
80
|
+
).toArray()[0]
|
|
81
|
+
if (existing !== undefined) {
|
|
82
|
+
if (replyTo !== null && existing.discard === 0 && existing.processed === 0) {
|
|
83
|
+
const replyTos = decodeReplyTargets(existing.reply_to)
|
|
84
|
+
const requestId = envelope.requestId
|
|
85
|
+
if (!replyTos.some((target) => target.replyTo === replyTo && target.requestId === requestId)) {
|
|
86
|
+
replyTos.push({ replyTo, requestId })
|
|
87
|
+
}
|
|
88
|
+
sql.exec(
|
|
89
|
+
"UPDATE cluster_messages SET reply_to = ? WHERE request_id = ?",
|
|
90
|
+
JSON.stringify(replyTos),
|
|
91
|
+
existing.request_id
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
return Effect.succeed<PersistResult>({
|
|
95
|
+
_tag: "Duplicate",
|
|
96
|
+
originalId: existing.request_id,
|
|
97
|
+
processed: existing.processed === 1
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// A request counts against capacity until it is processed and, for streams,
|
|
102
|
+
// until its chunks are acknowledged. Two indexed counts instead of one
|
|
103
|
+
// `OR EXISTS` scan over the ever-growing dedup history.
|
|
104
|
+
const pending = sql.exec<CountRow>(
|
|
105
|
+
"SELECT COUNT(*) AS count FROM cluster_messages WHERE processed = 0"
|
|
106
|
+
).toArray()[0]?.count ?? 0
|
|
107
|
+
const unacked = pending >= mailboxCapacity ?
|
|
108
|
+
0 :
|
|
109
|
+
sql.exec<CountRow>(
|
|
110
|
+
`SELECT COUNT(DISTINCT r.request_id) AS count
|
|
111
|
+
FROM cluster_replies r
|
|
112
|
+
JOIN cluster_messages m ON m.request_id = r.request_id
|
|
113
|
+
WHERE r.kind = 'Chunk' AND r.acked = 0 AND m.processed = 1`
|
|
114
|
+
).toArray()[0]?.count ?? 0
|
|
115
|
+
if (pending + unacked >= mailboxCapacity) {
|
|
116
|
+
return Effect.fail(new MailboxFullError("Entity mailbox has reached its 4096 request capacity"))
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
sql.exec(
|
|
120
|
+
`INSERT INTO cluster_messages
|
|
121
|
+
(request_id, message_id, envelope, discard, processed, last_reply_id, deliver_at, reply_to)
|
|
122
|
+
VALUES (?, ?, ?, ?, 0, NULL, ?, ?)`,
|
|
123
|
+
envelope.requestId,
|
|
124
|
+
primaryKey,
|
|
125
|
+
envelopeText,
|
|
126
|
+
discard ? 1 : 0,
|
|
127
|
+
deliverAt,
|
|
128
|
+
replyTo === null ? null : JSON.stringify([{ replyTo, requestId: envelope.requestId }])
|
|
129
|
+
)
|
|
130
|
+
return Effect.succeed<PersistResult>({ _tag: "Success" })
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
/** @internal */
|
|
134
|
+
export const saveReply = (sql: SqlStorage, replyText: string): Effect.Effect<void, EncodedMessageTooLargeError> =>
|
|
135
|
+
Effect.suspend(() => {
|
|
136
|
+
if (exceedsMaximumEncodedSize(replyText)) {
|
|
137
|
+
return Effect.fail(new EncodedMessageTooLargeError("Encoded entity reply chunk exceeds 2 MB"))
|
|
138
|
+
}
|
|
139
|
+
const reply = JSON.parse(replyText) as {
|
|
140
|
+
readonly _tag?: unknown
|
|
141
|
+
readonly requestId?: unknown
|
|
142
|
+
readonly id?: unknown
|
|
143
|
+
readonly sequence?: unknown
|
|
144
|
+
}
|
|
145
|
+
if (
|
|
146
|
+
(reply._tag !== "Chunk" && reply._tag !== "WithExit") ||
|
|
147
|
+
typeof reply.requestId !== "string" ||
|
|
148
|
+
typeof reply.id !== "string"
|
|
149
|
+
) {
|
|
150
|
+
throw new TypeError("Expected an encoded Chunk or WithExit reply")
|
|
151
|
+
}
|
|
152
|
+
sql.exec(
|
|
153
|
+
`INSERT OR IGNORE INTO cluster_replies
|
|
154
|
+
(reply_id, request_id, reply, kind, sequence, acked)
|
|
155
|
+
VALUES (?, ?, ?, ?, ?, 0)`,
|
|
156
|
+
reply.id,
|
|
157
|
+
reply.requestId,
|
|
158
|
+
replyText,
|
|
159
|
+
reply._tag,
|
|
160
|
+
reply._tag === "Chunk" ? reply.sequence : null
|
|
161
|
+
)
|
|
162
|
+
sql.exec(
|
|
163
|
+
`UPDATE cluster_messages
|
|
164
|
+
SET last_reply_id = ?, processed = CASE WHEN ? = 'WithExit' THEN 1 ELSE processed END
|
|
165
|
+
WHERE request_id = ?`,
|
|
166
|
+
reply.id,
|
|
167
|
+
reply._tag,
|
|
168
|
+
reply.requestId
|
|
169
|
+
)
|
|
170
|
+
return Effect.void
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* A pinned caller of a scheduled ask: the reply is pushed to the `replyTo`
|
|
175
|
+
* entity under the caller's own request id, which it registered before
|
|
176
|
+
* sending, so the push cannot arrive ahead of the registration.
|
|
177
|
+
*
|
|
178
|
+
* @internal
|
|
179
|
+
*/
|
|
180
|
+
export interface ReplyTarget {
|
|
181
|
+
readonly replyTo: string
|
|
182
|
+
readonly requestId: string
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** @internal */
|
|
186
|
+
export interface StoredMessage {
|
|
187
|
+
readonly requestId: string
|
|
188
|
+
readonly envelope: string
|
|
189
|
+
readonly lastSentChunk: string | undefined
|
|
190
|
+
readonly discard: boolean
|
|
191
|
+
readonly deliverAt?: number | undefined
|
|
192
|
+
readonly replyTos?: ReadonlyArray<ReplyTarget> | undefined
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
type StoredMessageRow = {
|
|
196
|
+
readonly request_id: string
|
|
197
|
+
readonly envelope: string
|
|
198
|
+
readonly discard: number
|
|
199
|
+
readonly deliver_at: number | null
|
|
200
|
+
readonly reply_to: string | null
|
|
201
|
+
readonly last_reply: string | null
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const isReplyTarget = (item: unknown): item is ReplyTarget =>
|
|
205
|
+
typeof item === "object" && item !== null &&
|
|
206
|
+
typeof (item as ReplyTarget).replyTo === "string" &&
|
|
207
|
+
typeof (item as ReplyTarget).requestId === "string"
|
|
208
|
+
|
|
209
|
+
const decodeReplyTargets = (value: unknown): Array<ReplyTarget> => {
|
|
210
|
+
if (typeof value !== "string") return []
|
|
211
|
+
try {
|
|
212
|
+
const decoded = JSON.parse(value)
|
|
213
|
+
if (Array.isArray(decoded) && decoded.every(isReplyTarget)) return decoded
|
|
214
|
+
} catch {
|
|
215
|
+
// Malformed rows deliver nowhere instead of poisoning the replay.
|
|
216
|
+
}
|
|
217
|
+
return []
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const rowToMessage = (row: StoredMessageRow): StoredMessage => {
|
|
221
|
+
const replyTos = decodeReplyTargets(row.reply_to)
|
|
222
|
+
const message: StoredMessage = {
|
|
223
|
+
requestId: row.request_id,
|
|
224
|
+
envelope: row.envelope,
|
|
225
|
+
lastSentChunk: row.last_reply ?? undefined,
|
|
226
|
+
discard: row.discard === 1,
|
|
227
|
+
...(row.deliver_at === null ? undefined : { deliverAt: row.deliver_at }),
|
|
228
|
+
...(replyTos.length === 0 ? undefined : { replyTos })
|
|
229
|
+
}
|
|
230
|
+
return message
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** @internal */
|
|
234
|
+
export const loadUnprocessed = (sql: SqlStorage, now?: number): Effect.Effect<Array<StoredMessage>> =>
|
|
235
|
+
Effect.sync(() =>
|
|
236
|
+
sql.exec<StoredMessageRow>(
|
|
237
|
+
`SELECT m.request_id, m.envelope, m.discard, m.deliver_at, m.reply_to, r.reply AS last_reply
|
|
238
|
+
FROM cluster_messages m
|
|
239
|
+
LEFT JOIN cluster_replies r ON r.reply_id = m.last_reply_id
|
|
240
|
+
WHERE m.processed = 0 AND (m.deliver_at IS NULL OR m.deliver_at <= ?)
|
|
241
|
+
ORDER BY m.rowid ASC`,
|
|
242
|
+
now ?? Date.now()
|
|
243
|
+
).toArray().map(rowToMessage)
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
/** @internal */
|
|
247
|
+
export const loadMessage = (sql: SqlStorage, requestId: string): Effect.Effect<StoredMessage | undefined> =>
|
|
248
|
+
Effect.sync(() => {
|
|
249
|
+
const row = sql.exec<StoredMessageRow>(
|
|
250
|
+
`SELECT m.request_id, m.envelope, m.discard, m.deliver_at, m.reply_to, r.reply AS last_reply
|
|
251
|
+
FROM cluster_messages m
|
|
252
|
+
LEFT JOIN cluster_replies r ON r.reply_id = m.last_reply_id
|
|
253
|
+
WHERE m.request_id = ?
|
|
254
|
+
LIMIT 1`,
|
|
255
|
+
requestId
|
|
256
|
+
).toArray()[0]
|
|
257
|
+
return row === undefined ? undefined : rowToMessage(row)
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
/** @internal */
|
|
261
|
+
export interface NextReply {
|
|
262
|
+
readonly reply: string
|
|
263
|
+
readonly kind: "Chunk" | "WithExit"
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
type NextReplyRow = {
|
|
267
|
+
readonly reply: string
|
|
268
|
+
readonly kind: NextReply["kind"]
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** @internal */
|
|
272
|
+
export const loadNextReply = (sql: SqlStorage, requestId: string): Effect.Effect<NextReply | undefined> =>
|
|
273
|
+
Effect.sync(() => {
|
|
274
|
+
const row = sql.exec<NextReplyRow>(
|
|
275
|
+
`SELECT reply, kind
|
|
276
|
+
FROM cluster_replies
|
|
277
|
+
WHERE request_id = ? AND kind = 'Chunk' AND acked = 0
|
|
278
|
+
ORDER BY sequence ASC
|
|
279
|
+
LIMIT 1`,
|
|
280
|
+
requestId
|
|
281
|
+
).toArray()[0] ?? sql.exec<NextReplyRow>(
|
|
282
|
+
`SELECT reply, kind
|
|
283
|
+
FROM cluster_replies
|
|
284
|
+
WHERE request_id = ? AND kind = 'WithExit'
|
|
285
|
+
LIMIT 1`,
|
|
286
|
+
requestId
|
|
287
|
+
).toArray()[0]
|
|
288
|
+
return row
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
/** @internal */
|
|
292
|
+
export const completeTell = (sql: SqlStorage, requestId: string): Effect.Effect<void> =>
|
|
293
|
+
Effect.sync(() => {
|
|
294
|
+
sql.exec(
|
|
295
|
+
`UPDATE cluster_messages
|
|
296
|
+
SET processed = 1, last_reply_id = NULL
|
|
297
|
+
WHERE request_id = ?`,
|
|
298
|
+
requestId
|
|
299
|
+
)
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
/** @internal */
|
|
303
|
+
export const ackChunk = (sql: SqlStorage, requestId: string, replyId: string): Effect.Effect<void> =>
|
|
304
|
+
Effect.sync(() => {
|
|
305
|
+
sql.exec(
|
|
306
|
+
`UPDATE cluster_replies
|
|
307
|
+
SET acked = 1
|
|
308
|
+
WHERE request_id = ? AND reply_id = ? AND kind = 'Chunk'`,
|
|
309
|
+
requestId,
|
|
310
|
+
replyId
|
|
311
|
+
)
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
/** @internal */
|
|
315
|
+
export const clearReplies = (sql: SqlStorage, requestId: string): Effect.Effect<void> =>
|
|
316
|
+
Effect.sync(() => {
|
|
317
|
+
sql.exec("DELETE FROM cluster_replies WHERE request_id = ?", requestId)
|
|
318
|
+
sql.exec(
|
|
319
|
+
`UPDATE cluster_messages
|
|
320
|
+
SET processed = 0, last_reply_id = NULL
|
|
321
|
+
WHERE request_id = ?`,
|
|
322
|
+
requestId
|
|
323
|
+
)
|
|
324
|
+
})
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/** @internal */
|
|
2
|
+
import type * as Entity from "effect/cluster/Entity"
|
|
3
|
+
import type * as Context from "effect/Context"
|
|
4
|
+
import type * as Effect from "effect/Effect"
|
|
5
|
+
import { makeRegistry } from "./registry.ts"
|
|
6
|
+
|
|
7
|
+
export interface EntityRegistration {
|
|
8
|
+
readonly entity: Entity.Entity<any, any>
|
|
9
|
+
readonly build: Effect.Effect<Record<string, (request: any) => any>, never, never>
|
|
10
|
+
readonly options: {
|
|
11
|
+
readonly concurrency?: number | "unbounded" | undefined
|
|
12
|
+
readonly disableFatalDefects?: boolean | undefined
|
|
13
|
+
readonly defectRetryPolicy?: unknown
|
|
14
|
+
readonly spanAttributes?: Record<string, string> | undefined
|
|
15
|
+
} | undefined
|
|
16
|
+
readonly context: Context.Context<never>
|
|
17
|
+
/** Interval of the keep-alive heartbeat alarm, in milliseconds. */
|
|
18
|
+
readonly keepAliveHeartbeat: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const registry = makeRegistry<EntityRegistration>()
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Default interval of the keep-alive heartbeat alarm.
|
|
25
|
+
*
|
|
26
|
+
* @internal
|
|
27
|
+
*/
|
|
28
|
+
export const defaultKeepAliveHeartbeatMillis = 30_000
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The heartbeat interval of an entity type. An alarm can wake an object whose
|
|
32
|
+
* type a deploy removed, so a missing registration falls back to the default.
|
|
33
|
+
*
|
|
34
|
+
* @internal
|
|
35
|
+
*/
|
|
36
|
+
export const keepAliveHeartbeatMillis = (entityType: string): number =>
|
|
37
|
+
registry.get(entityType)?.keepAliveHeartbeat ?? defaultKeepAliveHeartbeatMillis
|
|
38
|
+
|
|
39
|
+
/** @internal */
|
|
40
|
+
export const getEntityRegistration: (type: string) => EntityRegistration | undefined = registry.get
|
|
41
|
+
|
|
42
|
+
/** @internal */
|
|
43
|
+
export const registerEntity: (type: string, registration: EntityRegistration) => boolean = registry.register
|
|
44
|
+
|
|
45
|
+
/** @internal */
|
|
46
|
+
export const unregisterEntity: (type: string, registration: EntityRegistration) => void = registry.unregister
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/** @internal */
|
|
2
|
+
import * as Context from "effect/Context"
|
|
3
|
+
import * as Deferred from "effect/Deferred"
|
|
4
|
+
import * as Effect from "effect/Effect"
|
|
5
|
+
|
|
6
|
+
/** @internal */
|
|
7
|
+
export class CurrentEntityName extends Context.Service<CurrentEntityName, string>()(
|
|
8
|
+
"@effect/platform-cloudflare/CurrentEntityName"
|
|
9
|
+
) {}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Completes the delayed asks made by one entity Durable Object's handlers.
|
|
13
|
+
* The destination object pushes the stored reply back over the caller's
|
|
14
|
+
* `deliverReply` RPC, which finds the waiting `Deferred` here.
|
|
15
|
+
*
|
|
16
|
+
* @internal
|
|
17
|
+
*/
|
|
18
|
+
export interface EntityReplyRegistry {
|
|
19
|
+
readonly register: (requestId: string, waiter: Deferred.Deferred<string>) => void
|
|
20
|
+
readonly unregister: (requestId: string, waiter: Deferred.Deferred<string>) => void
|
|
21
|
+
readonly deliver: (requestId: string, reply: string) => boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** @internal */
|
|
25
|
+
export const makeReplyRegistry = (): EntityReplyRegistry => {
|
|
26
|
+
const waiters = new Map<string, Set<Deferred.Deferred<string>>>()
|
|
27
|
+
return {
|
|
28
|
+
register(requestId, waiter) {
|
|
29
|
+
const registered = waiters.get(requestId) ?? new Set()
|
|
30
|
+
registered.add(waiter)
|
|
31
|
+
waiters.set(requestId, registered)
|
|
32
|
+
},
|
|
33
|
+
unregister(requestId, waiter) {
|
|
34
|
+
const registered = waiters.get(requestId)
|
|
35
|
+
if (registered === undefined) return
|
|
36
|
+
registered.delete(waiter)
|
|
37
|
+
if (registered.size === 0) waiters.delete(requestId)
|
|
38
|
+
},
|
|
39
|
+
deliver(requestId, reply) {
|
|
40
|
+
const registered = waiters.get(requestId)
|
|
41
|
+
if (registered === undefined) return false
|
|
42
|
+
waiters.delete(requestId)
|
|
43
|
+
for (const waiter of registered) {
|
|
44
|
+
Deferred.doneUnsafe(waiter, Effect.succeed(reply))
|
|
45
|
+
}
|
|
46
|
+
return true
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** @internal */
|
|
52
|
+
export class CurrentReplyRegistry extends Context.Service<CurrentReplyRegistry, EntityReplyRegistry>()(
|
|
53
|
+
"@effect/platform-cloudflare/CurrentReplyRegistry"
|
|
54
|
+
) {}
|