@livestore/sync-cf 0.4.0 → 0.5.0-dev.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/README.md +5 -5
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/cf-worker/do/durable-object.d.ts.map +1 -1
  4. package/dist/cf-worker/do/durable-object.js +53 -36
  5. package/dist/cf-worker/do/durable-object.js.map +1 -1
  6. package/dist/cf-worker/do/layer.d.ts +22 -18
  7. package/dist/cf-worker/do/layer.d.ts.map +1 -1
  8. package/dist/cf-worker/do/layer.js +45 -35
  9. package/dist/cf-worker/do/layer.js.map +1 -1
  10. package/dist/cf-worker/do/observability.d.ts +23 -0
  11. package/dist/cf-worker/do/observability.d.ts.map +1 -0
  12. package/dist/cf-worker/do/observability.js +52 -0
  13. package/dist/cf-worker/do/observability.js.map +1 -0
  14. package/dist/cf-worker/do/observability.test.d.ts +2 -0
  15. package/dist/cf-worker/do/observability.test.d.ts.map +1 -0
  16. package/dist/cf-worker/do/observability.test.js +204 -0
  17. package/dist/cf-worker/do/observability.test.js.map +1 -0
  18. package/dist/cf-worker/do/pull.d.ts +3 -3
  19. package/dist/cf-worker/do/pull.d.ts.map +1 -1
  20. package/dist/cf-worker/do/pull.js +19 -20
  21. package/dist/cf-worker/do/pull.js.map +1 -1
  22. package/dist/cf-worker/do/push.d.ts +16 -3
  23. package/dist/cf-worker/do/push.d.ts.map +1 -1
  24. package/dist/cf-worker/do/push.js +74 -53
  25. package/dist/cf-worker/do/push.js.map +1 -1
  26. package/dist/cf-worker/do/push.test.d.ts +2 -0
  27. package/dist/cf-worker/do/push.test.d.ts.map +1 -0
  28. package/dist/cf-worker/do/push.test.js +30 -0
  29. package/dist/cf-worker/do/push.test.js.map +1 -0
  30. package/dist/cf-worker/do/sqlite.d.ts +34 -46
  31. package/dist/cf-worker/do/sqlite.d.ts.map +1 -1
  32. package/dist/cf-worker/do/sqlite.js +2 -2
  33. package/dist/cf-worker/do/sqlite.js.map +1 -1
  34. package/dist/cf-worker/do/sync-storage.d.ts +7 -6
  35. package/dist/cf-worker/do/sync-storage.d.ts.map +1 -1
  36. package/dist/cf-worker/do/sync-storage.js +29 -17
  37. package/dist/cf-worker/do/sync-storage.js.map +1 -1
  38. package/dist/cf-worker/do/transport/do-rpc-server.d.ts +17 -2
  39. package/dist/cf-worker/do/transport/do-rpc-server.d.ts.map +1 -1
  40. package/dist/cf-worker/do/transport/do-rpc-server.js +41 -23
  41. package/dist/cf-worker/do/transport/do-rpc-server.js.map +1 -1
  42. package/dist/cf-worker/do/transport/do-rpc-server.test.d.ts +2 -0
  43. package/dist/cf-worker/do/transport/do-rpc-server.test.d.ts.map +1 -0
  44. package/dist/cf-worker/do/transport/do-rpc-server.test.js +28 -0
  45. package/dist/cf-worker/do/transport/do-rpc-server.test.js.map +1 -0
  46. package/dist/cf-worker/do/transport/http-rpc-server.d.ts +2 -2
  47. package/dist/cf-worker/do/transport/http-rpc-server.d.ts.map +1 -1
  48. package/dist/cf-worker/do/transport/http-rpc-server.js +12 -12
  49. package/dist/cf-worker/do/transport/http-rpc-server.js.map +1 -1
  50. package/dist/cf-worker/do/transport/ws-rpc-server.d.ts +9 -2
  51. package/dist/cf-worker/do/transport/ws-rpc-server.d.ts.map +1 -1
  52. package/dist/cf-worker/do/transport/ws-rpc-server.js +37 -29
  53. package/dist/cf-worker/do/transport/ws-rpc-server.js.map +1 -1
  54. package/dist/cf-worker/mod.js +3 -3
  55. package/dist/cf-worker/shared.d.ts +37 -29
  56. package/dist/cf-worker/shared.d.ts.map +1 -1
  57. package/dist/cf-worker/shared.js +14 -12
  58. package/dist/cf-worker/shared.js.map +1 -1
  59. package/dist/cf-worker/worker.d.ts +9 -9
  60. package/dist/cf-worker/worker.d.ts.map +1 -1
  61. package/dist/cf-worker/worker.js +17 -15
  62. package/dist/cf-worker/worker.js.map +1 -1
  63. package/dist/client/mod.js +3 -3
  64. package/dist/client/transport/do-rpc-client.d.ts +18 -6
  65. package/dist/client/transport/do-rpc-client.d.ts.map +1 -1
  66. package/dist/client/transport/do-rpc-client.js +112 -38
  67. package/dist/client/transport/do-rpc-client.js.map +1 -1
  68. package/dist/client/transport/do-rpc-client.test.d.ts +2 -0
  69. package/dist/client/transport/do-rpc-client.test.d.ts.map +1 -0
  70. package/dist/client/transport/do-rpc-client.test.js +268 -0
  71. package/dist/client/transport/do-rpc-client.test.js.map +1 -0
  72. package/dist/client/transport/http-rpc-client.d.ts +3 -3
  73. package/dist/client/transport/http-rpc-client.d.ts.map +1 -1
  74. package/dist/client/transport/http-rpc-client.js +48 -29
  75. package/dist/client/transport/http-rpc-client.js.map +1 -1
  76. package/dist/client/transport/rpc-client-error.d.ts +5 -0
  77. package/dist/client/transport/rpc-client-error.d.ts.map +1 -0
  78. package/dist/client/transport/rpc-client-error.js +8 -0
  79. package/dist/client/transport/rpc-client-error.js.map +1 -0
  80. package/dist/client/transport/rpc-client-error.test.d.ts +2 -0
  81. package/dist/client/transport/rpc-client-error.test.d.ts.map +1 -0
  82. package/dist/client/transport/rpc-client-error.test.js +33 -0
  83. package/dist/client/transport/rpc-client-error.test.js.map +1 -0
  84. package/dist/client/transport/ws-rpc-client.d.ts +3 -3
  85. package/dist/client/transport/ws-rpc-client.d.ts.map +1 -1
  86. package/dist/client/transport/ws-rpc-client.js +33 -23
  87. package/dist/client/transport/ws-rpc-client.js.map +1 -1
  88. package/dist/common/do-rpc-schema.d.ts +53 -41
  89. package/dist/common/do-rpc-schema.d.ts.map +1 -1
  90. package/dist/common/do-rpc-schema.js +13 -4
  91. package/dist/common/do-rpc-schema.js.map +1 -1
  92. package/dist/common/http-rpc-schema.d.ts +38 -39
  93. package/dist/common/http-rpc-schema.d.ts.map +1 -1
  94. package/dist/common/http-rpc-schema.js +6 -6
  95. package/dist/common/http-rpc-schema.js.map +1 -1
  96. package/dist/common/mod.d.ts +5 -5
  97. package/dist/common/mod.d.ts.map +1 -1
  98. package/dist/common/mod.js +7 -7
  99. package/dist/common/mod.js.map +1 -1
  100. package/dist/common/sync-message-types.d.ts +116 -133
  101. package/dist/common/sync-message-types.d.ts.map +1 -1
  102. package/dist/common/sync-message-types.js +26 -14
  103. package/dist/common/sync-message-types.js.map +1 -1
  104. package/dist/common/ws-rpc-schema.d.ts +33 -34
  105. package/dist/common/ws-rpc-schema.d.ts.map +1 -1
  106. package/dist/common/ws-rpc-schema.js +5 -5
  107. package/dist/common/ws-rpc-schema.js.map +1 -1
  108. package/package.json +42 -58
  109. package/src/cf-worker/do/durable-object.ts +70 -64
  110. package/src/cf-worker/do/layer.ts +142 -99
  111. package/src/cf-worker/do/observability.test.ts +337 -0
  112. package/src/cf-worker/do/observability.ts +121 -0
  113. package/src/cf-worker/do/pull.ts +28 -28
  114. package/src/cf-worker/do/push.test.ts +40 -0
  115. package/src/cf-worker/do/push.ts +139 -115
  116. package/src/cf-worker/do/sqlite.ts +1 -1
  117. package/src/cf-worker/do/sync-storage.ts +34 -21
  118. package/src/cf-worker/do/transport/do-rpc-server.test.ts +32 -0
  119. package/src/cf-worker/do/transport/do-rpc-server.ts +46 -35
  120. package/src/cf-worker/do/transport/http-rpc-server.ts +7 -10
  121. package/src/cf-worker/do/transport/ws-rpc-server.ts +52 -28
  122. package/src/cf-worker/shared.ts +29 -17
  123. package/src/cf-worker/worker.ts +21 -22
  124. package/src/client/transport/do-rpc-client.test.ts +411 -0
  125. package/src/client/transport/do-rpc-client.ts +175 -51
  126. package/src/client/transport/http-rpc-client.ts +66 -45
  127. package/src/client/transport/rpc-client-error.test.ts +41 -0
  128. package/src/client/transport/rpc-client-error.ts +9 -0
  129. package/src/client/transport/ws-rpc-client.ts +40 -32
  130. package/src/common/do-rpc-schema.ts +13 -3
  131. package/src/common/http-rpc-schema.ts +5 -5
  132. package/src/common/mod.ts +4 -4
  133. package/src/common/sync-message-types.ts +16 -16
  134. package/src/common/ws-rpc-schema.ts +4 -4
