@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.
Files changed (101) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/cf-worker/do/durable-object.d.ts.map +1 -1
  3. package/dist/cf-worker/do/durable-object.js +24 -11
  4. package/dist/cf-worker/do/durable-object.js.map +1 -1
  5. package/dist/cf-worker/do/layer.d.ts +22 -18
  6. package/dist/cf-worker/do/layer.d.ts.map +1 -1
  7. package/dist/cf-worker/do/layer.js +43 -33
  8. package/dist/cf-worker/do/layer.js.map +1 -1
  9. package/dist/cf-worker/do/pull.d.ts +3 -3
  10. package/dist/cf-worker/do/pull.d.ts.map +1 -1
  11. package/dist/cf-worker/do/pull.js +17 -18
  12. package/dist/cf-worker/do/pull.js.map +1 -1
  13. package/dist/cf-worker/do/push.d.ts +16 -3
  14. package/dist/cf-worker/do/push.d.ts.map +1 -1
  15. package/dist/cf-worker/do/push.js +69 -49
  16. package/dist/cf-worker/do/push.js.map +1 -1
  17. package/dist/cf-worker/do/push.test.d.ts +2 -0
  18. package/dist/cf-worker/do/push.test.d.ts.map +1 -0
  19. package/dist/cf-worker/do/push.test.js +30 -0
  20. package/dist/cf-worker/do/push.test.js.map +1 -0
  21. package/dist/cf-worker/do/sqlite.d.ts +34 -46
  22. package/dist/cf-worker/do/sqlite.d.ts.map +1 -1
  23. package/dist/cf-worker/do/sqlite.js +1 -1
  24. package/dist/cf-worker/do/sqlite.js.map +1 -1
  25. package/dist/cf-worker/do/sync-storage.d.ts +7 -6
  26. package/dist/cf-worker/do/sync-storage.d.ts.map +1 -1
  27. package/dist/cf-worker/do/sync-storage.js +26 -14
  28. package/dist/cf-worker/do/sync-storage.js.map +1 -1
  29. package/dist/cf-worker/do/transport/do-rpc-server.d.ts +2 -2
  30. package/dist/cf-worker/do/transport/do-rpc-server.d.ts.map +1 -1
  31. package/dist/cf-worker/do/transport/do-rpc-server.js +21 -19
  32. package/dist/cf-worker/do/transport/do-rpc-server.js.map +1 -1
  33. package/dist/cf-worker/do/transport/http-rpc-server.d.ts +2 -2
  34. package/dist/cf-worker/do/transport/http-rpc-server.d.ts.map +1 -1
  35. package/dist/cf-worker/do/transport/http-rpc-server.js +7 -7
  36. package/dist/cf-worker/do/transport/http-rpc-server.js.map +1 -1
  37. package/dist/cf-worker/do/transport/ws-rpc-server.d.ts +2 -2
  38. package/dist/cf-worker/do/transport/ws-rpc-server.d.ts.map +1 -1
  39. package/dist/cf-worker/do/transport/ws-rpc-server.js +9 -9
  40. package/dist/cf-worker/do/transport/ws-rpc-server.js.map +1 -1
  41. package/dist/cf-worker/shared.d.ts +22 -19
  42. package/dist/cf-worker/shared.d.ts.map +1 -1
  43. package/dist/cf-worker/shared.js +13 -11
  44. package/dist/cf-worker/shared.js.map +1 -1
  45. package/dist/cf-worker/worker.d.ts +6 -6
  46. package/dist/cf-worker/worker.d.ts.map +1 -1
  47. package/dist/cf-worker/worker.js +16 -14
  48. package/dist/cf-worker/worker.js.map +1 -1
  49. package/dist/client/transport/do-rpc-client.d.ts +13 -4
  50. package/dist/client/transport/do-rpc-client.d.ts.map +1 -1
  51. package/dist/client/transport/do-rpc-client.js +55 -26
  52. package/dist/client/transport/do-rpc-client.js.map +1 -1
  53. package/dist/client/transport/http-rpc-client.d.ts +3 -3
  54. package/dist/client/transport/http-rpc-client.d.ts.map +1 -1
  55. package/dist/client/transport/http-rpc-client.js +30 -21
  56. package/dist/client/transport/http-rpc-client.js.map +1 -1
  57. package/dist/client/transport/ws-rpc-client.d.ts +3 -3
  58. package/dist/client/transport/ws-rpc-client.d.ts.map +1 -1
  59. package/dist/client/transport/ws-rpc-client.js +20 -16
  60. package/dist/client/transport/ws-rpc-client.js.map +1 -1
  61. package/dist/common/do-rpc-schema.d.ts +38 -39
  62. package/dist/common/do-rpc-schema.d.ts.map +1 -1
  63. package/dist/common/do-rpc-schema.js +3 -3
  64. package/dist/common/do-rpc-schema.js.map +1 -1
  65. package/dist/common/http-rpc-schema.d.ts +36 -37
  66. package/dist/common/http-rpc-schema.d.ts.map +1 -1
  67. package/dist/common/http-rpc-schema.js +5 -5
  68. package/dist/common/http-rpc-schema.js.map +1 -1
  69. package/dist/common/mod.d.ts +4 -5
  70. package/dist/common/mod.d.ts.map +1 -1
  71. package/dist/common/mod.js +3 -4
  72. package/dist/common/mod.js.map +1 -1
  73. package/dist/common/sync-message-types.d.ts +116 -133
  74. package/dist/common/sync-message-types.d.ts.map +1 -1
  75. package/dist/common/sync-message-types.js +26 -14
  76. package/dist/common/sync-message-types.js.map +1 -1
  77. package/dist/common/ws-rpc-schema.d.ts +33 -34
  78. package/dist/common/ws-rpc-schema.d.ts.map +1 -1
  79. package/dist/common/ws-rpc-schema.js +4 -4
  80. package/dist/common/ws-rpc-schema.js.map +1 -1
  81. package/package.json +21 -37
  82. package/src/cf-worker/do/durable-object.ts +45 -36
  83. package/src/cf-worker/do/layer.ts +142 -99
  84. package/src/cf-worker/do/pull.ts +28 -28
  85. package/src/cf-worker/do/push.test.ts +40 -0
  86. package/src/cf-worker/do/push.ts +138 -115
  87. package/src/cf-worker/do/sqlite.ts +1 -1
  88. package/src/cf-worker/do/sync-storage.ts +34 -21
  89. package/src/cf-worker/do/transport/do-rpc-server.ts +23 -34
  90. package/src/cf-worker/do/transport/http-rpc-server.ts +7 -10
  91. package/src/cf-worker/do/transport/ws-rpc-server.ts +10 -10
  92. package/src/cf-worker/shared.ts +18 -13
  93. package/src/cf-worker/worker.ts +21 -22
  94. package/src/client/transport/do-rpc-client.ts +82 -40
  95. package/src/client/transport/http-rpc-client.ts +47 -37
  96. package/src/client/transport/ws-rpc-client.ts +29 -26
  97. package/src/common/do-rpc-schema.ts +3 -3
  98. package/src/common/http-rpc-schema.ts +5 -5
  99. package/src/common/mod.ts +3 -4
  100. package/src/common/sync-message-types.ts +16 -16
  101. 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.4.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/common": "^0.4.0",
