@livestore/sync-cf 0.4.0-dev.9 → 0.5.0-dev.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -8
- package/dist/.tsbuildinfo +1 -1
- package/dist/cf-worker/do/durable-object.d.ts +1 -1
- package/dist/cf-worker/do/durable-object.d.ts.map +1 -1
- package/dist/cf-worker/do/durable-object.js +36 -18
- package/dist/cf-worker/do/durable-object.js.map +1 -1
- package/dist/cf-worker/do/layer.d.ts +24 -20
- package/dist/cf-worker/do/layer.d.ts.map +1 -1
- package/dist/cf-worker/do/layer.js +57 -24
- package/dist/cf-worker/do/layer.js.map +1 -1
- package/dist/cf-worker/do/pull.d.ts +8 -3
- package/dist/cf-worker/do/pull.d.ts.map +1 -1
- package/dist/cf-worker/do/pull.js +26 -21
- package/dist/cf-worker/do/pull.js.map +1 -1
- package/dist/cf-worker/do/push.d.ts +20 -6
- package/dist/cf-worker/do/push.d.ts.map +1 -1
- package/dist/cf-worker/do/push.js +84 -56
- package/dist/cf-worker/do/push.js.map +1 -1
- package/dist/cf-worker/do/push.test.d.ts +2 -0
- package/dist/cf-worker/do/push.test.d.ts.map +1 -0
- package/dist/cf-worker/do/push.test.js +30 -0
- package/dist/cf-worker/do/push.test.js.map +1 -0
- package/dist/cf-worker/do/sqlite.d.ts +49 -52
- package/dist/cf-worker/do/sqlite.d.ts.map +1 -1
- package/dist/cf-worker/do/sqlite.js +14 -5
- package/dist/cf-worker/do/sqlite.js.map +1 -1
- package/dist/cf-worker/do/sync-storage.d.ts +15 -9
- package/dist/cf-worker/do/sync-storage.d.ts.map +1 -1
- package/dist/cf-worker/do/sync-storage.js +111 -25
- package/dist/cf-worker/do/sync-storage.js.map +1 -1
- package/dist/cf-worker/do/transport/do-rpc-server.d.ts +2 -2
- package/dist/cf-worker/do/transport/do-rpc-server.d.ts.map +1 -1
- package/dist/cf-worker/do/transport/do-rpc-server.js +31 -23
- package/dist/cf-worker/do/transport/do-rpc-server.js.map +1 -1
- package/dist/cf-worker/do/transport/http-rpc-server.d.ts +5 -3
- package/dist/cf-worker/do/transport/http-rpc-server.d.ts.map +1 -1
- package/dist/cf-worker/do/transport/http-rpc-server.js +28 -19
- package/dist/cf-worker/do/transport/http-rpc-server.js.map +1 -1
- package/dist/cf-worker/do/transport/ws-rpc-server.d.ts +3 -2
- package/dist/cf-worker/do/transport/ws-rpc-server.d.ts.map +1 -1
- package/dist/cf-worker/do/transport/ws-rpc-server.js +32 -10
- package/dist/cf-worker/do/transport/ws-rpc-server.js.map +1 -1
- package/dist/cf-worker/shared.d.ts +124 -34
- package/dist/cf-worker/shared.d.ts.map +1 -1
- package/dist/cf-worker/shared.js +52 -17
- package/dist/cf-worker/shared.js.map +1 -1
- package/dist/cf-worker/worker.d.ts +47 -39
- package/dist/cf-worker/worker.d.ts.map +1 -1
- package/dist/cf-worker/worker.js +54 -35
- package/dist/cf-worker/worker.js.map +1 -1
- package/dist/client/transport/do-rpc-client.d.ts +13 -4
- package/dist/client/transport/do-rpc-client.d.ts.map +1 -1
- package/dist/client/transport/do-rpc-client.js +74 -28
- package/dist/client/transport/do-rpc-client.js.map +1 -1
- package/dist/client/transport/http-rpc-client.d.ts +3 -3
- package/dist/client/transport/http-rpc-client.d.ts.map +1 -1
- package/dist/client/transport/http-rpc-client.js +51 -22
- package/dist/client/transport/http-rpc-client.js.map +1 -1
- package/dist/client/transport/ws-rpc-client.d.ts +4 -3
- package/dist/client/transport/ws-rpc-client.d.ts.map +1 -1
- package/dist/client/transport/ws-rpc-client.js +40 -22
- package/dist/client/transport/ws-rpc-client.js.map +1 -1
- package/dist/common/constants.d.ts +7 -0
- package/dist/common/constants.d.ts.map +1 -0
- package/dist/common/constants.js +17 -0
- package/dist/common/constants.js.map +1 -0
- package/dist/common/do-rpc-schema.d.ts +42 -43
- package/dist/common/do-rpc-schema.d.ts.map +1 -1
- package/dist/common/do-rpc-schema.js +5 -5
- package/dist/common/do-rpc-schema.js.map +1 -1
- package/dist/common/http-rpc-schema.d.ts +37 -38
- package/dist/common/http-rpc-schema.d.ts.map +1 -1
- package/dist/common/http-rpc-schema.js +7 -7
- package/dist/common/http-rpc-schema.js.map +1 -1
- package/dist/common/mod.d.ts +5 -3
- package/dist/common/mod.d.ts.map +1 -1
- package/dist/common/mod.js +4 -2
- package/dist/common/mod.js.map +1 -1
- package/dist/common/sync-message-types.d.ts +113 -130
- package/dist/common/sync-message-types.d.ts.map +1 -1
- package/dist/common/sync-message-types.js +29 -17
- package/dist/common/sync-message-types.js.map +1 -1
- package/dist/common/ws-rpc-schema.d.ts +34 -35
- package/dist/common/ws-rpc-schema.d.ts.map +1 -1
- package/dist/common/ws-rpc-schema.js +5 -5
- package/dist/common/ws-rpc-schema.js.map +1 -1
- package/package.json +57 -15
- package/src/cf-worker/do/durable-object.ts +59 -41
- package/src/cf-worker/do/layer.ts +153 -88
- package/src/cf-worker/do/pull.ts +48 -31
- package/src/cf-worker/do/push.test.ts +40 -0
- package/src/cf-worker/do/push.ts +163 -125
- package/src/cf-worker/do/sqlite.ts +15 -5
- package/src/cf-worker/do/sync-storage.ts +171 -38
- package/src/cf-worker/do/transport/do-rpc-server.ts +40 -40
- package/src/cf-worker/do/transport/http-rpc-server.ts +37 -20
- package/src/cf-worker/do/transport/ws-rpc-server.ts +44 -16
- package/src/cf-worker/shared.ts +152 -36
- package/src/cf-worker/worker.ts +110 -58
- package/src/client/transport/do-rpc-client.ts +106 -40
- package/src/client/transport/http-rpc-client.ts +74 -38
- package/src/client/transport/ws-rpc-client.ts +55 -29
- package/src/common/constants.ts +18 -0
- package/src/common/do-rpc-schema.ts +6 -5
- package/src/common/http-rpc-schema.ts +8 -7
- package/src/common/mod.ts +4 -3
- package/src/common/sync-message-types.ts +19 -19
- package/src/common/ws-rpc-schema.ts +6 -5
- package/dist/cf-worker/do/ws-chunking.d.ts +0 -22
- package/dist/cf-worker/do/ws-chunking.d.ts.map +0 -1
- package/dist/cf-worker/do/ws-chunking.js +0 -49
- package/dist/cf-worker/do/ws-chunking.js.map +0 -1
- package/src/cf-worker/do/ws-chunking.ts +0 -76
package/package.json
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@livestore/sync-cf",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0-dev.0",
|
|
4
|
+
"license": "Apache-2.0",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/livestorejs/livestore.git"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"package.json",
|
|
12
|
+
"src",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
4
15
|
"type": "module",
|
|
5
16
|
"sideEffects": false,
|
|
6
17
|
"exports": {
|
|
@@ -8,23 +19,54 @@
|
|
|
8
19
|
"./common": "./dist/common/mod.js",
|
|
9
20
|
"./cf-worker": "./dist/cf-worker/mod.js"
|
|
10
21
|
},
|
|
11
|
-
"dependencies": {
|
|
12
|
-
"@cloudflare/workers-types": "4.20250923.0",
|
|
13
|
-
"@livestore/common": "0.4.0-dev.9",
|
|
14
|
-
"@livestore/common-cf": "0.4.0-dev.9",
|
|
15
|
-
"@livestore/utils": "0.4.0-dev.9"
|
|
16
|
-
},
|
|
17
|
-
"files": [
|
|
18
|
-
"dist",
|
|
19
|
-
"src",
|
|
20
|
-
"package.json",
|
|
21
|
-
"README.md"
|
|
22
|
-
],
|
|
23
|
-
"license": "Apache-2.0",
|
|
24
22
|
"publishConfig": {
|
|
25
23
|
"access": "public"
|
|
26
24
|
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@cloudflare/workers-types": "4.20251118.0",
|
|
27
|
+
"@livestore/utils": "^0.5.0-dev.0",
|
|
28
|
+
"@livestore/common": "^0.5.0-dev.0",
|
|
29
|
+
"@livestore/common-cf": "^0.5.0-dev.0"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@effect/opentelemetry": "4.0.0-rc.111",
|
|
33
|
+
"@effect/platform-browser": "4.0.0-rc.111",
|
|
34
|
+
"@effect/platform-bun": "4.0.0-rc.111",
|
|
35
|
+
"@effect/platform-node": "4.0.0-rc.111",
|
|
36
|
+
"@effect/platform-node-shared": "4.0.0-rc.111",
|
|
37
|
+
"@effect/vitest": "4.0.0-rc.111",
|
|
38
|
+
"@opentelemetry/api": "1.9.0",
|
|
39
|
+
"@opentelemetry/resources": "2.2.0",
|
|
40
|
+
"@standard-schema/spec": "1.1.0",
|
|
41
|
+
"effect": "4.0.0-rc.111",
|
|
42
|
+
"vitest": "4.1.9",
|
|
43
|
+
"@livestore/utils-dev": "^0.5.0-dev.0"
|
|
44
|
+
},
|
|
45
|
+
"peerDependencies": {
|
|
46
|
+
"@effect/opentelemetry": "^4.0.0-rc.111",
|
|
47
|
+
"@effect/platform-browser": "^4.0.0-rc.111",
|
|
48
|
+
"@effect/platform-bun": "^4.0.0-rc.111",
|
|
49
|
+
"@effect/platform-node": "^4.0.0-rc.111",
|
|
50
|
+
"@effect/platform-node-shared": "^4.0.0-rc.111",
|
|
51
|
+
"@effect/vitest": "^4.0.0-rc.111",
|
|
52
|
+
"@opentelemetry/api": "^1.9.0",
|
|
53
|
+
"@opentelemetry/resources": "^2.2.0",
|
|
54
|
+
"@standard-schema/spec": "^1.1.0",
|
|
55
|
+
"effect": "^4.0.0-rc.111"
|
|
56
|
+
},
|
|
57
|
+
"$genie": {
|
|
58
|
+
"source": "package.json.genie.ts",
|
|
59
|
+
"warning": "DO NOT EDIT - changes will be overwritten",
|
|
60
|
+
"workspaceClosureDirs": [
|
|
61
|
+
"packages/@livestore/common",
|
|
62
|
+
"packages/@livestore/common-cf",
|
|
63
|
+
"packages/@livestore/sync-cf",
|
|
64
|
+
"packages/@livestore/utils",
|
|
65
|
+
"packages/@livestore/utils-dev",
|
|
66
|
+
"packages/@livestore/webmesh"
|
|
67
|
+
]
|
|
68
|
+
},
|
|
27
69
|
"scripts": {
|
|
28
|
-
"test": "
|
|
70
|
+
"test": "vitest run"
|
|
29
71
|
}
|
|
30
72
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/// <reference types="@cloudflare/workers-types" />
|
|
2
2
|
|
|
3
3
|
import { DurableObject } from 'cloudflare:workers'
|
|
4
|
+
|
|
4
5
|
import { type CfTypes, setupDurableObjectWebSocketRpc } from '@livestore/common-cf'
|
|
5
6
|
import { CfDeclare } from '@livestore/common-cf/declare'
|
|
6
7
|
import {
|
|
@@ -8,32 +9,34 @@ import {
|
|
|
8
9
|
FetchHttpClient,
|
|
9
10
|
Layer,
|
|
10
11
|
Logger,
|
|
11
|
-
LogLevel,
|
|
12
12
|
Otlp,
|
|
13
|
+
References,
|
|
13
14
|
RpcMessage,
|
|
14
15
|
Schema,
|
|
15
16
|
type Scope,
|
|
16
17
|
} from '@livestore/utils/effect'
|
|
18
|
+
|
|
17
19
|
import {
|
|
18
20
|
type Env,
|
|
21
|
+
extractForwardedHeaders,
|
|
19
22
|
type MakeDurableObjectClassOptions,
|
|
20
23
|
matchSyncRequest,
|
|
21
24
|
type SyncBackendRpcInterface,
|
|
22
25
|
WebSocketAttachmentSchema,
|
|
23
26
|
} from '../shared.ts'
|
|
24
|
-
import
|
|
27
|
+
import * as DoCtx from './layer.ts'
|
|
25
28
|
import { createDoRpcHandler } from './transport/do-rpc-server.ts'
|
|
26
29
|
import { createHttpRpcHandler } from './transport/http-rpc-server.ts'
|
|
27
30
|
import { makeRpcServer } from './transport/ws-rpc-server.ts'
|
|
28
31
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
const Response = CfDeclare.Response
|
|
33
|
+
const WebSocketPair = CfDeclare.WebSocketPair
|
|
34
|
+
const WebSocketRequestResponsePair = CfDeclare.WebSocketRequestResponsePair
|
|
35
|
+
|
|
36
|
+
/** Module-scoped JSON encoder; keeping the sync codec out of Effect generators avoids `schemaSyncInEffect`. */
|
|
37
|
+
const jsonStringify = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown))
|
|
35
38
|
|
|
36
|
-
const DurableObjectBase = DurableObject
|
|
39
|
+
const DurableObjectBase = DurableObject as any as new (
|
|
37
40
|
state: CfTypes.DurableObjectState,
|
|
38
41
|
env: Env,
|
|
39
42
|
) => CfTypes.DurableObject & { ctx: CfTypes.DurableObjectState; env: Env }
|
|
@@ -48,15 +51,15 @@ export type MakeDurableObjectClass = (options?: MakeDurableObjectClassOptions) =
|
|
|
48
51
|
|
|
49
52
|
/**
|
|
50
53
|
* Creates a Durable Object class for handling WebSocket-based sync.
|
|
51
|
-
* A sync
|
|
52
|
-
*
|
|
54
|
+
* A sync Durable Object is uniquely scoped to a specific `storeId`.
|
|
55
|
+
*
|
|
53
56
|
* The sync DO supports 3 transport modes:
|
|
54
57
|
* - HTTP JSON-RPC
|
|
55
58
|
* - WebSocket
|
|
56
59
|
* - Durable Object RPC calls (only works in combination with `@livestore/adapter-cf`)
|
|
57
60
|
*
|
|
58
61
|
* Example:
|
|
59
|
-
*
|
|
62
|
+
*
|
|
60
63
|
* ```ts
|
|
61
64
|
* // In your Cloudflare Worker file
|
|
62
65
|
* import { makeDurableObject } from '@livestore/sync-cf/cf-worker'
|
|
@@ -85,17 +88,16 @@ export type MakeDurableObjectClass = (options?: MakeDurableObjectClassOptions) =
|
|
|
85
88
|
export const makeDurableObject: MakeDurableObjectClass = (options) => {
|
|
86
89
|
const enabledTransports = options?.enabledTransports ?? new Set(['http', 'ws', 'do-rpc'])
|
|
87
90
|
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
: Layer.empty
|
|
91
|
+
const Observability: Layer.Layer<never> =
|
|
92
|
+
options?.otel?.baseUrl !== undefined
|
|
93
|
+
? Otlp.layerJson({
|
|
94
|
+
baseUrl: options.otel.baseUrl,
|
|
95
|
+
tracerExportInterval: 50,
|
|
96
|
+
resource: {
|
|
97
|
+
serviceName: options.otel.serviceName ?? 'sync-cf-do',
|
|
98
|
+
},
|
|
99
|
+
}).pipe(Layer.provide(FetchHttpClient.layer))
|
|
100
|
+
: Layer.empty
|
|
99
101
|
|
|
100
102
|
return class SyncBackendDOBase extends DurableObjectBase implements SyncBackendRpcInterface {
|
|
101
103
|
__DURABLE_OBJECT_BRAND = 'SyncBackendDOBase' as never
|
|
@@ -106,7 +108,7 @@ export const makeDurableObject: MakeDurableObjectClass = (options) => {
|
|
|
106
108
|
const WebSocketRpcServerLive = makeRpcServer({ doSelf: this, doOptions: options })
|
|
107
109
|
|
|
108
110
|
// This registers the `webSocketMessage` and `webSocketClose` handlers
|
|
109
|
-
if (enabledTransports.has('ws')) {
|
|
111
|
+
if (enabledTransports.has('ws') === true) {
|
|
110
112
|
setupDurableObjectWebSocketRpc({
|
|
111
113
|
doSelf: this,
|
|
112
114
|
rpcLayer: WebSocketRpcServerLive,
|
|
@@ -117,7 +119,7 @@ export const makeDurableObject: MakeDurableObjectClass = (options) => {
|
|
|
117
119
|
if (request._tag === 'Request' && request.tag === 'SyncWsRpc.Pull') {
|
|
118
120
|
// Is Pull request: add requestId to pullRequestIds
|
|
119
121
|
const attachment = ws.deserializeAttachment()
|
|
120
|
-
const { pullRequestIds, ...rest } = Schema.
|
|
122
|
+
const { pullRequestIds, ...rest } = Schema.decodeUnknownSync(WebSocketAttachmentSchema)(attachment)
|
|
121
123
|
ws.serializeAttachment(
|
|
122
124
|
Schema.encodeSync(WebSocketAttachmentSchema)({
|
|
123
125
|
...rest,
|
|
@@ -127,7 +129,7 @@ export const makeDurableObject: MakeDurableObjectClass = (options) => {
|
|
|
127
129
|
} else if (request._tag === 'Interrupt') {
|
|
128
130
|
// Is Interrupt request: remove requestId from pullRequestIds
|
|
129
131
|
const attachment = ws.deserializeAttachment()
|
|
130
|
-
const { pullRequestIds, ...rest } = Schema.
|
|
132
|
+
const { pullRequestIds, ...rest } = Schema.decodeUnknownSync(WebSocketAttachmentSchema)(attachment)
|
|
131
133
|
ws.serializeAttachment(
|
|
132
134
|
Schema.encodeSync(WebSocketAttachmentSchema)({
|
|
133
135
|
...rest,
|
|
@@ -142,8 +144,8 @@ export const makeDurableObject: MakeDurableObjectClass = (options) => {
|
|
|
142
144
|
}
|
|
143
145
|
}
|
|
144
146
|
|
|
145
|
-
fetch = async (request: Request): Promise<Response> =>
|
|
146
|
-
Effect.gen(this, function* () {
|
|
147
|
+
override fetch = async (request: CfTypes.Request): Promise<CfTypes.Response> =>
|
|
148
|
+
Effect.gen({ self: this }, function* () {
|
|
147
149
|
const searchParams = matchSyncRequest(request)
|
|
148
150
|
if (searchParams === undefined) {
|
|
149
151
|
throw new Error('No search params found in request URL')
|
|
@@ -155,17 +157,28 @@ export const makeDurableObject: MakeDurableObjectClass = (options) => {
|
|
|
155
157
|
throw new Error(`Transport ${transport} is not enabled (based on \`options.enabledTransports\`)`)
|
|
156
158
|
}
|
|
157
159
|
|
|
160
|
+
// Extract headers to forward based on configuration (available for all transports)
|
|
161
|
+
const headers = extractForwardedHeaders(request, options?.forwardHeaders)
|
|
162
|
+
|
|
158
163
|
if (transport === 'http') {
|
|
159
|
-
return yield* this.handleHttp(request)
|
|
164
|
+
return yield* this.handleHttp(request, headers)
|
|
160
165
|
}
|
|
161
166
|
|
|
162
167
|
if (transport === 'ws') {
|
|
163
168
|
const { 0: client, 1: server } = new WebSocketPair()
|
|
164
169
|
|
|
165
170
|
// Since we're using websocket hibernation, we need to remember the storeId for subsequent `webSocketMessage` calls
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
171
|
+
// Also store forwarded headers so they're available after hibernation resume
|
|
172
|
+
// Encoding known-valid domain data: an encode failure is an invariant violation (a defect),
|
|
173
|
+
// so `Effect.orDie` is the correct modeling. serializeAttachment takes the value synchronously,
|
|
174
|
+
// so resolve the encoded value first, then pass it in.
|
|
175
|
+
const attachment = yield* Schema.encodeEffect(WebSocketAttachmentSchema)({
|
|
176
|
+
storeId,
|
|
177
|
+
payload,
|
|
178
|
+
pullRequestIds: [],
|
|
179
|
+
headers,
|
|
180
|
+
}).pipe(Effect.orDie)
|
|
181
|
+
server.serializeAttachment(attachment)
|
|
169
182
|
|
|
170
183
|
// See https://developers.cloudflare.com/durable-objects/examples/websocket-hibernation-server
|
|
171
184
|
|
|
@@ -173,10 +186,7 @@ export const makeDurableObject: MakeDurableObjectClass = (options) => {
|
|
|
173
186
|
|
|
174
187
|
// Ping requests are sent by Effect RPC internally
|
|
175
188
|
this.ctx.setWebSocketAutoResponse(
|
|
176
|
-
new WebSocketRequestResponsePair(
|
|
177
|
-
JSON.stringify(RpcMessage.constPing),
|
|
178
|
-
JSON.stringify(RpcMessage.constPong),
|
|
179
|
-
),
|
|
189
|
+
new WebSocketRequestResponsePair(jsonStringify(RpcMessage.constPing), jsonStringify(RpcMessage.constPong)),
|
|
180
190
|
)
|
|
181
191
|
|
|
182
192
|
return new Response(null, {
|
|
@@ -193,11 +203,11 @@ export const makeDurableObject: MakeDurableObjectClass = (options) => {
|
|
|
193
203
|
})
|
|
194
204
|
}).pipe(
|
|
195
205
|
Effect.tapCauseLogPretty, // Also log errors to console before catching them
|
|
196
|
-
Effect.
|
|
206
|
+
Effect.catchCause((cause) =>
|
|
197
207
|
Effect.succeed(new Response('Error', { status: 500, statusText: cause.toString() })),
|
|
198
208
|
),
|
|
199
209
|
Effect.withSpan('@livestore/sync-cf:durable-object:fetch'),
|
|
200
|
-
Effect.provide(DoCtx.
|
|
210
|
+
Effect.provide(DoCtx.layer({ doSelf: this, doOptions: options, from: request })),
|
|
201
211
|
this.runEffectAsPromise,
|
|
202
212
|
)
|
|
203
213
|
|
|
@@ -220,16 +230,24 @@ export const makeDurableObject: MakeDurableObjectClass = (options) => {
|
|
|
220
230
|
*
|
|
221
231
|
* Requires the `enable_request_signal` compatibility flag to properly support `pull` streaming responses
|
|
222
232
|
*/
|
|
223
|
-
private handleHttp = (request: CfTypes.Request) =>
|
|
233
|
+
private handleHttp = (request: CfTypes.Request, forwardedHeaders: Record<string, string> | undefined) =>
|
|
224
234
|
createHttpRpcHandler({
|
|
225
235
|
request,
|
|
236
|
+
...(options?.http?.responseHeaders !== undefined ? { responseHeaders: options.http.responseHeaders } : {}),
|
|
237
|
+
...(forwardedHeaders !== undefined ? { forwardedHeaders } : {}),
|
|
226
238
|
}).pipe(Effect.withSpan('@livestore/sync-cf:durable-object:handleHttp'))
|
|
227
239
|
|
|
228
240
|
private runEffectAsPromise = <T, E = never>(effect: Effect.Effect<T, E, Scope.Scope>): Promise<T> =>
|
|
229
241
|
effect.pipe(
|
|
230
242
|
Effect.tapCauseLogPretty,
|
|
231
|
-
|
|
232
|
-
Effect.provide(
|
|
243
|
+
Effect.annotateLogs({ thread: 'SyncDo' }),
|
|
244
|
+
Effect.provide(
|
|
245
|
+
Layer.mergeAll(
|
|
246
|
+
Observability,
|
|
247
|
+
Logger.layer([Logger.consoleStructured]),
|
|
248
|
+
Layer.succeed(References.MinimumLogLevel, 'Debug'),
|
|
249
|
+
),
|
|
250
|
+
),
|
|
233
251
|
Effect.scoped,
|
|
234
252
|
Effect.runPromise,
|
|
235
253
|
)
|
|
@@ -1,16 +1,18 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { EventSequenceNumber, State } from '@livestore/common/schema'
|
|
1
|
+
import { UnknownError } from '@livestore/common'
|
|
3
2
|
import type { CfTypes } from '@livestore/common-cf'
|
|
3
|
+
import { EventSequenceNumber, State } from '@livestore/common/schema'
|
|
4
4
|
import { shouldNeverHappen } from '@livestore/utils'
|
|
5
|
-
import { Effect, Predicate } from '@livestore/utils/effect'
|
|
5
|
+
import { Context, Effect, Layer, Predicate, Semaphore } from '@livestore/utils/effect'
|
|
6
6
|
import { nanoid } from '@livestore/utils/nanoid'
|
|
7
|
-
|
|
7
|
+
|
|
8
|
+
import type { Env, MakeDurableObjectClassOptions } from '../shared.ts'
|
|
8
9
|
import { contextTable, eventlogTable } from './sqlite.ts'
|
|
9
|
-
import { makeStorage } from './sync-storage.ts'
|
|
10
|
+
import { makeStorage, type StorageEngine, type SyncStorage } from './sync-storage.ts'
|
|
10
11
|
|
|
11
12
|
const CacheSymbol = Symbol('Cache')
|
|
13
|
+
const InitializationSemaphoreSymbol = Symbol('InitializationSemaphore')
|
|
12
14
|
|
|
13
|
-
export interface
|
|
15
|
+
export interface Options {
|
|
14
16
|
doSelf: CfTypes.DurableObject & {
|
|
15
17
|
ctx: CfTypes.DurableObjectState
|
|
16
18
|
env: Env
|
|
@@ -19,89 +21,152 @@ export interface DoCtxInput {
|
|
|
19
21
|
from: CfTypes.Request | { storeId: string }
|
|
20
22
|
}
|
|
21
23
|
|
|
22
|
-
export
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
24
|
+
export type DoCtxInput = Options
|
|
25
|
+
|
|
26
|
+
export interface Service {
|
|
27
|
+
readonly storeId: string
|
|
28
|
+
readonly backendId: string
|
|
29
|
+
readonly currentHeadRef: { current: EventSequenceNumber.Global.Type }
|
|
30
|
+
readonly updateCurrentHead: (currentHead: EventSequenceNumber.Global.Type) => void
|
|
31
|
+
/** Serializes push admission across concurrent RPC fibers for this Durable Object instance. */
|
|
32
|
+
readonly pushSemaphore: Semaphore.Semaphore
|
|
33
|
+
readonly storage: SyncStorage
|
|
34
|
+
readonly doOptions: MakeDurableObjectClassOptions | undefined
|
|
35
|
+
readonly env: Env
|
|
36
|
+
readonly ctx: CfTypes.DurableObjectState
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class DoCtx extends Context.Service<DoCtx, Service>()('@livestore/sync-cf/DoCtx') {}
|
|
40
|
+
|
|
41
|
+
type DurableObjectCacheHost = Options['doSelf'] & {
|
|
42
|
+
[CacheSymbol]?: Service
|
|
43
|
+
[InitializationSemaphoreSymbol]?: Semaphore.Semaphore
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const make = Effect.fn(
|
|
47
|
+
function* ({ doSelf, doOptions, from }: Options) {
|
|
48
|
+
const cacheHost = doSelf as DurableObjectCacheHost
|
|
49
|
+
if (cacheHost[CacheSymbol] !== undefined) {
|
|
50
|
+
return DoCtx.of(cacheHost[CacheSymbol])
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Request layers can be constructed concurrently during the first RPCs to a DO.
|
|
54
|
+
// Install this lock synchronously so every contender shares the same initializer.
|
|
55
|
+
const initializationSemaphore = cacheHost[InitializationSemaphoreSymbol] ?? Semaphore.makeUnsafe(1)
|
|
56
|
+
cacheHost[InitializationSemaphoreSymbol] = initializationSemaphore
|
|
57
|
+
|
|
58
|
+
return yield* initializationSemaphore.withPermits(1)(
|
|
59
|
+
Effect.gen(function* () {
|
|
60
|
+
if (cacheHost[CacheSymbol] !== undefined) {
|
|
61
|
+
return DoCtx.of(cacheHost[CacheSymbol])
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const getStoreId = (from: CfTypes.Request | { storeId: string }) => {
|
|
65
|
+
if (Predicate.hasProperty(from, 'url') === true) {
|
|
66
|
+
const url = new URL(from.url)
|
|
67
|
+
return (
|
|
68
|
+
url.searchParams.get('storeId') ?? shouldNeverHappen(`No storeId provided in request URL search params`)
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
return from.storeId
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const storeId = getStoreId(from)
|
|
75
|
+
const engine = yield* resolveStorageEngine(doOptions?.storage, doSelf.env)
|
|
76
|
+
|
|
77
|
+
const storage = makeStorage(doSelf.ctx, storeId, engine)
|
|
78
|
+
|
|
79
|
+
// Initialize database tables
|
|
80
|
+
{
|
|
81
|
+
const colSpec = State.SQLite.makeColumnSpec(eventlogTable.sqliteDef.ast)
|
|
82
|
+
if (engine._tag === 'd1') {
|
|
83
|
+
// D1 database is async, so we need to use a promise
|
|
84
|
+
yield* Effect.promise(() =>
|
|
85
|
+
engine.db.exec(`CREATE TABLE IF NOT EXISTS "${storage.dbName}" (${colSpec}) strict`),
|
|
86
|
+
)
|
|
87
|
+
} else {
|
|
88
|
+
// DO SQLite table lives in Durable Object storage
|
|
89
|
+
doSelf.ctx.storage.sql.exec(`CREATE TABLE IF NOT EXISTS "${storage.dbName}" (${colSpec}) strict`)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
{
|
|
93
|
+
const colSpec = State.SQLite.makeColumnSpec(contextTable.sqliteDef.ast)
|
|
94
|
+
doSelf.ctx.storage.sql.exec(`CREATE TABLE IF NOT EXISTS "${contextTable.sqliteDef.name}" (${colSpec}) strict`)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const storageRow = doSelf.ctx.storage.sql
|
|
98
|
+
.exec(`SELECT * FROM "${contextTable.sqliteDef.name}" WHERE storeId = ?`, storeId)
|
|
99
|
+
.toArray()[0] as typeof contextTable.rowSchema.Type | undefined
|
|
100
|
+
|
|
101
|
+
const currentHeadRef = { current: storageRow?.currentHead ?? EventSequenceNumber.Client.ROOT.global }
|
|
102
|
+
const pushSemaphore = yield* Semaphore.make(1)
|
|
103
|
+
|
|
104
|
+
// TODO: Reconcile this persisted head with the event log's highest sequence number.
|
|
105
|
+
// D1 event batches and this DO-local context row cannot be updated atomically, so a
|
|
106
|
+
// failure between those writes can leave push admission using a stale head after restart.
|
|
107
|
+
|
|
108
|
+
// Should be the same backendId for lifetime of the Durable Object
|
|
109
|
+
const backendId = storageRow?.backendId ?? nanoid()
|
|
110
|
+
|
|
111
|
+
const updateCurrentHead = (currentHead: EventSequenceNumber.Global.Type) => {
|
|
112
|
+
doSelf.ctx.storage.sql.exec(
|
|
113
|
+
`INSERT OR REPLACE INTO "${contextTable.sqliteDef.name}" (storeId, currentHead, backendId) VALUES (?, ?, ?)`,
|
|
114
|
+
storeId,
|
|
115
|
+
currentHead,
|
|
116
|
+
backendId,
|
|
34
117
|
)
|
|
118
|
+
|
|
119
|
+
currentHeadRef.current = currentHead
|
|
35
120
|
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const storeId = getStoreId(from)
|
|
40
|
-
const storage = makeStorage(doSelf.ctx, doSelf.env, storeId)
|
|
41
|
-
|
|
42
|
-
// Initialize database tables
|
|
43
|
-
{
|
|
44
|
-
const colSpec = State.SQLite.makeColumnSpec(eventlogTable.sqliteDef.ast)
|
|
45
|
-
// D1 database is async, so we need to use a promise
|
|
46
|
-
yield* Effect.promise(() =>
|
|
47
|
-
doSelf.env.DB.exec(`CREATE TABLE IF NOT EXISTS "${storage.dbName}" (${colSpec}) strict`),
|
|
48
|
-
)
|
|
49
|
-
}
|
|
50
|
-
{
|
|
51
|
-
const colSpec = State.SQLite.makeColumnSpec(contextTable.sqliteDef.ast)
|
|
52
|
-
doSelf.ctx.storage.sql.exec(`CREATE TABLE IF NOT EXISTS "${contextTable.sqliteDef.name}" (${colSpec}) strict`)
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
const storageRow = doSelf.ctx.storage.sql
|
|
56
|
-
.exec(`SELECT * FROM "${contextTable.sqliteDef.name}" WHERE storeId = ?`, storeId)
|
|
57
|
-
.toArray()[0] as typeof contextTable.rowSchema.Type | undefined
|
|
58
|
-
|
|
59
|
-
const currentHeadRef = { current: storageRow?.currentHead ?? EventSequenceNumber.ROOT.global }
|
|
60
|
-
|
|
61
|
-
// TODO do concistency check with eventlog table to make sure the head is consistent
|
|
62
|
-
|
|
63
|
-
// Should be the same backendId for lifetime of the durable object
|
|
64
|
-
const backendId = storageRow?.backendId ?? nanoid()
|
|
65
|
-
|
|
66
|
-
const updateCurrentHead = (currentHead: EventSequenceNumber.GlobalEventSequenceNumber) => {
|
|
67
|
-
doSelf.ctx.storage.sql.exec(
|
|
68
|
-
`INSERT OR REPLACE INTO "${contextTable.sqliteDef.name}" (storeId, currentHead, backendId) VALUES (?, ?, ?)`,
|
|
121
|
+
|
|
122
|
+
const storageCache = {
|
|
69
123
|
storeId,
|
|
70
|
-
currentHead,
|
|
71
124
|
backendId,
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
125
|
+
currentHeadRef,
|
|
126
|
+
updateCurrentHead,
|
|
127
|
+
pushSemaphore,
|
|
128
|
+
storage,
|
|
129
|
+
doOptions,
|
|
130
|
+
env: doSelf.env,
|
|
131
|
+
ctx: doSelf.ctx,
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
cacheHost[CacheSymbol] = storageCache
|
|
135
|
+
|
|
136
|
+
// Set initial current head to root
|
|
137
|
+
if (storageRow === undefined) {
|
|
138
|
+
updateCurrentHead(EventSequenceNumber.Client.ROOT.global)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return DoCtx.of(storageCache)
|
|
142
|
+
}),
|
|
143
|
+
)
|
|
144
|
+
},
|
|
145
|
+
UnknownError.mapToUnknownError,
|
|
146
|
+
Effect.withSpan('@livestore/sync-cf:durable-object:makeDoCtx'),
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
export const layer = (options: Options) => Layer.effect(DoCtx, make(options))
|
|
150
|
+
|
|
151
|
+
/** Resolves storage configuration into the concrete database handle used at runtime. */
|
|
152
|
+
const resolveStorageEngine = Effect.fn(function* (
|
|
153
|
+
storageOption: MakeDurableObjectClassOptions['storage'],
|
|
154
|
+
env: Env,
|
|
155
|
+
): Effect.fn.Return<StorageEngine, UnknownError> {
|
|
156
|
+
if (storageOption === undefined || storageOption._tag === 'do-sqlite') {
|
|
157
|
+
return { _tag: 'do-sqlite' }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (storageOption._tag === 'd1') {
|
|
161
|
+
const db = (env as Record<string, CfTypes.D1Database | undefined>)[storageOption.binding]
|
|
162
|
+
if (db === undefined) {
|
|
163
|
+
return yield* UnknownError.make({
|
|
164
|
+
cause: new Error(`D1 binding '${storageOption.binding}' not found on env`),
|
|
165
|
+
})
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return { _tag: 'd1', db }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return shouldNeverHappen(`Invalid storage engine`, storageOption)
|
|
172
|
+
})
|