package/package.json CHANGED
@@ -1,88 +1,72 @@
1
1
  {
2
+ "$genie": {
3
+ "source": "package.json.genie.ts",
4
+ "warning": "DO NOT EDIT - changes will be overwritten",
5
+ "workspaceClosureDirs": [
6
+ "packages/@livestore/common",
7
+ "packages/@livestore/common-cf",
8
+ "packages/@livestore/sync-cf",
9
+ "packages/@livestore/utils",
10
+ "packages/@livestore/utils-dev",
11
+ "packages/@livestore/webmesh"
12
+ ]
13
+ },
2
14
  "name": "@livestore/sync-cf",
3
- "version": "0.4.0",
15
+ "version": "0.5.0-dev.1",
16
+ "type": "module",
17
+ "sideEffects": false,
4
18
  "license": "Apache-2.0",
5
19
  "repository": {
6
20
  "type": "git",
7
21
  "url": "git+https://github.com/livestorejs/livestore.git"
8
22
  },
23
+ "exports": {
24
+ "./client": "./dist/client/mod.js",
25
+ "./common": "./dist/common/mod.js",
26
+ "./cf-worker": "./dist/cf-worker/mod.js"
27
+ },
9
28
  "files": [
10
29
  "dist",
11
30
  "package.json",
12
31
  "src",
13
32
  "README.md"
14
33
  ],
15
- "type": "module",
16
- "sideEffects": false,
17
- "exports": {
18
- "./client": "./dist/client/mod.js",
19
- "./common": "./dist/common/mod.js",
20
- "./cf-worker": "./dist/cf-worker/mod.js"
21
- },
22
- "publishConfig": {
23
- "access": "public"
24
- },
25
34
  "dependencies": {
26
35
  "@cloudflare/workers-types": "4.20251118.0",
27
- "@livestore/common": "^0.4.0",
28
- "@livestore/common-cf": "^0.4.0",
29
- "@livestore/utils": "^0.4.0"
36
+ "@livestore/common": "^0.5.0-dev.1",
37
+ "@livestore/common-cf": "^0.5.0-dev.1",
38
+ "@livestore/utils": "^0.5.0-dev.1"
30
39
  },
31
40
  "devDependencies": {
32
- "@effect/ai": "0.35.0",
33
- "@effect/cli": "0.75.1",
34
- "@effect/cluster": "0.58.2",
35
- "@effect/experimental": "0.60.0",
36
- "@effect/opentelemetry": "0.63.0",
37
- "@effect/platform": "0.96.1",
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",
41
+ "@effect/opentelemetry": "4.0.3",
42
+ "@effect/platform-browser": "4.0.3",
43
+ "@effect/platform-bun": "4.0.3",
44
+ "@effect/platform-node": "4.0.3",
45
+ "@effect/platform-node-shared": "4.0.3",
46
+ "@effect/vitest": "4.0.3",
47
+ "@livestore/utils-dev": "^0.5.0-dev.1",
47
48
  "@opentelemetry/api": "1.9.0",
48
49
  "@opentelemetry/resources": "2.2.0",
49
50
  "@standard-schema/spec": "1.1.0",
50
- "effect": "3.21.2"
51
+ "effect": "4.0.3",
52
+ "vitest": "4.1.9"
51
53
  },
52
54
  "peerDependencies": {
53
- "@effect/ai": "^0.35.0",
54
- "@effect/cli": "^0.75.1",
55
- "@effect/cluster": "^0.58.2",
56
- "@effect/experimental": "^0.60.0",
57
- "@effect/opentelemetry": "^0.63.0",
58
- "@effect/platform": "^0.96.1",
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",
55
+ "@effect/opentelemetry": "^4.0.3",
56
+ "@effect/platform-browser": "^4.0.3",
57
+ "@effect/platform-bun": "^4.0.3",
58
+ "@effect/platform-node": "^4.0.3",
59
+ "@effect/platform-node-shared": "^4.0.3",
60
+ "@effect/vitest": "^4.0.3",
68
61
  "@opentelemetry/api": "^1.9.0",
69
62
  "@opentelemetry/resources": "^2.2.0",
70
63
  "@standard-schema/spec": "^1.1.0",
71
- "effect": "^3.21.2"
64
+ "effect": "^4.0.3"
72
65
  },
73
- "$genie": {
74
- "source": "package.json.genie.ts",
75
- "warning": "DO NOT EDIT - changes will be overwritten",
76
- "workspaceClosureDirs": [
77
- "packages/@livestore/common",
78
- "packages/@livestore/common-cf",
79
- "packages/@livestore/sync-cf",
80
- "packages/@livestore/utils",
81
- "packages/@livestore/utils-dev",
82
- "packages/@livestore/webmesh"
83
- ]
66
+ "publishConfig": {
67
+ "access": "public"
84
68
  },
85
69
  "scripts": {
86
- "test": "echo 'No tests yet'"
70
+ "test": "vitest run"
87
71
  }
88
72
  }
@@ -4,17 +4,7 @@ import { DurableObject } from 'cloudflare:workers'
4
4
 
5
5
  import { type CfTypes, setupDurableObjectWebSocketRpc } from '@livestore/common-cf'
6
6
  import { CfDeclare } from '@livestore/common-cf/declare'
7
- import {
8
- Effect,
9
- FetchHttpClient,
10
- Layer,
11
- Logger,
12
- LogLevel,
13
- Otlp,
14
- RpcMessage,
15
- Schema,
16
- type Scope,
17
- } from '@livestore/utils/effect'
7
+ import { Effect, Layer, Logger, References, RpcMessage, Schema, type Scope } from '@livestore/utils/effect'
18
8
 
19
9
  import {
20
10
  type Env,
@@ -24,17 +14,18 @@ import {
24
14
  type SyncBackendRpcInterface,
25
15
  WebSocketAttachmentSchema,
26
16
  } from '../shared.ts'
27
- import { DoCtx } from './layer.ts'
17
+ import * as DoCtx from './layer.ts'
18
+ import { makeObservability } from './observability.ts'
28
19
  import { createDoRpcHandler } from './transport/do-rpc-server.ts'
29
20
  import { createHttpRpcHandler } from './transport/http-rpc-server.ts'
30
21
  import { makeRpcServer } from './transport/ws-rpc-server.ts'
31
22
 
32
- // NOTE We need to redeclare runtime types here to avoid type conflicts with the lib.dom Response type.
33
- // TODO get rid of those once CF fixed their type mismatch in the worker types
34
- declare class Request extends CfDeclare.Request {}
35
- declare class Response extends CfDeclare.Response {}
36
- declare class WebSocketPair extends CfDeclare.WebSocketPair {}
37
- declare class WebSocketRequestResponsePair extends CfDeclare.WebSocketRequestResponsePair {}
23
+ const Response = CfDeclare.Response
24
+ const WebSocketPair = CfDeclare.WebSocketPair
25
+ const WebSocketRequestResponsePair = CfDeclare.WebSocketRequestResponsePair
26
+
27
+ /** Module-scoped JSON encoder; keeping the sync codec out of Effect generators avoids `schemaSyncInEffect`. */
28
+ const jsonStringify = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown))
38
29
 