28
- "@livestore/common-cf": "^0.4.0",
29
- "@livestore/utils": "^0.4.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
30
  },
31
31
  "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",
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": "3.21.2"
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/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",
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": "^3.21.2"
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": "echo 'No tests yet'"
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 { DoCtx } from './layer.ts'
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
- // 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 {}
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 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
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.decodeSync(WebSocketAttachmentSchema)(attachment)
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.decodeSync(WebSocketAttachmentSchema)(attachment)
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
- server.serializeAttachment(
174
- Schema.encodeSync(WebSocketAttachmentSchema)({ storeId, payload, pullRequestIds: [], headers }),
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.catchAllCause((cause) =>
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.Default({ doSelf: this, doOptions: options, from: request })),
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
- Logger.withMinimumLogLevel(LogLevel.Debug),
241
- 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
+ ),
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, 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
+ })
@@ -1,11 +1,11 @@
1
1
  import { BackendIdMismatchError, SyncBackend, UnknownError } from '@livestore/common'
2
- import { splitChunkBySize } from '@livestore/common/sync'
3
- import { Chunk, Effect, Option, Schema, Stream } from '@livestore/utils/effect'
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 { DoCtx } from './layer.ts'
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.JsonValue | undefined
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.tryAll(() =>
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.mapChunksEffect(
55
- splitChunkBySize({
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(total, (remaining, chunk) => {
65
- const asArray = Chunk.toReadonlyArray(chunk)
66
- const nextRemaining = Math.max(0, remaining - asArray.length)
62
+ Stream.mapAccum(
63
+ () => total,
64
+ (remaining, array) => {
65
+ const nextRemaining = Math.max(0, remaining - array.length)
67
66
 
68
- return [
69
- nextRemaining,
70
- SyncMessage.PullResponse.make({
71
- batch: asArray,
72
- pageInfo: nextRemaining > 0 ? SyncBackend.pageInfoMoreKnown(nextRemaining) : SyncBackend.pageInfoNoMore,
73
- backendId,
74
- }),
75
- ] as const
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.tryAll(() => doOptions.onPullRes!(res)).pipe(UnknownError.mapToUnknownError)
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
  )