@livestore/adapter-node 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 (41) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/client-session/adapter.d.ts +1 -1
  3. package/dist/client-session/adapter.d.ts.map +1 -1
  4. package/dist/client-session/adapter.js +58 -39
  5. package/dist/client-session/adapter.js.map +1 -1
  6. package/dist/devtools/devtools-server.d.ts.map +1 -1
  7. package/dist/devtools/devtools-server.js +12 -14
  8. package/dist/devtools/devtools-server.js.map +1 -1
  9. package/dist/devtools/vite-dev-server.d.ts +7 -11
  10. package/dist/devtools/vite-dev-server.d.ts.map +1 -1
  11. package/dist/devtools/vite-dev-server.js +5 -6
  12. package/dist/devtools/vite-dev-server.js.map +1 -1
  13. package/dist/leader-thread-shared.d.ts +4 -4
  14. package/dist/leader-thread-shared.d.ts.map +1 -1
  15. package/dist/leader-thread-shared.js +13 -12
  16. package/dist/leader-thread-shared.js.map +1 -1
  17. package/dist/make-leader-worker.d.ts +4 -4
  18. package/dist/make-leader-worker.d.ts.map +1 -1
  19. package/dist/make-leader-worker.js +62 -71
  20. package/dist/make-leader-worker.js.map +1 -1
  21. package/dist/rpc-worker.d.ts +8 -0
  22. package/dist/rpc-worker.d.ts.map +1 -0
  23. package/dist/rpc-worker.js +5 -0
  24. package/dist/rpc-worker.js.map +1 -0
  25. package/dist/webchannel.d.ts +1 -1
  26. package/dist/webchannel.d.ts.map +1 -1
  27. package/dist/webchannel.js +17 -18
  28. package/dist/webchannel.js.map +1 -1
  29. package/dist/worker-schema.d.ts +248 -212
  30. package/dist/worker-schema.d.ts.map +1 -1
  31. package/dist/worker-schema.js +62 -53
  32. package/dist/worker-schema.js.map +1 -1
  33. package/package.json +22 -31
  34. package/src/client-session/adapter.ts +104 -77
  35. package/src/devtools/devtools-server.ts +33 -30
  36. package/src/devtools/vite-dev-server.ts +7 -9
  37. package/src/leader-thread-shared.ts +24 -25
  38. package/src/make-leader-worker.ts +163 -127
  39. package/src/rpc-worker.ts +28 -0
  40. package/src/webchannel.ts +21 -28
  41. package/src/worker-schema.ts +115 -149
