@livestore/sync-cf 0.4.0 → 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/dist/.tsbuildinfo +1 -1
- package/dist/cf-worker/do/durable-object.d.ts.map +1 -1
- package/dist/cf-worker/do/durable-object.js +24 -11
- package/dist/cf-worker/do/durable-object.js.map +1 -1
- package/dist/cf-worker/do/layer.d.ts +22 -18
- package/dist/cf-worker/do/layer.d.ts.map +1 -1
- package/dist/cf-worker/do/layer.js +43 -33
- package/dist/cf-worker/do/layer.js.map +1 -1
- package/dist/cf-worker/do/pull.d.ts +3 -3
- package/dist/cf-worker/do/pull.d.ts.map +1 -1
- package/dist/cf-worker/do/pull.js +17 -18
- package/dist/cf-worker/do/pull.js.map +1 -1
- package/dist/cf-worker/do/push.d.ts +16 -3
- package/dist/cf-worker/do/push.d.ts.map +1 -1
- package/dist/cf-worker/do/push.js +69 -49
- 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 +34 -46
- package/dist/cf-worker/do/sqlite.d.ts.map +1 -1
- package/dist/cf-worker/do/sqlite.js +1 -1
- package/dist/cf-worker/do/sqlite.js.map +1 -1
- package/dist/cf-worker/do/sync-storage.d.ts +7 -6
- package/dist/cf-worker/do/sync-storage.d.ts.map +1 -1
- package/dist/cf-worker/do/sync-storage.js +26 -14
- 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 +21 -19
- package/dist/cf-worker/do/transport/do-rpc-server.js.map +1 -1
- package/dist/cf-worker/do/transport/http-rpc-server.d.ts +2 -2
- package/dist/cf-worker/do/transport/http-rpc-server.d.ts.map +1 -1
- package/dist/cf-worker/do/transport/http-rpc-server.js +7 -7
- package/dist/cf-worker/do/transport/http-rpc-server.js.map +1 -1
- package/dist/cf-worker/do/transport/ws-rpc-server.d.ts +2 -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 +9 -9
- package/dist/cf-worker/do/transport/ws-rpc-server.js.map +1 -1
- package/dist/cf-worker/shared.d.ts +22 -19
- package/dist/cf-worker/shared.d.ts.map +1 -1
- package/dist/cf-worker/shared.js +13 -11
- package/dist/cf-worker/shared.js.map +1 -1
- package/dist/cf-worker/worker.d.ts +6 -6
- package/dist/cf-worker/worker.d.ts.map +1 -1
- package/dist/cf-worker/worker.js +16 -14
- 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 +55 -26
- 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 +30 -21
- package/dist/client/transport/http-rpc-client.js.map +1 -1
- package/dist/client/transport/ws-rpc-client.d.ts +3 -3
- package/dist/client/transport/ws-rpc-client.d.ts.map +1 -1
- package/dist/client/transport/ws-rpc-client.js +20 -16
- package/dist/client/transport/ws-rpc-client.js.map +1 -1
- package/dist/common/do-rpc-schema.d.ts +38 -39
- package/dist/common/do-rpc-schema.d.ts.map +1 -1
- package/dist/common/do-rpc-schema.js +3 -3
- package/dist/common/do-rpc-schema.js.map +1 -1
- package/dist/common/http-rpc-schema.d.ts +36 -37
- package/dist/common/http-rpc-schema.d.ts.map +1 -1
- package/dist/common/http-rpc-schema.js +5 -5
- package/dist/common/http-rpc-schema.js.map +1 -1
- package/dist/common/mod.d.ts +4 -5
- package/dist/common/mod.d.ts.map +1 -1
- package/dist/common/mod.js +3 -4
- package/dist/common/mod.js.map +1 -1
- package/dist/common/sync-message-types.d.ts +116 -133
- package/dist/common/sync-message-types.d.ts.map +1 -1
- package/dist/common/sync-message-types.js +26 -14
- package/dist/common/sync-message-types.js.map +1 -1
- package/dist/common/ws-rpc-schema.d.ts +33 -34
- package/dist/common/ws-rpc-schema.d.ts.map +1 -1
- package/dist/common/ws-rpc-schema.js +4 -4
- package/dist/common/ws-rpc-schema.js.map +1 -1
- package/package.json +21 -37
- package/src/cf-worker/do/durable-object.ts +45 -36
- package/src/cf-worker/do/layer.ts +142 -99
- package/src/cf-worker/do/pull.ts +28 -28
- package/src/cf-worker/do/push.test.ts +40 -0
- package/src/cf-worker/do/push.ts +138 -115
- package/src/cf-worker/do/sqlite.ts +1 -1
- package/src/cf-worker/do/sync-storage.ts +34 -21
- package/src/cf-worker/do/transport/do-rpc-server.ts +23 -34
- package/src/cf-worker/do/transport/http-rpc-server.ts +7 -10
- package/src/cf-worker/do/transport/ws-rpc-server.ts +10 -10
- package/src/cf-worker/shared.ts +18 -13
- package/src/cf-worker/worker.ts +21 -22
- package/src/client/transport/do-rpc-client.ts +82 -40
- package/src/client/transport/http-rpc-client.ts +47 -37
- package/src/client/transport/ws-rpc-client.ts +29 -26
- package/src/common/do-rpc-schema.ts +3 -3
- package/src/common/http-rpc-schema.ts +5 -5
- package/src/common/mod.ts +3 -4
- package/src/common/sync-message-types.ts +16 -16
- package/src/common/ws-rpc-schema.ts +4 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@livestore/sync-cf",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0-dev.0",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -24,51 +24,35 @@
|
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
26
|
"@cloudflare/workers-types": "4.20251118.0",
|
|
27
|
-
"@livestore/
|
|
28
|
-
"@livestore/common
|
|
29
|
-
"@livestore/
|
|
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
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
|
-
"@effect/
|
|
33
|
-
"@effect/
|
|
34
|
-
"@effect/
|
|
35
|
-
"@effect/
|
|
36
|
-
"@effect/
|
|
37
|
-
"@effect/
|
|
38
|
-
"@effect/platform-browser": "0.76.0",
|
|
39
|
-
"@effect/platform-bun": "0.89.0",
|
|
40
|
-
"@effect/platform-node": "0.106.0",
|
|
41
|
-
"@effect/printer": "0.49.0",
|
|
42
|
-
"@effect/printer-ansi": "0.49.0",
|
|
43
|
-
"@effect/rpc": "0.75.1",
|
|
44
|
-
"@effect/sql": "0.51.1",
|
|
45
|
-
"@effect/typeclass": "0.40.0",
|
|
46
|
-
"@effect/vitest": "0.29.0",
|
|
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",
|
|
47
38
|
"@opentelemetry/api": "1.9.0",
|
|
48
39
|
"@opentelemetry/resources": "2.2.0",
|
|
49
40
|
"@standard-schema/spec": "1.1.0",
|
|
50
|
-
"effect": "
|
|
41
|
+
"effect": "4.0.0-rc.111",
|
|
42
|
+
"vitest": "4.1.9",
|
|
43
|
+
"@livestore/utils-dev": "^0.5.0-dev.0"
|
|
51
44
|
},
|
|
52
45
|
"peerDependencies": {
|
|
53
|
-
"@effect/
|
|
54
|
-
"@effect/
|
|
55
|
-
"@effect/
|
|
56
|
-
"@effect/
|
|
57
|
-
"@effect/
|
|
58
|
-
"@effect/
|
|
59
|
-
"@effect/platform-browser": "^0.76.0",
|
|
60
|
-
"@effect/platform-bun": "^0.89.0",
|
|
61
|
-
"@effect/platform-node": "^0.106.0",
|
|
62
|
-
"@effect/printer": "^0.49.0",
|
|
63
|
-
"@effect/printer-ansi": "^0.49.0",
|
|
64
|
-
"@effect/rpc": "^0.75.1",
|
|
65
|
-
"@effect/sql": "^0.51.1",
|
|
66
|
-
"@effect/typeclass": "^0.40.0",
|
|
67
|
-
"@effect/vitest": "^0.29.0",
|
|
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",
|
|
68
52
|
"@opentelemetry/api": "^1.9.0",
|
|
69
53
|
"@opentelemetry/resources": "^2.2.0",
|
|
70
54
|
"@standard-schema/spec": "^1.1.0",
|
|
71
|
-
"effect": "^
|
|
55
|
+
"effect": "^4.0.0-rc.111"
|
|
72
56
|
},
|
|
73
57
|
"$genie": {
|
|
74
58
|
"source": "package.json.genie.ts",
|
|
@@ -83,6 +67,6 @@
|
|
|
83
67
|
]
|
|
84
68
|
},
|
|
85
69
|
"scripts": {
|
|
86
|
-
"test": "
|
|
70
|
+
"test": "vitest run"
|
|
87
71
|
}
|
|
88
72
|
}
|
|
@@ -9,8 +9,8 @@ import {
|
|
|
9
9
|
FetchHttpClient,
|
|
10
10
|
Layer,
|
|
11
11
|
Logger,
|
|
12
|
-
LogLevel,
|
|
13
12
|
Otlp,
|
|
13
|
+
References,
|
|
14
14
|
RpcMessage,
|
|
15
15
|
Schema,
|
|
16
16
|
type Scope,
|
|
@@ -24,17 +24,17 @@ import {
|
|
|
24
24
|
type SyncBackendRpcInterface,
|
|
25
25
|
WebSocketAttachmentSchema,
|
|
26
26
|
} from '../shared.ts'
|
|
27
|
-
import
|
|
27
|
+
import * as DoCtx from './layer.ts'
|
|
28
28
|
import { createDoRpcHandler } from './transport/do-rpc-server.ts'
|
|
29
29
|
import { createHttpRpcHandler } from './transport/http-rpc-server.ts'
|
|
30
30
|
import { makeRpcServer } from './transport/ws-rpc-server.ts'
|
|
31
31
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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))
|
|
38
38
|
|
|
39
39
|
const DurableObjectBase = DurableObject as any as new (
|
|
40
40
|
state: CfTypes.DurableObjectState,
|
|
@@ -52,14 +52,14 @@ export type MakeDurableObjectClass = (options?: MakeDurableObjectClassOptions) =
|
|
|
52
52
|
/**
|
|
53
53
|
* Creates a Durable Object class for handling WebSocket-based sync.
|
|
54
54
|
* A sync Durable Object is uniquely scoped to a specific `storeId`.
|
|
55
|
-
*
|
|
55
|
+
*
|
|
56
56
|
* The sync DO supports 3 transport modes:
|
|
57
57
|
* - HTTP JSON-RPC
|
|
58
58
|
* - WebSocket
|
|
59
59
|
* - Durable Object RPC calls (only works in combination with `@livestore/adapter-cf`)
|
|
60
60
|
*
|
|
61
61
|
* Example:
|
|
62
|
-
*
|
|
62
|
+
*
|
|
63
63
|
* ```ts
|
|
64
64
|
* // In your Cloudflare Worker file
|
|
65
65
|
* import { makeDurableObject } from '@livestore/sync-cf/cf-worker'
|
|
@@ -88,17 +88,16 @@ export type MakeDurableObjectClass = (options?: MakeDurableObjectClassOptions) =
|
|
|
88
88
|
export const makeDurableObject: MakeDurableObjectClass = (options) => {
|
|
89
89
|
const enabledTransports = options?.enabledTransports ?? new Set(['http', 'ws', 'do-rpc'])
|
|
90
90
|
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
: 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
|
|
102
101
|
|
|
103
102
|
return class SyncBackendDOBase extends DurableObjectBase implements SyncBackendRpcInterface {
|
|
104
103
|
__DURABLE_OBJECT_BRAND = 'SyncBackendDOBase' as never
|
|
@@ -120,7 +119,7 @@ export const makeDurableObject: MakeDurableObjectClass = (options) => {
|
|
|
120
119
|
if (request._tag === 'Request' && request.tag === 'SyncWsRpc.Pull') {
|
|
121
120
|
// Is Pull request: add requestId to pullRequestIds
|
|
122
121
|
const attachment = ws.deserializeAttachment()
|
|
123
|
-
const { pullRequestIds, ...rest } = Schema.
|
|
122
|
+
const { pullRequestIds, ...rest } = Schema.decodeUnknownSync(WebSocketAttachmentSchema)(attachment)
|
|
124
123
|
ws.serializeAttachment(
|
|
125
124
|
Schema.encodeSync(WebSocketAttachmentSchema)({
|
|
126
125
|
...rest,
|
|
@@ -130,7 +129,7 @@ export const makeDurableObject: MakeDurableObjectClass = (options) => {
|
|
|
130
129
|
} else if (request._tag === 'Interrupt') {
|
|
131
130
|
// Is Interrupt request: remove requestId from pullRequestIds
|
|
132
131
|
const attachment = ws.deserializeAttachment()
|
|
133
|
-
const { pullRequestIds, ...rest } = Schema.
|
|
132
|
+
const { pullRequestIds, ...rest } = Schema.decodeUnknownSync(WebSocketAttachmentSchema)(attachment)
|
|
134
133
|
ws.serializeAttachment(
|
|
135
134
|
Schema.encodeSync(WebSocketAttachmentSchema)({
|
|
136
135
|
...rest,
|
|
@@ -145,8 +144,8 @@ export const makeDurableObject: MakeDurableObjectClass = (options) => {
|
|
|
145
144
|
}
|
|
146
145
|
}
|
|
147
146
|
|
|
148
|
-
override fetch = async (request: Request): Promise<Response> =>
|
|
149
|
-
Effect.gen(this, function* () {
|
|
147
|
+
override fetch = async (request: CfTypes.Request): Promise<CfTypes.Response> =>
|
|
148
|
+
Effect.gen({ self: this }, function* () {
|
|
150
149
|
const searchParams = matchSyncRequest(request)
|
|
151
150
|
if (searchParams === undefined) {
|
|
152
151
|
throw new Error('No search params found in request URL')
|
|
@@ -170,9 +169,16 @@ export const makeDurableObject: MakeDurableObjectClass = (options) => {
|
|
|
170
169
|
|
|
171
170
|
// Since we're using websocket hibernation, we need to remember the storeId for subsequent `webSocketMessage` calls
|
|
172
171
|
// Also store forwarded headers so they're available after hibernation resume
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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)
|
|
176
182
|
|
|
177
183
|
// See https://developers.cloudflare.com/durable-objects/examples/websocket-hibernation-server
|
|
178
184
|
|
|
@@ -180,10 +186,7 @@ export const makeDurableObject: MakeDurableObjectClass = (options) => {
|
|
|
180
186
|
|
|
181
187
|
// Ping requests are sent by Effect RPC internally
|
|
182
188
|
this.ctx.setWebSocketAutoResponse(
|
|
183
|
-
new WebSocketRequestResponsePair(
|
|
184
|
-
JSON.stringify(RpcMessage.constPing),
|
|
185
|
-
JSON.stringify(RpcMessage.constPong),
|
|
186
|
-
),
|
|
189
|
+
new WebSocketRequestResponsePair(jsonStringify(RpcMessage.constPing), jsonStringify(RpcMessage.constPong)),
|
|
187
190
|
)
|
|
188
191
|
|
|
189
192
|
return new Response(null, {
|
|
@@ -200,11 +203,11 @@ export const makeDurableObject: MakeDurableObjectClass = (options) => {
|
|
|
200
203
|
})
|
|
201
204
|
}).pipe(
|
|
202
205
|
Effect.tapCauseLogPretty, // Also log errors to console before catching them
|
|
203
|
-
Effect.
|
|
206
|
+
Effect.catchCause((cause) =>
|
|
204
207
|
Effect.succeed(new Response('Error', { status: 500, statusText: cause.toString() })),
|
|
205
208
|
),
|
|
206
209
|
Effect.withSpan('@livestore/sync-cf:durable-object:fetch'),
|
|
207
|
-
Effect.provide(DoCtx.
|
|
210
|
+
Effect.provide(DoCtx.layer({ doSelf: this, doOptions: options, from: request })),
|
|
208
211
|
this.runEffectAsPromise,
|
|
209
212
|
)
|
|
210
213
|
|
|
@@ -237,8 +240,14 @@ export const makeDurableObject: MakeDurableObjectClass = (options) => {
|
|
|
237
240
|
private runEffectAsPromise = <T, E = never>(effect: Effect.Effect<T, E, Scope.Scope>): Promise<T> =>
|
|
238
241
|
effect.pipe(
|
|
239
242
|
Effect.tapCauseLogPretty,
|
|
240
|
-
|
|
241
|
-
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
|
+
),
|
|
242
251
|
Effect.scoped,
|
|
243
252
|
Effect.runPromise,
|
|
244
253
|
)
|
|
@@ -2,16 +2,17 @@ import { UnknownError } from '@livestore/common'
|
|
|
2
2
|
import type { CfTypes } from '@livestore/common-cf'
|
|
3
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
|
|
8
|
+
import type { Env, MakeDurableObjectClassOptions } from '../shared.ts'
|
|
9
9
|
import { contextTable, eventlogTable } from './sqlite.ts'
|
|
10
|
-
import { makeStorage } from './sync-storage.ts'
|
|
10
|
+
import { makeStorage, type StorageEngine, type SyncStorage } from './sync-storage.ts'
|
|
11
11
|
|
|
12
12
|
const CacheSymbol = Symbol('Cache')
|
|
13
|
+
const InitializationSemaphoreSymbol = Symbol('InitializationSemaphore')
|
|
13
14
|
|
|
14
|
-
export interface
|
|
15
|
+
export interface Options {
|
|
15
16
|
doSelf: CfTypes.DurableObject & {
|
|
16
17
|
ctx: CfTypes.DurableObjectState
|
|
17
18
|
env: Env
|
|
@@ -20,110 +21,152 @@ export interface DoCtxInput {
|
|
|
20
21
|
from: CfTypes.Request | { storeId: string }
|
|
21
22
|
}
|
|
22
23
|
|
|
23
|
-
export
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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])
|
|
36
62
|
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
if (opt?._tag === 'd1') {
|
|
45
|
-
const db = (doSelf.env as any)[opt.binding]
|
|
46
|
-
if (db == null) {
|
|
47
|
-
return yield* UnknownError.make({ cause: new Error(`D1 binding '${opt.binding}' not found on env`) })
|
|
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
|
+
)
|
|
48
70
|
}
|
|
49
|
-
return
|
|
50
|
-
}
|
|
51
|
-
return { _tag: 'do-sqlite' as const }
|
|
52
|
-
} else return shouldNeverHappen(`Invalid storage engine`, opt)
|
|
53
|
-
})
|
|
71
|
+
return from.storeId
|
|
72
|
+
}
|
|
54
73
|
|
|
55
|
-
|
|
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
|
+
}
|
|
56
96
|
|
|
57
|
-
|
|
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
|
|
58
100
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
const colSpec = State.SQLite.makeColumnSpec(eventlogTable.sqliteDef.ast)
|
|
62
|
-
if (engine._tag === 'd1') {
|
|
63
|
-
// D1 database is async, so we need to use a promise
|
|
64
|
-
yield* Effect.promise(() =>
|
|
65
|
-
engine.db.exec(`CREATE TABLE IF NOT EXISTS "${storage.dbName}" (${colSpec}) strict`),
|
|
66
|
-
)
|
|
67
|
-
} else {
|
|
68
|
-
// DO SQLite table lives in Durable Object storage
|
|
69
|
-
doSelf.ctx.storage.sql.exec(`CREATE TABLE IF NOT EXISTS "${storage.dbName}" (${colSpec}) strict`)
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
{
|
|
73
|
-
const colSpec = State.SQLite.makeColumnSpec(contextTable.sqliteDef.ast)
|
|
74
|
-
doSelf.ctx.storage.sql.exec(`CREATE TABLE IF NOT EXISTS "${contextTable.sqliteDef.name}" (${colSpec}) strict`)
|
|
75
|
-
}
|
|
101
|
+
const currentHeadRef = { current: storageRow?.currentHead ?? EventSequenceNumber.Client.ROOT.global }
|
|
102
|
+
const pushSemaphore = yield* Semaphore.make(1)
|
|
76
103
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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.
|
|
80
107
|
|
|
81
|
-
|
|
108
|
+
// Should be the same backendId for lifetime of the Durable Object
|
|
109
|
+
const backendId = storageRow?.backendId ?? nanoid()
|
|
82
110
|
|
|
83
|
-
|
|
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,
|
|
117
|
+
)
|
|
84
118
|
|
|
85
|
-
|
|
86
|
-
|
|
119
|
+
currentHeadRef.current = currentHead
|
|
120
|
+
}
|
|
87
121
|
|
|
88
|
-
|
|
89
|
-
doSelf.ctx.storage.sql.exec(
|
|
90
|
-
`INSERT OR REPLACE INTO "${contextTable.sqliteDef.name}" (storeId, currentHead, backendId) VALUES (?, ?, ?)`,
|
|
122
|
+
const storageCache = {
|
|
91
123
|
storeId,
|
|
92
|
-
currentHead,
|
|
93
124
|
backendId,
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
+
})
|
package/src/cf-worker/do/pull.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { BackendIdMismatchError, SyncBackend, UnknownError } from '@livestore/common'
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { splitArrayBySize } from '@livestore/common/sync'
|
|
3
|
+
import { Effect, Option, Schema, Stream } from '@livestore/utils/effect'
|
|
4
4
|
|
|
5
5
|
import { MAX_PULL_EVENTS_PER_MESSAGE, MAX_WS_MESSAGE_BYTES } from '../../common/constants.ts'
|
|
6
6
|
import { SyncMessage } from '../../common/mod.ts'
|
|
7
7
|
import type { ForwardedHeaders } from '../shared.ts'
|
|
8
|
-
import
|
|
8
|
+
import * as DoCtx from './layer.ts'
|
|
9
9
|
|
|
10
10
|
const encodePullResponse = Schema.encodeSync(SyncMessage.PullResponse)
|
|
11
11
|
|
|
@@ -24,22 +24,20 @@ export const makeEndingPullStream = ({
|
|
|
24
24
|
headers,
|
|
25
25
|
}: {
|
|
26
26
|
req: SyncMessage.PullRequest
|
|
27
|
-
payload: Schema.
|
|
27
|
+
payload: Schema.Json | undefined
|
|
28
28
|
headers: ForwardedHeaders | undefined
|
|
29
|
-
}): Stream.Stream<SyncMessage.PullResponse, UnknownError | BackendIdMismatchError, DoCtx> =>
|
|
29
|
+
}): Stream.Stream<SyncMessage.PullResponse, UnknownError | BackendIdMismatchError, DoCtx.DoCtx> =>
|
|
30
30
|
Effect.gen(function* () {
|
|
31
|
-
const { doOptions, backendId, storeId, storage } = yield* DoCtx
|
|
31
|
+
const { doOptions, backendId, storeId, storage } = yield* DoCtx.DoCtx
|
|
32
32
|
|
|
33
33
|
if (doOptions?.onPull !== undefined) {
|
|
34
|
-
yield* Effect.
|
|
34
|
+
yield* Effect.trySyncOrPromiseOrEffect(() =>
|
|
35
35
|
doOptions.onPull!(req, {
|
|
36
36
|
storeId,
|
|
37
37
|
...(payload !== undefined ? { payload } : {}),
|
|
38
38
|
...(headers !== undefined ? { headers } : {}),
|
|
39
39
|
}),
|
|
40
|
-
).pipe(
|
|
41
|
-
UnknownError.mapToUnknownError,
|
|
42
|
-
)
|
|
40
|
+
).pipe(UnknownError.mapToUnknownError)
|
|
43
41
|
}
|
|
44
42
|
|
|
45
43
|
if (req.cursor._tag === 'Some' && req.cursor.value.backendId !== backendId) {
|
|
@@ -51,8 +49,8 @@ export const makeEndingPullStream = ({
|
|
|
51
49
|
)
|
|
52
50
|
|
|
53
51
|
return storedEvents.pipe(
|
|
54
|
-
Stream.
|
|
55
|
-
|
|
52
|
+
Stream.mapArrayEffect(
|
|
53
|
+
splitArrayBySize({
|
|
56
54
|
maxItems: MAX_PULL_EVENTS_PER_MESSAGE,
|
|
57
55
|
maxBytes: MAX_WS_MESSAGE_BYTES,
|
|
58
56
|
encode: (batch) =>
|
|
@@ -61,23 +59,27 @@ export const makeEndingPullStream = ({
|
|
|
61
59
|
),
|
|
62
60
|
}),
|
|
63
61
|
),
|
|
64
|
-
Stream.mapAccum(
|
|
65
|
-
|
|
66
|
-
|
|
62
|
+
Stream.mapAccum(
|
|
63
|
+
() => total,
|
|
64
|
+
(remaining, array) => {
|
|
65
|
+
const nextRemaining = Math.max(0, remaining - array.length)
|
|
67
66
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
67
|
+
return [
|
|
68
|
+
nextRemaining,
|
|
69
|
+
[
|
|
70
|
+
SyncMessage.PullResponse.make({
|
|
71
|
+
batch: array,
|
|
72
|
+
pageInfo: nextRemaining > 0 ? SyncBackend.pageInfoMoreKnown(nextRemaining) : SyncBackend.pageInfoNoMore,
|
|
73
|
+
backendId,
|
|
74
|
+
}),
|
|
75
|
+
],
|
|
76
|
+
] as const
|
|
77
|
+
},
|
|
78
|
+
),
|
|
77
79
|
Stream.tap(
|
|
78
80
|
Effect.fn(function* (res) {
|
|
79
81
|
if (doOptions?.onPullRes !== undefined) {
|
|
80
|
-
yield* Effect.
|
|
82
|
+
yield* Effect.trySyncOrPromiseOrEffect(() => doOptions.onPullRes!(res)).pipe(UnknownError.mapToUnknownError)
|
|
81
83
|
}
|
|
82
84
|
}),
|
|
83
85
|
),
|
|
@@ -86,9 +88,7 @@ export const makeEndingPullStream = ({
|
|
|
86
88
|
}).pipe(
|
|
87
89
|
Stream.unwrap,
|
|
88
90
|
Stream.mapError((cause) =>
|
|
89
|
-
cause._tag === 'BackendIdMismatchError' || cause._tag === 'UnknownError'
|
|
90
|
-
? cause
|
|
91
|
-
: new UnknownError({ cause }),
|
|
91
|
+
cause._tag === 'BackendIdMismatchError' || cause._tag === 'UnknownError' ? cause : new UnknownError({ cause }),
|
|
92
92
|
),
|
|
93
93
|
Stream.withSpan('cloudflare-provider:pull'),
|
|
94
94
|
)
|