39
30
  const DurableObjectBase = DurableObject as any as new (
40
31
  state: CfTypes.DurableObjectState,
@@ -52,14 +43,14 @@ export type MakeDurableObjectClass = (options?: MakeDurableObjectClassOptions) =
52
43
  /**
53
44
  * Creates a Durable Object class for handling WebSocket-based sync.
54
45
  * A sync Durable Object is uniquely scoped to a specific `storeId`.
55
- *
46
+ *
56
47
  * The sync DO supports 3 transport modes:
57
48
  * - HTTP JSON-RPC
58
49
  * - WebSocket
59
50
  * - Durable Object RPC calls (only works in combination with `@livestore/adapter-cf`)
60
51
  *
61
52
  * Example:
62
- *
53
+ *
63
54
  * ```ts
64
55
  * // In your Cloudflare Worker file
65
56
  * import { makeDurableObject } from '@livestore/sync-cf/cf-worker'
@@ -88,25 +79,18 @@ export type MakeDurableObjectClass = (options?: MakeDurableObjectClassOptions) =
88
79
  export const makeDurableObject: MakeDurableObjectClass = (options) => {
89
80
  const enabledTransports = options?.enabledTransports ?? new Set(['http', 'ws', 'do-rpc'])
90
81
 
91
- const Logging = Logger.consoleWithThread('SyncDo')
92
-
93
- const Observability: Layer.Layer<never> = options?.otel?.baseUrl !== undefined
94
- ? (Otlp.layer({
95
- baseUrl: options.otel.baseUrl,
96
- tracerExportInterval: 50,
97
- resource: {
98
- serviceName: options.otel.serviceName ?? 'sync-cf-do',
99
- },
100
- }).pipe(Layer.provide(FetchHttpClient.layer)) as Layer.Layer<never>)
101
- : Layer.empty
102
-
103
82
  return class SyncBackendDOBase extends DurableObjectBase implements SyncBackendRpcInterface {
104
83
  __DURABLE_OBJECT_BRAND = 'SyncBackendDOBase' as never
84
+ private readonly observability = makeObservability(options?.otel)
105
85
 
106
86
  constructor(ctx: CfTypes.DurableObjectState, env: Env) {
107
87
  super(ctx, env)
108
88
 
109
- const WebSocketRpcServerLive = makeRpcServer({ doSelf: this, doOptions: options })
89
+ const WebSocketRpcServerLive = makeRpcServer({
90
+ doSelf: this,
91
+ doOptions: options,
92
+ observability: this.observability,
93
+ })
110
94
 
111
95
  // This registers the `webSocketMessage` and `webSocketClose` handlers
112
96
  if (enabledTransports.has('ws') === true) {
@@ -118,35 +102,24 @@ export const makeDurableObject: MakeDurableObjectClass = (options) => {
118
102
  // in combination with DO hibernation
119
103
  onMessage: (request, ws) => {
120
104
  if (request._tag === 'Request' && request.tag === 'SyncWsRpc.Pull') {
121
- // Is Pull request: add requestId to pullRequestIds
122
105
  const attachment = ws.deserializeAttachment()
123
- const { pullRequestIds, ...rest } = Schema.decodeSync(WebSocketAttachmentSchema)(attachment)
106
+ const { pullRequestIds, ...rest } = Schema.decodeUnknownSync(WebSocketAttachmentSchema)(attachment)
124
107
  ws.serializeAttachment(
125
108
  Schema.encodeSync(WebSocketAttachmentSchema)({
126
109
  ...rest,
127
110
  pullRequestIds: [...pullRequestIds, request.id],
128
111
  }),
129
112
  )
130
- } else if (request._tag === 'Interrupt') {
131
- // Is Interrupt request: remove requestId from pullRequestIds
132
- const attachment = ws.deserializeAttachment()
133
- const { pullRequestIds, ...rest } = Schema.decodeSync(WebSocketAttachmentSchema)(attachment)
134
- ws.serializeAttachment(
135
- Schema.encodeSync(WebSocketAttachmentSchema)({
136
- ...rest,
137
- pullRequestIds: pullRequestIds.filter((id) => id !== request.requestId),
138
- }),
139
- )
140
- // TODO also emit `Exit` stream RPC message
141
113
  }
142
114
  },
143
- mainLayer: Observability,
115
+ onRequestExit: (requestId, ws) => removePullRequestId(ws, requestId),
116
+ onProtocolDefect: clearPullRequestIds,
144
117
  })
145
118
  }
146
119
  }
147
120
 
148
- override fetch = async (request: Request): Promise<Response> =>
149
- Effect.gen(this, function* () {
121
+ override fetch = async (request: CfTypes.Request): Promise<CfTypes.Response> =>
122
+ Effect.gen({ self: this }, function* () {
150
123
  const searchParams = matchSyncRequest(request)
151
124
  if (searchParams === undefined) {
152
125
  throw new Error('No search params found in request URL')
@@ -170,9 +143,16 @@ export const makeDurableObject: MakeDurableObjectClass = (options) => {
170
143
 
171
144
  // Since we're using websocket hibernation, we need to remember the storeId for subsequent `webSocketMessage` calls
172
145
  // Also store forwarded headers so they're available after hibernation resume
173
- server.serializeAttachment(
174
- Schema.encodeSync(WebSocketAttachmentSchema)({ storeId, payload, pullRequestIds: [], headers }),
175
- )
146
+ // Encoding known-valid domain data: an encode failure is an invariant violation (a defect),
147
+ // so `Effect.orDie` is the correct modeling. serializeAttachment takes the value synchronously,
148
+ // so resolve the encoded value first, then pass it in.
149
+ const attachment = yield* Schema.encodeEffect(WebSocketAttachmentSchema)({
150
+ storeId,
151
+ payload,
152
+ pullRequestIds: [],
153
+ headers,
154
+ }).pipe(Effect.orDie)
155
+ server.serializeAttachment(attachment)
176
156
 
177
157
  // See https://developers.cloudflare.com/durable-objects/examples/websocket-hibernation-server
178
158
 
@@ -180,10 +160,7 @@ export const makeDurableObject: MakeDurableObjectClass = (options) => {
180
160
 
181
161
  // Ping requests are sent by Effect RPC internally
182
162
  this.ctx.setWebSocketAutoResponse(
183
- new WebSocketRequestResponsePair(
184
- JSON.stringify(RpcMessage.constPing),
185
- JSON.stringify(RpcMessage.constPong),
186
- ),
163
+ new WebSocketRequestResponsePair(jsonStringify(RpcMessage.constPing), jsonStringify(RpcMessage.constPong)),
187
164
  )
188
165
 
189
166
  return new Response(null, {
@@ -200,11 +177,11 @@ export const makeDurableObject: MakeDurableObjectClass = (options) => {
200
177
  })
201
178
  }).pipe(
202
179
  Effect.tapCauseLogPretty, // Also log errors to console before catching them
203
- Effect.catchAllCause((cause) =>
180
+ Effect.catchCause((cause) =>
204
181
  Effect.succeed(new Response('Error', { status: 500, statusText: cause.toString() })),
205
182
  ),
206
183
  Effect.withSpan('@livestore/sync-cf:durable-object:fetch'),
207
- Effect.provide(DoCtx.Default({ doSelf: this, doOptions: options, from: request })),
184
+ Effect.provide(DoCtx.layer({ doSelf: this, doOptions: options, from: request })),
208
185
  this.runEffectAsPromise,
209
186
  )
210
187
 
@@ -216,10 +193,11 @@ export const makeDurableObject: MakeDurableObjectClass = (options) => {
216
193
  throw new Error('Do RPC transport is not enabled (based on `options.enabledTransports`)')
217
194
  }
218
195
 
219
- return createDoRpcHandler({ payload, input: { doSelf: this, doOptions: options } }).pipe(
220
- Effect.withSpan('@livestore/sync-cf:durable-object:rpc'),
221
- this.runEffectAsPromise,
222
- )
196
+ return createDoRpcHandler({
197
+ payload,
198
+ input: { doSelf: this, doOptions: options },
199
+ observability: this.observability,
200
+ }).pipe(Effect.withSpan('@livestore/sync-cf:durable-object:rpc'), this.runEffectAsPromise)
223
201
  }
224
202
 
225
203
  /**
@@ -237,10 +215,38 @@ export const makeDurableObject: MakeDurableObjectClass = (options) => {
237
215
  private runEffectAsPromise = <T, E = never>(effect: Effect.Effect<T, E, Scope.Scope>): Promise<T> =>
238
216
  effect.pipe(
239
217
  Effect.tapCauseLogPretty,
240
- Logger.withMinimumLogLevel(LogLevel.Debug),
241
- Effect.provide(Layer.mergeAll(Observability, Logging)),
218
+ Effect.annotateLogs({ thread: 'SyncDo' }),
219
+ Effect.provide(
220
+ Layer.mergeAll(Logger.layer([Logger.consoleStructured]), Layer.succeed(References.MinimumLogLevel, 'Debug')),
221
+ ),
242
222
  Effect.scoped,
223
+ this.observability.effect,
243
224
  Effect.runPromise,
244
225
  )
245
226
  }
246
227
  }
228
+
229
+ const removePullRequestId = (ws: CfTypes.WebSocket, requestId: string | number) =>
230
+ updatePullRequestIds(ws, (pullRequestIds) =>
231
+ pullRequestIds.includes(requestId) === true ? pullRequestIds.filter((id) => id !== requestId) : pullRequestIds,
232
+ )
233
+
234
+ const clearPullRequestIds = (ws: CfTypes.WebSocket) =>
235
+ updatePullRequestIds(ws, (pullRequestIds) => (pullRequestIds.length === 0 ? pullRequestIds : []))
236
+
237
+ const updatePullRequestIds = (
238
+ ws: CfTypes.WebSocket,
239
+ update: (pullRequestIds: ReadonlyArray<string | number>) => ReadonlyArray<string | number>,
240
+ ) => {
241
+ const attachment = ws.deserializeAttachment()
242
+ const { pullRequestIds, ...rest } = Schema.decodeUnknownSync(WebSocketAttachmentSchema)(attachment)
243
+ const nextPullRequestIds = update(pullRequestIds)
244
+ if (nextPullRequestIds === pullRequestIds) return
245
+
246
+ ws.serializeAttachment(
247
+ Schema.encodeSync(WebSocketAttachmentSchema)({
248
+ ...rest,
249
+ pullRequestIds: nextPullRequestIds,
250
+ }),
251
+ )
252
+ }
@@ -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, RpcSubscription } from '../shared.ts'
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 DoCtxInput {
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 class DoCtx extends Effect.Service<DoCtx>()('DoCtx', {
24
- effect: Effect.fn(
25
- function* ({ doSelf, doOptions, from }: DoCtxInput) {
26
- if ((doSelf as any)[CacheSymbol] !== undefined) {
27
- return (doSelf as any)[CacheSymbol] as never
28
- }
29
-
30
- const getStoreId = (from: CfTypes.Request | { storeId: string }) => {
31
- if (Predicate.hasProperty(from, 'url') === true) {
32
- const url = new URL(from.url)
33
- return (
34
- url.searchParams.get('storeId') ?? shouldNeverHappen(`No storeId provided in request URL search params`)
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
- return from.storeId
38
- }
39
-
40
- const storeId = getStoreId(from)
41
- // Resolve storage engine
42
- const makeEngine = Effect.gen(function* () {
43
- const opt = doOptions?.storage
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 { _tag: 'd1' as const, db }
50
- } else if (opt?._tag === 'do-sqlite' || opt === undefined) {
51
- return { _tag: 'do-sqlite' as const }
52
- } else return shouldNeverHappen(`Invalid storage engine`, opt)
53
- })
71
+ return from.storeId
72
+ }
54
73
 
55
- const engine = yield* makeEngine
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
- const storage = makeStorage(doSelf.ctx, storeId, engine)
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
- // Initialize database tables
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
- const storageRow = doSelf.ctx.storage.sql
78
- .exec(`SELECT * FROM "${contextTable.sqliteDef.name}" WHERE storeId = ?`, storeId)
79
- .toArray()[0] as typeof contextTable.rowSchema.Type | undefined
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
- const currentHeadRef = { current: storageRow?.currentHead ?? EventSequenceNumber.Client.ROOT.global }
108
+ // Should be the same backendId for lifetime of the Durable Object
109
+ const backendId = storageRow?.backendId ?? nanoid()
82
110
 
83
- // TODO do concistency check with eventlog table to make sure the head is consistent
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
- // Should be the same backendId for lifetime of the Durable Object
86
- const backendId = storageRow?.backendId ?? nanoid()
119
+ currentHeadRef.current = currentHead
120
+ }
87
121
 
88
- const updateCurrentHead = (currentHead: EventSequenceNumber.Global.Type) => {
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
- currentHeadRef.current = currentHead
97
-
98
- // I still don't know why we need to re-assign this ref to the `doSelf` object but somehow this seems to be needed 😵‍💫
99
- // @ts-expect-error
100
- doSelf[CacheSymbol].currentHeadRef = { current: currentHead }
101
- }
102
-
103
- const rpcSubscriptions = new Map<string, RpcSubscription>()
104
-
105
- const storageCache = {
106
- storeId,
107
- backendId,
108
- currentHeadRef,
109
- updateCurrentHead,
110
- storage,
111
- doOptions,
112
- env: doSelf.env,
113
- ctx: doSelf.ctx,
114
- rpcSubscriptions,
115
- }
116
-
117
- ;(doSelf as any)[CacheSymbol] = storageCache
118
-
119
- // Set initial current head to root
120
- if (storageRow === undefined) {
121
- updateCurrentHead(EventSequenceNumber.Client.ROOT.global)
122
- }
123
-
124
- return storageCache
125
- },
126
- UnknownError.mapToUnknownError,
127
- Effect.withSpan('@livestore/sync-cf:durable-object:makeDoCtx'),
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
+ })