@@ -8,7 +8,6 @@ import {
8
8
  type BootStatus,
9
9
  ClientSessionLeaderThreadProxy,
10
10
  IntentionalShutdownCause,
11
- isWorkerTransportError,
12
11
  type LockStatus,
13
12
  type MakeSqliteDb,
14
13
  makeClientSession,
@@ -29,20 +28,22 @@ import {
29
28
  Fiber,
30
29
  FileSystem,
31
30
  Layer,
32
- Option,
33
31
  Queue,
32
+ RpcClient,
33
+ RpcWorker,
34
34
  Schedule,
35
35
  Schema,
36
+ Scope,
36
37
  Stream,
37
38
  Subscribable,
38
39
  SubscriptionRef,
39
- Worker,
40
40
  } from '@livestore/utils/effect'
41
41
  import { PlatformNode } from '@livestore/utils/node'
42
42
  import * as Webmesh from '@livestore/webmesh'
43
43
 
44
44
  import type { TestingOverrides } from '../leader-thread-shared.ts'
45
45
  import { makeLeaderThread } from '../leader-thread-shared.ts'
46
+ import { type WithoutRpcClientError, dieOnRpcClientError, dieOnRpcClientErrorStream } from '../rpc-worker.ts'
46
47
  import { makeShutdownChannel } from '../shutdown-channel.ts'
47
48
  import * as WorkerSchema from '../worker-schema.ts'
48
49
 
@@ -178,7 +179,7 @@ export const makeWorkerAdapter = ({
178
179
  * Extra arguments to pass to the worker which can be accessed in the worker
179
180
  * via `getWorkerArgs()`
180
181
  */
181
- workerExtraArgs?: Schema.JsonValue
182
+ workerExtraArgs?: Schema.Json
182
183
  }): Adapter => makeAdapterImpl({ ...options, leaderThread: { _tag: 'multi-threaded', workerUrl, workerExtraArgs } })
183
184
 
184
185
  const makeAdapterImpl = ({
@@ -199,7 +200,7 @@ const makeAdapterImpl = ({
199
200
  | {
200
201
  _tag: 'multi-threaded'
201
202
  workerUrl: URL
202
- workerExtraArgs: Schema.JsonValue | undefined
203
+ workerExtraArgs: Schema.Json | undefined
203
204
  }
204
205
  }): Adapter =>
205
206
  ((adapterArgs) =>
@@ -231,7 +232,7 @@ const makeAdapterImpl = ({
231
232
  }
232
233
 
233
234
  yield* shutdownChannel.listen.pipe(
234
- Stream.flatten(),
235
+ Stream.mapEffect(Effect.fromResult),
235
236
  Stream.tap((cause) =>
236
237
  shutdown(cause._tag === 'IntentionalShutdownCause' ? Exit.succeed(cause) : Exit.fail(cause)),
237
238
  ),
@@ -246,7 +247,7 @@ const makeAdapterImpl = ({
246
247
  // TODO actually implement this multi-session support
247
248
  const lockStatus = yield* SubscriptionRef.make<LockStatus>('has-lock')
248
249
 
249
- const devtoolsOptions: WorkerSchema.LeaderWorkerInnerInitialMessage['devtools'] =
250
+ const devtoolsOptions: WorkerSchema.LeaderWorkerInnerInitialMessagePayload['devtools'] =
250
251
  devtoolsEnabled === true && devtoolsOptionsInput !== undefined
251
252
  ? {
252
253
  enabled: true,
@@ -288,7 +289,7 @@ const makeAdapterImpl = ({
288
289
  devtools: devtoolsOptions,
289
290
  bootStatusQueue,
290
291
  syncPayloadEncoded,
291
- })
292
+ }).pipe(UnknownError.mapToUnknownError)
292
293
 
293
294
  syncInMemoryDb.import(initialSnapshot)
294
295
  syncInMemoryDb.debug.head = leaderThread.initialState.leaderHead
@@ -369,28 +370,42 @@ const makeLocalLeaderThread = ({
369
370
  makeSqliteDb: MakeSqliteDb
370
371
  syncOptions: SyncOptions | undefined
371
372
  storage: WorkerSchema.StorageType
372
- syncPayloadEncoded: Schema.JsonValue | undefined
373
+ syncPayloadEncoded: Schema.Json | undefined
373
374
  syncPayloadSchema: Schema.Schema<any>
374
- devtools: WorkerSchema.LeaderWorkerInnerInitialMessage['devtools']
375
+ devtools: WorkerSchema.LeaderWorkerInnerInitialMessagePayload['devtools']
375
376
  testing?: {
376
377
  overrides?: TestingOverrides
377
378
  }
378
379
  }) =>
379
380
  Effect.gen(function* () {
380
- const layer = yield* Layer.build(
381
- makeLeaderThread({
382
- storeId,
383
- clientId,
384
- schema,
385
- syncOptions,
386
- storage,
387
- syncPayloadEncoded,
388
- syncPayloadSchema,
389
- devtools,
390
- makeSqliteDb,
391
- ...omitUndefineds({ testing: testing?.overrides }),
392
- }).pipe(Layer.unwrapScoped),
393
- )
381
+ // The single-threaded node adapter runs the leader thread co-located in the same runtime as the
382
+ // client session. Unlike the worker adapters (where the leader lives on its own event loop and drains
383
+ // pushes independently), a co-located leader only makes progress when the surrounding fibers yield.
384
+ //
385
+ // We give the leader thread its own dedicated scope whose teardown finalizer is registered *before*
386
+ // `makeClientSession`. Finalizers run in reverse registration order, so on store shutdown the client
387
+ // session tears down first and only then is the leader thread scope closed — keeping the leader (and
388
+ // its SQLite connections) alive while the client-session pusher's in-flight batch is still being
389
+ // persisted. Building the leader into the ambient store scope instead (via `Layer.build`) interrupts
390
+ // the leader's push drainer concurrently with the client session, dropping the in-flight batch and
391
+ // causing silent data loss on commit-then-shutdown.
392
+ const leaderThreadScope = yield* Scope.make()
393
+ yield* Effect.addFinalizer((exit) => Scope.close(leaderThreadScope, exit))
394
+
395
+ const leaderThreadLayer = yield* makeLeaderThread({
396
+ storeId,
397
+ clientId,
398
+ schema,
399
+ syncOptions,
400
+ storage,
401
+ syncPayloadEncoded,
402
+ syncPayloadSchema,
403
+ devtools,
404
+ makeSqliteDb,
405
+ ...omitUndefineds({ testing: testing?.overrides }),
406
+ }).pipe(Scope.provide(leaderThreadScope))
407
+
408
+ const layer = yield* Layer.buildWithScope(leaderThreadLayer, leaderThreadScope)
394
409
 
395
410
  return yield* Effect.gen(function* () {
396
411
  const { dbState, dbEventlog, syncProcessor, extraIncomingMessagesQueue, initialState, networkStatus } =
@@ -403,10 +418,13 @@ const makeLocalLeaderThread = ({
403
418
  events: {
404
419
  pull: ({ cursor }) => syncProcessor.pull({ cursor }),
405
420
  push: (batch) =>
406
- syncProcessor.push(
407
- batch.map((item) => new LiveStoreEvent.Client.EncodedWithMeta(item)),
408
- { waitForProcessing: true },
409
- ),
421
+ // Mirror the worker `PushToLeader` handler which runs the leader push uninterruptibly.
422
+ // Together with the dedicated `leaderThreadScope` above, this guarantees an in-flight batch is
423
+ // fully persisted by the (still-live) leader before the co-located pusher fiber is interrupted
424
+ // on store shutdown.
425
+ syncProcessor
426
+ .push(batch.map((item) => new LiveStoreEvent.Client.EncodedWithMeta(item)))
427
+ .pipe(Effect.uninterruptible),
410
428
  stream: (options) =>
411
429
  streamEventsWithSyncState({
412
430
  dbEventlog,
@@ -422,7 +440,7 @@ const makeLocalLeaderThread = ({
422
440
  export: Effect.sync(() => dbState.export()),
423
441
  getEventlogData: Effect.sync(() => dbEventlog.export()),
424
442
  syncState: syncProcessor.syncState,
425
- sendDevtoolsMessage: (message) => extraIncomingMessagesQueue.offer(message),
443
+ sendDevtoolsMessage: (message) => Queue.offer(extraIncomingMessagesQueue, message),
426
444
  networkStatus,
427
445
  },
428
446
  { ...omitUndefineds({ overrides: testing?.overrides?.clientSession?.leaderThreadProxy }) },
@@ -452,11 +470,11 @@ const makeWorkerLeaderThread = ({
452
470
  clientId: string
453
471
  sessionId: string
454
472
  workerUrl: URL
455
- workerExtraArgs: Schema.JsonValue | undefined
473
+ workerExtraArgs: Schema.Json | undefined
456
474
  storage: WorkerSchema.StorageType
457
- devtools: WorkerSchema.LeaderWorkerInnerInitialMessage['devtools']
475
+ devtools: WorkerSchema.LeaderWorkerInnerInitialMessagePayload['devtools']
458
476
  bootStatusQueue: Queue.Queue<BootStatus>
459
- syncPayloadEncoded: Schema.JsonValue | undefined
477
+ syncPayloadEncoded: Schema.Json | undefined
460
478
  testing?: {
461
479
  overrides?: TestingOverrides
462
480
  }
@@ -464,66 +482,76 @@ const makeWorkerLeaderThread = ({
464
482
  Effect.gen(function* () {
465
483
  const nodeWorker = new WT.Worker(workerUrl, {
466
484
  execArgv: process.env.DEBUG_WORKER !== undefined ? ['--inspect --enable-source-maps'] : ['--enable-source-maps'],
467
- argv: [yield* Schema.encode(WorkerSchema.WorkerArgv)({ storeId, clientId, sessionId, extraArgs: workerExtraArgs }).pipe(Effect.orDie)],
468
- })
469
- const nodeWorkerLayer = yield* Layer.build(PlatformNode.NodeWorker.layer(() => nodeWorker))
470
-
471
- const worker = yield* Worker.makePoolSerialized<typeof WorkerSchema.LeaderWorkerInnerRequest.Type>({
472
- size: 1,
473
- concurrency: 100,
474
- initialMessage: () =>
475
- new WorkerSchema.LeaderWorkerInnerInitialMessage({
485
+ argv: [
486
+ yield* Schema.encodeEffect(WorkerSchema.WorkerArgv)({
476
487
  storeId,
477
488
  clientId,
478
- storage,
479
- devtools,
480
- syncPayloadEncoded,
481
- }),
482
- }).pipe(
483
- Effect.provide(nodeWorkerLayer),
489
+ sessionId,
490
+ extraArgs: workerExtraArgs,
491
+ }).pipe(Effect.orDie),
492
+ ],
493
+ })
494
+ const innerWorkerContext = yield* Layer.build(
495
+ RpcClient.layerProtocolWorker({ size: 1, concurrency: 100 }).pipe(
496
+ Layer.provide(PlatformNode.NodeWorker.layer(() => nodeWorker)),
497
+ Layer.provide(
498
+ RpcWorker.layerInitialMessage(
499
+ WorkerSchema.LeaderWorkerInnerInitialMessage.payloadSchema,
500
+ Effect.succeed({ storeId, clientId, storage, devtools, syncPayloadEncoded }),
501
+ ),
502
+ ),
503
+ ),
504
+ )
505
+
506
+ const worker = yield* RpcClient.make(WorkerSchema.LeaderWorkerInnerRpcs).pipe(
507
+ Effect.provide(innerWorkerContext),
484
508
  UnknownError.mapToUnknownError,
485
- Effect.tapErrorCause((cause) => shutdown(Exit.failCause(cause))),
509
+ Effect.tapCause((cause) => shutdown(Exit.failCause(cause))),
486
510
  Effect.withSpan('@livestore/adapter-node:adapter:setupLeaderThread'),
487
511
  )
488
512
 
489
- const runInWorker = <A, I, E, EI, R>(
490
- req: WorkerSchema.LeaderWorkerInnerRequest & Schema.WithResult<A, I, E, EI, R>,
491
- ): Effect.Effect<A, E, R> =>
492
- worker.executeEffect(req).pipe(
493
- Effect.catchIf(isWorkerTransportError, (e) => Effect.die(e)),
513
+ const runInWorker = <A, E, R>(
514
+ tag: string,
515
+ effect: Effect.Effect<A, E, R>,
516
+ ): Effect.Effect<A, WithoutRpcClientError<E>, R> =>
517
+ effect.pipe(
518
+ dieOnRpcClientError,
494
519
  Effect.logWarnIfTakesLongerThan({
495
- label: `@livestore/adapter-node:client-session:runInWorker:${req._tag}`,
520
+ label: `@livestore/adapter-node:client-session:runInWorker:${tag}`,
496
521
  duration: 2000,
497
522
  }),
498
- Effect.withSpan(`@livestore/adapter-node:client-session:runInWorker:${req._tag}`),
523
+ Effect.withSpan(`@livestore/adapter-node:client-session:runInWorker:${tag}`),
499
524
  )
500
525
 
501
- const runInWorkerStream = <A, I, E, EI, R>(
502
- req: WorkerSchema.LeaderWorkerInnerRequest & Schema.WithResult<A, I, E, EI, R>,
503
- ): Stream.Stream<A, E, R> =>
504
- worker.execute(req).pipe(
505
- Stream.refineOrDie((e) => isWorkerTransportError(e) === true ? Option.none() : Option.some(e)),
506
- Stream.withSpan(`@livestore/adapter-node:client-session:runInWorkerStream:${req._tag}`),
526
+ const runInWorkerStream = <A, E, R>(
527
+ tag: string,
528
+ stream: Stream.Stream<A, E, R>,
529
+ ): Stream.Stream<A, WithoutRpcClientError<E>, R> =>
530
+ stream.pipe(
531
+ dieOnRpcClientErrorStream,
532
+ Stream.withSpan(`@livestore/adapter-node:client-session:runInWorkerStream:${tag}`),
507
533
  )
508
534
 
509
- const bootStatusFiber = yield* runInWorkerStream(new WorkerSchema.LeaderWorkerInnerBootStatusStream()).pipe(
535
+ const bootStatusFiber = yield* runInWorkerStream('BootStatusStream', worker.BootStatusStream({})).pipe(
510
536
  Stream.tap((bootStatus) => Queue.offer(bootStatusQueue, bootStatus)),
511
537
  Stream.runDrain,
512
- Effect.tapErrorCause((cause) => (Cause.isInterruptedOnly(cause) === true ? Effect.void : shutdown(Exit.failCause(cause)))),
538
+ Effect.tapCause((cause) =>
539
+ Cause.hasInterruptsOnly(cause) === true ? Effect.void : shutdown(Exit.failCause(cause)),
540
+ ),
513
541
  Effect.interruptible,
514
542
  Effect.tapCauseLogPretty,
515
543
  Effect.forkScoped,
516
544
  )
517
545
 
518
- yield* Queue.awaitShutdown(bootStatusQueue).pipe(
546
+ yield* Queue.await(bootStatusQueue).pipe(
519
547
  Effect.andThen(Fiber.interrupt(bootStatusFiber)),
520
548
  Effect.tapCauseLogPretty,
521
549
  Effect.forkScoped,
522
550
  )
523
551
 
524
- const initialLeaderHead = yield* runInWorker(new WorkerSchema.LeaderWorkerInnerGetLeaderHead())
552
+ const initialLeaderHead = yield* runInWorker('GetLeaderHead', worker.GetLeaderHead({}))
525
553
 
526
- const bootResult = yield* runInWorker(new WorkerSchema.LeaderWorkerInnerGetRecreateSnapshot()).pipe(
554
+ const bootResult = yield* runInWorker('GetRecreateSnapshot', worker.GetRecreateSnapshot({})).pipe(
527
555
  Effect.timeoutOrDie(10_000),
528
556
  Effect.withSpan('@livestore/adapter-node:client-session:export'),
529
557
  )
@@ -531,16 +559,15 @@ const makeWorkerLeaderThread = ({
531
559
  const leaderThread = ClientSessionLeaderThreadProxy.of(
532
560
  {
533
561
  events: {
534
- pull: ({ cursor }) =>
535
- runInWorkerStream(new WorkerSchema.LeaderWorkerInnerPullStream({ cursor })).pipe(Stream.orDie),
562
+ pull: ({ cursor }) => runInWorkerStream('PullStream', worker.PullStream({ cursor })).pipe(Stream.orDie),
536
563
  push: (batch) =>
537
- runInWorker(new WorkerSchema.LeaderWorkerInnerPushToLeader({ batch })).pipe(
564
+ runInWorker('PushToLeader', worker.PushToLeader({ batch })).pipe(
538
565
  Effect.withSpan('@livestore/adapter-node:client-session:pushToLeader', {
539
566
  attributes: { batchSize: batch.length },
540
567
  }),
541
568
  ),
542
569
  stream: (options) =>
543
- runInWorkerStream(new WorkerSchema.LeaderWorkerInnerStreamEvents(options)).pipe(
570
+ runInWorkerStream('StreamEvents', worker.StreamEvents(options)).pipe(
544
571
  Stream.withSpan('@livestore/adapter-node:client-session:streamEvents'),
545
572
  Stream.orDie,
546
573
  ),
@@ -550,24 +577,24 @@ const makeWorkerLeaderThread = ({
550
577
  migrationsReport: bootResult.migrationsReport,
551
578
  storageMode: 'persisted',
552
579
  },
553
- export: runInWorker(new WorkerSchema.LeaderWorkerInnerExport()).pipe(
580
+ export: runInWorker('Export', worker.Export({})).pipe(
554
581
  Effect.timeoutOrDie(10_000),
555
582
  Effect.withSpan('@livestore/adapter-node:client-session:export'),
556
583
  ),
557
- getEventlogData: Effect.dieMessage('Not implemented'),
584
+ getEventlogData: Effect.die('Not implemented'),
558
585
  syncState: Subscribable.make({
559
- get: runInWorker(new WorkerSchema.LeaderWorkerInnerGetLeaderSyncState()).pipe(
586
+ get: runInWorker('GetLeaderSyncState', worker.GetLeaderSyncState({})).pipe(
560
587
  Effect.withSpan('@livestore/adapter-node:client-session:getLeaderSyncState'),
561
588
  ),
562
- changes: runInWorkerStream(new WorkerSchema.LeaderWorkerInnerSyncStateStream()).pipe(Stream.orDie),
589
+ changes: runInWorkerStream('SyncStateStream', worker.SyncStateStream({})).pipe(Stream.orDie),
563
590
  }),
564
591
  sendDevtoolsMessage: (message) =>
565
- runInWorker(new WorkerSchema.LeaderWorkerInnerExtraDevtoolsMessage({ message })).pipe(
592
+ runInWorker('ExtraDevtoolsMessage', worker.ExtraDevtoolsMessage({ message })).pipe(
566
593
  Effect.withSpan('@livestore/adapter-node:client-session:devtoolsMessageForLeader'),
567
594
  ),
568
595
  networkStatus: Subscribable.make({
569
- get: runInWorker(new WorkerSchema.LeaderWorkerInnerGetNetworkStatus()).pipe(Effect.orDie),
570
- changes: runInWorkerStream(new WorkerSchema.LeaderWorkerInnerNetworkStatusStream()).pipe(Stream.orDie),
596
+ get: runInWorker('GetNetworkStatus', worker.GetNetworkStatus({})).pipe(Effect.orDie),
597
+ changes: runInWorkerStream('NetworkStatusStream', worker.NetworkStatusStream({})).pipe(Stream.orDie),
571
598
  }),
572
599
  },
573
600
  {
@@ -2,7 +2,7 @@ import http from 'node:http'
2
2
  import path from 'node:path'
3
3
 
4
4
  import type { Devtools } from '@livestore/common'
5
- import { isReadonlyArray, LS_DEV } from '@livestore/utils'
5
+ import { LS_DEV } from '@livestore/utils'
6
6
  import type { HttpClient } from '@livestore/utils/effect'
7
7
  import {
8
8
  Deferred,
@@ -47,25 +47,28 @@ export const startDevtoolsServer = ({
47
47
  port: number
48
48
  }): Effect.Effect<never, never, HttpClient.HttpClient> =>
49
49
  Effect.gen(function* () {
50
- const viteMiddleware = yield* makeViteMiddleware({
51
- mode: { _tag: 'node', url: `http://${host}:${port}` },
52
- schemaPath:
53
- isReadonlyArray(schemaPath) === true
54
- ? schemaPath.map((schemaPath) => path.resolve(process.cwd(), schemaPath))
55
- : path.resolve(process.cwd(), schemaPath),
56
- viteConfig: (viteConfig) => {
57
- if (LS_DEV === true) {
58
- viteConfig.server ??= {}
59
- viteConfig.server.fs ??= {}
60
- viteConfig.server.fs.strict = false
61
-
62
- viteConfig.optimizeDeps ??= {}
63
- viteConfig.optimizeDeps.force = true
64
- }
65
-
66
- return viteConfig
67
- },
68
- }).pipe(Effect.acquireRelease((viteMiddleware) => Effect.promise(() => viteMiddleware.close())))
50
+ const viteMiddleware = yield* Effect.acquireRelease(
51
+ makeViteMiddleware({
52
+ mode: { _tag: 'node', url: `http://${host}:${port}` },
53
+ schemaPath:
54
+ typeof schemaPath === 'string'
55
+ ? path.resolve(process.cwd(), schemaPath)
56
+ : schemaPath.map((schemaPath) => path.resolve(process.cwd(), schemaPath)),
57
+ viteConfig: (viteConfig) => {
58
+ if (LS_DEV === true) {
59
+ viteConfig.server ??= {}
60
+ viteConfig.server.fs ??= {}
61
+ viteConfig.server.fs.strict = false
62
+
63
+ viteConfig.optimizeDeps ??= {}
64
+ viteConfig.optimizeDeps.force = true
65
+ }
66
+
67
+ return viteConfig
68
+ },
69
+ }),
70
+ (viteMiddleware) => Effect.promise(() => viteMiddleware.close()),
71
+ )
69
72
 
70
73
  const relayNodeName = 'ws'
71
74
 
@@ -88,9 +91,10 @@ export const startDevtoolsServer = ({
88
91
  // To handle websocket closing, we need to race the `webChannel.closedDeferred` to properly interrupt the handler
89
92
  yield* Effect.raceFirst(
90
93
  Effect.gen(function* () {
91
- yield* node
92
- .addEdge({ target: from, edgeChannel: webChannel, replaceIfExists: true })
93
- .pipe(Effect.acquireRelease(() => node.removeEdge(from).pipe(Effect.orDie)))
94
+ yield* Effect.acquireRelease(
95
+ node.addEdge({ target: from, edgeChannel: webChannel, replaceIfExists: true }),
96
+ () => node.removeEdge(from).pipe(Effect.orDie),
97
+ )
94
98
 
95
99
  if (LS_DEV === true) {
96
100
  yield* Effect.log(`WS Relay ${relayNodeName}: added edge from '${from}'`)
@@ -100,7 +104,7 @@ export const startDevtoolsServer = ({
100
104
  // We want to keep the websocket open until the client disconnects or the server shuts down
101
105
  return yield* Effect.never
102
106
  }),
103
- webChannel.closedDeferred,
107
+ Deferred.await(webChannel.closedDeferred),
104
108
  )
105
109
 
106
110
  return HttpServerResponse.empty({ status: 101 })
@@ -113,9 +117,9 @@ export const startDevtoolsServer = ({
113
117
  // TODO replace this once @effect/platform-node supports Node HTTP middlewares
114
118
  const nodeReq = PlatformNode.NodeHttpServerRequest.toIncomingMessage(req)
115
119
  const nodeRes = PlatformNode.NodeHttpServerRequest.toServerResponse(req)
116
- const deferred = yield* Deferred.make()
117
- viteMiddleware.middlewares(nodeReq, nodeRes, () => Deferred.unsafeDone(deferred, Exit.void))
118
- yield* deferred
120
+ const deferred = yield* Deferred.make<void>()
121
+ viteMiddleware.middlewares(nodeReq, nodeRes, () => Deferred.doneUnsafe(deferred, Exit.void))
122
+ yield* Deferred.await(deferred)
119
123
 
120
124
  // The response is already sent, so we need to return an empty response (which won't be sent)
121
125
  return HttpServerResponse.empty()
@@ -123,7 +127,7 @@ export const startDevtoolsServer = ({
123
127
  }
124
128
 
125
129
  return HttpServerResponse.text('Not found')
126
- }).pipe(Effect.tapCauseLogPretty, Effect.interruptible)
130
+ }).pipe(Effect.tapCauseLogPretty, Effect.interruptible, HttpMiddleware.withLoggerDisabled)
127
131
 
128
132
  const sessionSuffix =
129
133
  clientSessionInfo !== undefined
@@ -141,8 +145,7 @@ export const startDevtoolsServer = ({
141
145
  Effect.withSpan('@livestore/adapter-node:startDevtoolsServer', {
142
146
  attributes: { clientSessionInfo, port, host, schemaPath },
143
147
  }),
144
- HttpMiddleware.withLoggerDisabled,
145
- Layer.unwrapScoped,
148
+ Layer.unwrap,
146
149
  Layer.provide(PlatformNode.NodeHttpServer.layer(() => http.createServer(), { port, host })),
147
150
  Layer.launch,
148
151
  Effect.orDie,
@@ -1,12 +1,9 @@
1
1
  import path from 'node:path'
2
-
3
- import type * as Vite from 'vite'
4
-
5
2
  import type { Devtools } from '@livestore/common'
6
3
  import { UnknownError } from '@livestore/common'
7
- import { isReadonlyArray } from '@livestore/utils'
8
4
  import { Effect, Schema } from '@livestore/utils/effect'
9
5
  import { getFreePort } from '@livestore/utils/node'
6
+ import type * as Vite from 'vite'
10
7
 
11
8
  /**
12
9
  * Error thrown when @livestore/devtools-vite is not installed.
@@ -15,7 +12,7 @@ import { getFreePort } from '@livestore/utils/node'
15
12
  export class DevtoolsViteNotInstalledError extends Schema.TaggedError<DevtoolsViteNotInstalledError>(
16
13
  '~@livestore/adapter-node/DevtoolsViteNotInstalledError',
17
14
  )('DevtoolsViteNotInstalledError', {
18
- cause: Schema.Defect,
15
+ cause: Schema.Defect(),
19
16
  }) {
20
17
  override get message(): string {
21
18
  return (
@@ -30,7 +27,7 @@ export class DevtoolsViteNotInstalledError extends Schema.TaggedError<DevtoolsVi
30
27
  export class ViteNotInstalledError extends Schema.TaggedError<ViteNotInstalledError>(
31
28
  '~@livestore/adapter-node/ViteNotInstalledError',
32
29
  )('ViteNotInstalledError', {
33
- cause: Schema.Defect,
30
+ cause: Schema.Defect(),
34
31
  }) {
35
32
  override get message(): string {
36
33
  return (
@@ -82,9 +79,10 @@ export const makeViteMiddleware = (
82
79
  base: '/_livestore/',
83
80
  plugins: [
84
81
  livestoreDevtoolsPlugin({
85
- schemaPath: isReadonlyArray(options.schemaPath) === true
86
- ? options.schemaPath.map((schemaPath) => path.resolve(cwd, schemaPath))
87
- : path.resolve(cwd, options.schemaPath),
82
+ schemaPath:
83
+ typeof options.schemaPath === 'string'
84
+ ? path.resolve(cwd, options.schemaPath)
85
+ : options.schemaPath.map((schemaPath) => path.resolve(cwd, schemaPath)),
88
86
  mode: options.mode,
89
87
  path: '/',
90
88
  }),
@@ -7,13 +7,14 @@ if (process.execArgv.includes('--inspect') === true) {
7
7
  }
8
8
 
9
9
  import type { ClientSessionLeaderThreadProxy, MakeSqliteDb, SqliteDb, SyncOptions } from '@livestore/common'
10
- import { Devtools, liveStoreStorageFormatVersion, migrateDb, UnknownError } from '@livestore/common'
10
+ import { Devtools, liveStoreStorageFormatVersion, migrateDb, StateHead, UnknownError } from '@livestore/common'
11
11
  import type { DevtoolsOptions, LeaderSqliteDb, LeaderThreadCtx } from '@livestore/common/leader-thread'
12
12
  import { configureConnection, makeLeaderThreadLayer } from '@livestore/common/leader-thread'
13
13
  import type { LiveStoreSchema } from '@livestore/common/schema'
14
+ import { getStateDbBaseName } from '@livestore/common/schema'
14
15
  import type { MakeNodeSqliteDb } from '@livestore/sqlite-wasm/node'
15
- import type { FileSystem, HttpClient, Layer, Schema, Scope } from '@livestore/utils/effect'
16
- import { Effect } from '@livestore/utils/effect'
16
+ import type { FileSystem, HttpClient, Schema, Scope } from '@livestore/utils/effect'
17
+ import { Effect, Layer } from '@livestore/utils/effect'
17
18
  import * as Webmesh from '@livestore/webmesh'
18
19
 
19
20
  import { makeShutdownChannel } from './shutdown-channel.ts'
@@ -40,9 +41,9 @@ export interface MakeLeaderThreadArgs {
40
41
  syncOptions: SyncOptions | undefined
41
42
  storage: WorkerSchema.StorageType
42
43
  makeSqliteDb: MakeNodeSqliteDb
43
- devtools: WorkerSchema.LeaderWorkerInnerInitialMessage['devtools']
44
+ devtools: WorkerSchema.LeaderWorkerInnerInitialMessagePayload['devtools']
44
45
  schema: LiveStoreSchema
45
- syncPayloadEncoded: Schema.JsonValue | undefined
46
+ syncPayloadEncoded: Schema.Json | undefined
46
47
  syncPayloadSchema: Schema.Schema<any> | undefined
47
48
  testing: TestingOverrides | undefined
48
49
  }
@@ -64,10 +65,7 @@ export const makeLeaderThread = ({
64
65
  Scope.Scope
65
66
  > =>
66
67
  Effect.gen(function* () {
67
- const runtime = yield* Effect.runtime()
68
-
69
- const schemaHashSuffix =
70
- schema.state.sqlite.migrations.strategy === 'manual' ? 'fixed' : schema.state.sqlite.hash.toString()
68
+ const services = yield* Effect.context()
71
69
 
72
70
  const makeDb = (kind: 'state' | 'eventlog') => {
73
71
  if (testing?.makeLeaderThread !== undefined) {
@@ -79,18 +77,21 @@ export const makeLeaderThread = ({
79
77
  return storage.type === 'in-memory'
80
78
  ? makeSqliteDb({
81
79
  _tag: 'in-memory',
82
- configureDb: (db) =>
83
- configureConnection(db, { foreignKeys: true }).pipe(Effect.provide(runtime), Effect.runSync),
80
+ configureDb: (db) => configureConnection(db, { foreignKeys: true }).pipe(Effect.runSyncWith(services)),
84
81
  })
85
- : makeSqliteDb({
86
- _tag: 'fs',
87
- directory: path.join(storage.baseDirectory ?? '', storeId),
88
- fileName:
89
- kind === 'state' ? getStateDbFileName(schemaHashSuffix) : `eventlog@${liveStoreStorageFormatVersion}.db`,
90
- // TODO enable WAL for nodejs
91
- configureDb: (db) =>
92
- configureConnection(db, { foreignKeys: true }).pipe(Effect.provide(runtime), Effect.runSync),
93
- }).pipe(Effect.acquireRelease((db) => Effect.sync(() => db.close())))
82
+ : Effect.acquireRelease(
83
+ makeSqliteDb({
84
+ _tag: 'fs',
85
+ directory: path.join(storage.baseDirectory ?? '', storeId),
86
+ fileName:
87
+ kind === 'state'
88
+ ? `${getStateDbBaseName(schema)}@${liveStoreStorageFormatVersion}.db`
89
+ : `eventlog@${liveStoreStorageFormatVersion}.db`,
90
+ // TODO enable WAL for nodejs
91
+ configureDb: (db) => configureConnection(db, { foreignKeys: true }).pipe(Effect.runSyncWith(services)),
92
+ }),
93
+ (db) => Effect.sync(() => db.close()),
94
+ )
94
95
  }
95
96
 
96
97
  // Might involve some async work, so we're running them concurrently
@@ -116,8 +117,8 @@ export const makeLeaderThread = ({
116
117
  devtoolsOptions,
117
118
  shutdownChannel,
118
119
  syncPayloadEncoded,
119
- syncPayloadSchema,
120
- })
120
+ syncPayloadSchema: syncPayloadSchema as Schema.Decoder<Schema.Json, never> | undefined,
121
+ }).pipe(Layer.provide(StateHead.layer({ dbState })))
121
122
  }).pipe(
122
123
  Effect.tapCauseLogPretty,
123
124
  UnknownError.mapToUnknownError,
@@ -126,8 +127,6 @@ export const makeLeaderThread = ({
126
127
  }),
127
128
  )
128
129
 
129
- const getStateDbFileName = (suffix: string) => `state${suffix}@${liveStoreStorageFormatVersion}.db`
130
-
131
130
  const makeDevtoolsOptions = ({
132
131
  dbState,
133
132
  dbEventlog,
@@ -139,7 +138,7 @@ const makeDevtoolsOptions = ({
139
138
  dbEventlog: LeaderSqliteDb
140
139
  storeId: string
141
140
  clientId: string
142
- devtools: WorkerSchema.LeaderWorkerInnerInitialMessage['devtools']
141
+ devtools: WorkerSchema.LeaderWorkerInnerInitialMessagePayload['devtools']
143
142
  }): Effect.Effect<DevtoolsOptions, UnknownError, Scope.Scope> =>
144
143
  Effect.gen(function* () {
145
144
  if (devtools.enabled === false) {