@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.
Files changed (113) hide show
  1. package/README.md +7 -8
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/cf-worker/do/durable-object.d.ts +1 -1
  4. package/dist/cf-worker/do/durable-object.d.ts.map +1 -1
  5. package/dist/cf-worker/do/durable-object.js +36 -18
  6. package/dist/cf-worker/do/durable-object.js.map +1 -1
  7. package/dist/cf-worker/do/layer.d.ts +24 -20
  8. package/dist/cf-worker/do/layer.d.ts.map +1 -1
  9. package/dist/cf-worker/do/layer.js +57 -24
  10. package/dist/cf-worker/do/layer.js.map +1 -1
  11. package/dist/cf-worker/do/pull.d.ts +8 -3
  12. package/dist/cf-worker/do/pull.d.ts.map +1 -1
  13. package/dist/cf-worker/do/pull.js +26 -21
  14. package/dist/cf-worker/do/pull.js.map +1 -1
  15. package/dist/cf-worker/do/push.d.ts +20 -6
  16. package/dist/cf-worker/do/push.d.ts.map +1 -1
  17. package/dist/cf-worker/do/push.js +84 -56
  18. package/dist/cf-worker/do/push.js.map +1 -1
  19. package/dist/cf-worker/do/push.test.d.ts +2 -0
  20. package/dist/cf-worker/do/push.test.d.ts.map +1 -0
  21. package/dist/cf-worker/do/push.test.js +30 -0
  22. package/dist/cf-worker/do/push.test.js.map +1 -0
  23. package/dist/cf-worker/do/sqlite.d.ts +49 -52
  24. package/dist/cf-worker/do/sqlite.d.ts.map +1 -1
  25. package/dist/cf-worker/do/sqlite.js +14 -5
  26. package/dist/cf-worker/do/sqlite.js.map +1 -1
  27. package/dist/cf-worker/do/sync-storage.d.ts +15 -9
  28. package/dist/cf-worker/do/sync-storage.d.ts.map +1 -1
  29. package/dist/cf-worker/do/sync-storage.js +111 -25
  30. package/dist/cf-worker/do/sync-storage.js.map +1 -1
  31. package/dist/cf-worker/do/transport/do-rpc-server.d.ts +2 -2
  32. package/dist/cf-worker/do/transport/do-rpc-server.d.ts.map +1 -1
  33. package/dist/cf-worker/do/transport/do-rpc-server.js +31 -23
  34. package/dist/cf-worker/do/transport/do-rpc-server.js.map +1 -1
  35. package/dist/cf-worker/do/transport/http-rpc-server.d.ts +5 -3
  36. package/dist/cf-worker/do/transport/http-rpc-server.d.ts.map +1 -1
  37. package/dist/cf-worker/do/transport/http-rpc-server.js +28 -19
  38. package/dist/cf-worker/do/transport/http-rpc-server.js.map +1 -1
  39. package/dist/cf-worker/do/transport/ws-rpc-server.d.ts +3 -2
  40. package/dist/cf-worker/do/transport/ws-rpc-server.d.ts.map +1 -1
  41. package/dist/cf-worker/do/transport/ws-rpc-server.js +32 -10
  42. package/dist/cf-worker/do/transport/ws-rpc-server.js.map +1 -1
  43. package/dist/cf-worker/shared.d.ts +124 -34
  44. package/dist/cf-worker/shared.d.ts.map +1 -1
  45. package/dist/cf-worker/shared.js +52 -17
  46. package/dist/cf-worker/shared.js.map +1 -1
  47. package/dist/cf-worker/worker.d.ts +47 -39
  48. package/dist/cf-worker/worker.d.ts.map +1 -1
  49. package/dist/cf-worker/worker.js +54 -35
  50. package/dist/cf-worker/worker.js.map +1 -1
  51. package/dist/client/transport/do-rpc-client.d.ts +13 -4
  52. package/dist/client/transport/do-rpc-client.d.ts.map +1 -1
  53. package/dist/client/transport/do-rpc-client.js +74 -28
  54. package/dist/client/transport/do-rpc-client.js.map +1 -1
  55. package/dist/client/transport/http-rpc-client.d.ts +3 -3
  56. package/dist/client/transport/http-rpc-client.d.ts.map +1 -1
  57. package/dist/client/transport/http-rpc-client.js +51 -22
  58. package/dist/client/transport/http-rpc-client.js.map +1 -1
  59. package/dist/client/transport/ws-rpc-client.d.ts +4 -3
  60. package/dist/client/transport/ws-rpc-client.d.ts.map +1 -1
  61. package/dist/client/transport/ws-rpc-client.js +40 -22
  62. package/dist/client/transport/ws-rpc-client.js.map +1 -1
  63. package/dist/common/constants.d.ts +7 -0
  64. package/dist/common/constants.d.ts.map +1 -0
  65. package/dist/common/constants.js +17 -0
  66. package/dist/common/constants.js.map +1 -0
  67. package/dist/common/do-rpc-schema.d.ts +42 -43
  68. package/dist/common/do-rpc-schema.d.ts.map +1 -1
  69. package/dist/common/do-rpc-schema.js +5 -5
  70. package/dist/common/do-rpc-schema.js.map +1 -1
  71. package/dist/common/http-rpc-schema.d.ts +37 -38
  72. package/dist/common/http-rpc-schema.d.ts.map +1 -1
  73. package/dist/common/http-rpc-schema.js +7 -7
  74. package/dist/common/http-rpc-schema.js.map +1 -1
  75. package/dist/common/mod.d.ts +5 -3
  76. package/dist/common/mod.d.ts.map +1 -1
  77. package/dist/common/mod.js +4 -2
  78. package/dist/common/mod.js.map +1 -1
  79. package/dist/common/sync-message-types.d.ts +113 -130
  80. package/dist/common/sync-message-types.d.ts.map +1 -1
  81. package/dist/common/sync-message-types.js +29 -17
  82. package/dist/common/sync-message-types.js.map +1 -1
  83. package/dist/common/ws-rpc-schema.d.ts +34 -35
  84. package/dist/common/ws-rpc-schema.d.ts.map +1 -1
  85. package/dist/common/ws-rpc-schema.js +5 -5
  86. package/dist/common/ws-rpc-schema.js.map +1 -1
  87. package/package.json +57 -15
  88. package/src/cf-worker/do/durable-object.ts +59 -41
  89. package/src/cf-worker/do/layer.ts +153 -88
  90. package/src/cf-worker/do/pull.ts +48 -31
  91. package/src/cf-worker/do/push.test.ts +40 -0
  92. package/src/cf-worker/do/push.ts +163 -125
  93. package/src/cf-worker/do/sqlite.ts +15 -5
  94. package/src/cf-worker/do/sync-storage.ts +171 -38
  95. package/src/cf-worker/do/transport/do-rpc-server.ts +40 -40
  96. package/src/cf-worker/do/transport/http-rpc-server.ts +37 -20
  97. package/src/cf-worker/do/transport/ws-rpc-server.ts +44 -16
  98. package/src/cf-worker/shared.ts +152 -36
  99. package/src/cf-worker/worker.ts +110 -58
  100. package/src/client/transport/do-rpc-client.ts +106 -40
  101. package/src/client/transport/http-rpc-client.ts +74 -38
  102. package/src/client/transport/ws-rpc-client.ts +55 -29
  103. package/src/common/constants.ts +18 -0
  104. package/src/common/do-rpc-schema.ts +6 -5
  105. package/src/common/http-rpc-schema.ts +8 -7
  106. package/src/common/mod.ts +4 -3
  107. package/src/common/sync-message-types.ts +19 -19
  108. package/src/common/ws-rpc-schema.ts +6 -5
  109. package/dist/cf-worker/do/ws-chunking.d.ts +0 -22
  110. package/dist/cf-worker/do/ws-chunking.d.ts.map +0 -1
  111. package/dist/cf-worker/do/ws-chunking.js +0 -49
  112. package/dist/cf-worker/do/ws-chunking.js.map +0 -1
  113. 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.4.0-dev.9",
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": "echo 'No tests yet'"
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 { DoCtx } from './layer.ts'
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
- // NOTE We need to redeclare runtime types here to avoid type conflicts with the lib.dom Response type.
30
- // TODO get rid of those once CF fixed their type mismatch in the worker types
31
- declare class Request extends CfDeclare.Request {}
32
- declare class Response extends CfDeclare.Response {}
33
- declare class WebSocketPair extends CfDeclare.WebSocketPair {}
34
- declare class WebSocketRequestResponsePair extends CfDeclare.WebSocketRequestResponsePair {}
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<Env> as any as new (
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 durable object is uniquely scoped to a specific `storeId`.
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 Logging = Logger.consoleWithThread('SyncDo')
89
-
90
- const Observability = options?.otel?.baseUrl
91
- ? Otlp.layer({
92
- baseUrl: options.otel.baseUrl,
93
- tracerExportInterval: 50,
94
- resource: {
95
- serviceName: options.otel.serviceName ?? 'sync-cf-do',
96
- },
97
- }).pipe(Layer.provide(FetchHttpClient.layer))
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.decodeSync(WebSocketAttachmentSchema)(attachment)
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.decodeSync(WebSocketAttachmentSchema)(attachment)
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
- server.serializeAttachment(
167
- Schema.encodeSync(WebSocketAttachmentSchema)({ storeId, payload, pullRequestIds: [] }),
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.catchAllCause((cause) =>
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.Default({ doSelf: this, doOptions: options, from: request })),
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
- Logger.withMinimumLogLevel(LogLevel.Debug),
232
- Effect.provide(Layer.mergeAll(Observability, Logging)),
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 { UnexpectedError } from '@livestore/common'
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
- import type { Env, MakeDurableObjectClassOptions, RpcSubscription } from '../shared.ts'
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 DoCtxInput {
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 class DoCtx extends Effect.Service<DoCtx>()('DoCtx', {
23
- effect: Effect.fn(
24
- function* ({ doSelf, doOptions, from }: DoCtxInput) {
25
- if ((doSelf as any)[CacheSymbol] !== undefined) {
26
- return (doSelf as any)[CacheSymbol] as never
27
- }
28
-
29
- const getStoreId = (from: CfTypes.Request | { storeId: string }) => {
30
- if (Predicate.hasProperty(from, 'url')) {
31
- const url = new URL(from.url)
32
- return (
33
- url.searchParams.get('storeId') ?? shouldNeverHappen(`No storeId provided in request URL search params`)
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
- return from.storeId
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
- currentHeadRef.current = currentHead
75
-
76
- // I still don't know why we need to re-assign this ref to the `doSelf` object but somehow this seems to be needed 😵‍💫
77
- // @ts-expect-error
78
- doSelf[CacheSymbol].currentHeadRef = { current: currentHead }
79
- }
80
-
81
- const rpcSubscriptions = new Map<string, RpcSubscription>()
82
-
83
- const storageCache = {
84
- storeId,
85
- backendId,
86
- currentHeadRef,
87
- updateCurrentHead,
88
- storage,
89
- doOptions,
90
- env: doSelf.env,
91
- ctx: doSelf.ctx,
92
- rpcSubscriptions,
93
- }
94
-
95
- ;(doSelf as any)[CacheSymbol] = storageCache
96
-
97
- // Set initial current head to root
98
- if (storageRow === undefined) {
99
- updateCurrentHead(EventSequenceNumber.ROOT.global)
100
- }
101
-
102
- return storageCache
103
- },
104
- UnexpectedError.mapToUnexpectedError,
105
- Effect.withSpan('@livestore/sync-cf:durable-object:makeDoCtx'),
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
+ })