@livestore/utils 0.4.0-dev.2 → 0.4.0-dev.20

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 (184) hide show
  1. package/dist/.tsbuildinfo.json +1 -1
  2. package/dist/NoopTracer.d.ts.map +1 -1
  3. package/dist/NoopTracer.js +1 -0
  4. package/dist/NoopTracer.js.map +1 -1
  5. package/dist/browser/Opfs/Opfs.d.ts +51 -0
  6. package/dist/browser/Opfs/Opfs.d.ts.map +1 -0
  7. package/dist/browser/Opfs/Opfs.js +345 -0
  8. package/dist/browser/Opfs/Opfs.js.map +1 -0
  9. package/dist/browser/Opfs/debug-utils.d.ts +20 -0
  10. package/dist/browser/Opfs/debug-utils.d.ts.map +1 -0
  11. package/dist/browser/Opfs/debug-utils.js +94 -0
  12. package/dist/browser/Opfs/debug-utils.js.map +1 -0
  13. package/dist/browser/Opfs/mod.d.ts +4 -0
  14. package/dist/browser/Opfs/mod.d.ts.map +1 -0
  15. package/dist/browser/Opfs/mod.js +4 -0
  16. package/dist/browser/Opfs/mod.js.map +1 -0
  17. package/dist/browser/Opfs/utils.d.ts +68 -0
  18. package/dist/browser/Opfs/utils.d.ts.map +1 -0
  19. package/dist/browser/Opfs/utils.js +206 -0
  20. package/dist/browser/Opfs/utils.js.map +1 -0
  21. package/dist/browser/QuotaExceededError.d.ts +59 -0
  22. package/dist/browser/QuotaExceededError.d.ts.map +1 -0
  23. package/dist/browser/QuotaExceededError.js +2 -0
  24. package/dist/browser/QuotaExceededError.js.map +1 -0
  25. package/dist/browser/WebChannelBrowser.d.ts +22 -0
  26. package/dist/browser/WebChannelBrowser.d.ts.map +1 -0
  27. package/dist/browser/WebChannelBrowser.js +76 -0
  28. package/dist/browser/WebChannelBrowser.js.map +1 -0
  29. package/dist/browser/WebError.d.ts +425 -0
  30. package/dist/browser/WebError.d.ts.map +1 -0
  31. package/dist/browser/WebError.js +414 -0
  32. package/dist/browser/WebError.js.map +1 -0
  33. package/dist/browser/WebError.test.d.ts +2 -0
  34. package/dist/browser/WebError.test.d.ts.map +1 -0
  35. package/dist/browser/WebError.test.js +46 -0
  36. package/dist/browser/WebError.test.js.map +1 -0
  37. package/dist/browser/WebLock.d.ts.map +1 -0
  38. package/dist/browser/WebLock.js.map +1 -0
  39. package/dist/{browser.d.ts → browser/detect.d.ts} +1 -1
  40. package/dist/browser/detect.d.ts.map +1 -0
  41. package/dist/{browser.js → browser/detect.js} +1 -1
  42. package/dist/browser/detect.js.map +1 -0
  43. package/dist/browser/mod.d.ts +8 -0
  44. package/dist/browser/mod.d.ts.map +1 -0
  45. package/dist/browser/mod.js +8 -0
  46. package/dist/browser/mod.js.map +1 -0
  47. package/dist/effect/Debug.d.ts +38 -0
  48. package/dist/effect/Debug.d.ts.map +1 -0
  49. package/dist/effect/Debug.js +287 -0
  50. package/dist/effect/Debug.js.map +1 -0
  51. package/dist/effect/Effect.d.ts +9 -3
  52. package/dist/effect/Effect.d.ts.map +1 -1
  53. package/dist/effect/Effect.js +4 -2
  54. package/dist/effect/Effect.js.map +1 -1
  55. package/dist/effect/Error.d.ts +1 -1
  56. package/dist/effect/Error.js.map +1 -1
  57. package/dist/effect/Logger.d.ts +4 -1
  58. package/dist/effect/Logger.d.ts.map +1 -1
  59. package/dist/effect/Logger.js +12 -3
  60. package/dist/effect/Logger.js.map +1 -1
  61. package/dist/effect/OtelTracer.d.ts +5 -0
  62. package/dist/effect/OtelTracer.d.ts.map +1 -0
  63. package/dist/effect/OtelTracer.js +8 -0
  64. package/dist/effect/OtelTracer.js.map +1 -0
  65. package/dist/effect/RpcClient.d.ts +32 -0
  66. package/dist/effect/RpcClient.d.ts.map +1 -0
  67. package/dist/effect/RpcClient.js +149 -0
  68. package/dist/effect/RpcClient.js.map +1 -0
  69. package/dist/effect/Schema/index.d.ts +2 -2
  70. package/dist/effect/Schema/index.d.ts.map +1 -1
  71. package/dist/effect/Schema/index.js +12 -2
  72. package/dist/effect/Schema/index.js.map +1 -1
  73. package/dist/effect/Stream.d.ts +73 -2
  74. package/dist/effect/Stream.d.ts.map +1 -1
  75. package/dist/effect/Stream.js +68 -1
  76. package/dist/effect/Stream.js.map +1 -1
  77. package/dist/effect/Stream.test.d.ts +2 -0
  78. package/dist/effect/Stream.test.d.ts.map +1 -0
  79. package/dist/effect/Stream.test.js +84 -0
  80. package/dist/effect/Stream.test.js.map +1 -0
  81. package/dist/effect/SubscriptionRef.d.ts +2 -2
  82. package/dist/effect/SubscriptionRef.d.ts.map +1 -1
  83. package/dist/effect/SubscriptionRef.js +6 -1
  84. package/dist/effect/SubscriptionRef.js.map +1 -1
  85. package/dist/effect/WebChannel/WebChannel.d.ts +2 -21
  86. package/dist/effect/WebChannel/WebChannel.d.ts.map +1 -1
  87. package/dist/effect/WebChannel/WebChannel.js +5 -81
  88. package/dist/effect/WebChannel/WebChannel.js.map +1 -1
  89. package/dist/effect/WebChannel/WebChannel.test.js +1 -1
  90. package/dist/effect/WebChannel/WebChannel.test.js.map +1 -1
  91. package/dist/effect/WebChannel/common.d.ts +1 -1
  92. package/dist/effect/WebChannel/common.d.ts.map +1 -1
  93. package/dist/effect/WebSocket.d.ts.map +1 -1
  94. package/dist/effect/WebSocket.js +12 -12
  95. package/dist/effect/WebSocket.js.map +1 -1
  96. package/dist/effect/mod.d.ts +32 -0
  97. package/dist/effect/mod.d.ts.map +1 -0
  98. package/dist/effect/mod.js +35 -0
  99. package/dist/effect/mod.js.map +1 -0
  100. package/dist/global.d.ts +1 -0
  101. package/dist/global.d.ts.map +1 -1
  102. package/dist/global.js.map +1 -1
  103. package/dist/misc.js +1 -1
  104. package/dist/misc.js.map +1 -1
  105. package/dist/mod.d.ts +3 -1
  106. package/dist/mod.d.ts.map +1 -1
  107. package/dist/mod.js +5 -1
  108. package/dist/mod.js.map +1 -1
  109. package/dist/node/ChildProcessRunner/ChildProcessRunner.d.ts.map +1 -1
  110. package/dist/node/ChildProcessRunner/ChildProcessRunner.js +66 -10
  111. package/dist/node/ChildProcessRunner/ChildProcessRunner.js.map +1 -1
  112. package/dist/node/ChildProcessRunner/ChildProcessRunnerTest/ChildProcessRunner.test.js +177 -3
  113. package/dist/node/ChildProcessRunner/ChildProcessRunnerTest/ChildProcessRunner.test.js.map +1 -1
  114. package/dist/node/ChildProcessRunner/ChildProcessRunnerTest/schema.d.ts +14 -5
  115. package/dist/node/ChildProcessRunner/ChildProcessRunnerTest/schema.d.ts.map +1 -1
  116. package/dist/node/ChildProcessRunner/ChildProcessRunnerTest/schema.js +7 -1
  117. package/dist/node/ChildProcessRunner/ChildProcessRunnerTest/schema.js.map +1 -1
  118. package/dist/node/ChildProcessRunner/ChildProcessRunnerTest/serializedWorker.js +13 -3
  119. package/dist/node/ChildProcessRunner/ChildProcessRunnerTest/serializedWorker.js.map +1 -1
  120. package/dist/node/ChildProcessRunner/ChildProcessWorker.d.ts +16 -0
  121. package/dist/node/ChildProcessRunner/ChildProcessWorker.d.ts.map +1 -1
  122. package/dist/node/ChildProcessRunner/ChildProcessWorker.js +98 -2
  123. package/dist/node/ChildProcessRunner/ChildProcessWorker.js.map +1 -1
  124. package/dist/node/mod.d.ts +8 -2
  125. package/dist/node/mod.d.ts.map +1 -1
  126. package/dist/node/mod.js +11 -3
  127. package/dist/node/mod.js.map +1 -1
  128. package/dist/qr.d.ts +38 -0
  129. package/dist/qr.d.ts.map +1 -0
  130. package/dist/qr.js +109 -0
  131. package/dist/qr.js.map +1 -0
  132. package/package.json +54 -44
  133. package/src/NoopTracer.ts +1 -0
  134. package/src/browser/Opfs/Opfs.ts +428 -0
  135. package/src/browser/Opfs/debug-utils.ts +151 -0
  136. package/src/browser/Opfs/mod.ts +3 -0
  137. package/src/browser/Opfs/utils.ts +270 -0
  138. package/src/browser/QuotaExceededError.ts +59 -0
  139. package/src/browser/WebChannelBrowser.ts +131 -0
  140. package/src/browser/WebError.test.ts +66 -0
  141. package/src/browser/WebError.ts +599 -0
  142. package/src/browser/mod.ts +8 -0
  143. package/src/effect/Debug.ts +375 -0
  144. package/src/effect/Effect.ts +31 -4
  145. package/src/effect/Error.ts +1 -1
  146. package/src/effect/Logger.ts +14 -4
  147. package/src/effect/OtelTracer.ts +11 -0
  148. package/src/effect/RpcClient.ts +212 -0
  149. package/src/effect/Schema/index.ts +17 -3
  150. package/src/effect/Stream.test.ts +127 -0
  151. package/src/effect/Stream.ts +111 -2
  152. package/src/effect/SubscriptionRef.ts +14 -2
  153. package/src/effect/WebChannel/WebChannel.test.ts +1 -1
  154. package/src/effect/WebChannel/WebChannel.ts +13 -135
  155. package/src/effect/WebChannel/common.ts +1 -1
  156. package/src/effect/WebSocket.ts +11 -10
  157. package/src/effect/{index.ts → mod.ts} +42 -15
  158. package/src/global.ts +1 -0
  159. package/src/misc.ts +1 -1
  160. package/src/mod.ts +10 -1
  161. package/src/node/ChildProcessRunner/ChildProcessRunner.ts +71 -10
  162. package/src/node/ChildProcessRunner/ChildProcessRunnerTest/ChildProcessRunner.test.ts +258 -3
  163. package/src/node/ChildProcessRunner/ChildProcessRunnerTest/schema.ts +14 -1
  164. package/src/node/ChildProcessRunner/ChildProcessRunnerTest/serializedWorker.ts +16 -3
  165. package/src/node/ChildProcessRunner/ChildProcessWorker.ts +111 -3
  166. package/src/node/mod.ts +13 -6
  167. package/src/qr.ts +125 -0
  168. package/dist/browser.d.ts.map +0 -1
  169. package/dist/browser.js.map +0 -1
  170. package/dist/effect/Schema/msgpack.d.ts +0 -3
  171. package/dist/effect/Schema/msgpack.d.ts.map +0 -1
  172. package/dist/effect/Schema/msgpack.js +0 -7
  173. package/dist/effect/Schema/msgpack.js.map +0 -1
  174. package/dist/effect/WebLock.d.ts.map +0 -1
  175. package/dist/effect/WebLock.js.map +0 -1
  176. package/dist/effect/index.d.ts +0 -27
  177. package/dist/effect/index.d.ts.map +0 -1
  178. package/dist/effect/index.js +0 -31
  179. package/dist/effect/index.js.map +0 -1
  180. package/src/effect/Schema/msgpack.ts +0 -8
  181. /package/dist/{effect → browser}/WebLock.d.ts +0 -0
  182. /package/dist/{effect → browser}/WebLock.js +0 -0
  183. /package/src/{effect → browser}/WebLock.ts +0 -0
  184. /package/src/{browser.ts → browser/detect.ts} +0 -0
@@ -0,0 +1,212 @@
1
+ export * from '@effect/rpc/RpcClient'
2
+
3
+ import { Socket } from '@effect/platform'
4
+ import { RpcClient, RpcClientError, RpcSerialization } from '@effect/rpc'
5
+ import { Protocol } from '@effect/rpc/RpcClient'
6
+ import { constPing, type FromServerEncoded } from '@effect/rpc/RpcMessage'
7
+ import { Cause, Deferred, Effect, Layer, Option, Schedule, type Scope } from 'effect'
8
+ import { constVoid, identity } from 'effect/Function'
9
+ import * as SubscriptionRef from './SubscriptionRef.ts'
10
+
11
+ // This is based on `makeProtocolSocket` / `layerProtocolSocket` from `@effect/rpc` in order to:
12
+ // - Add a `isConnected` subscription ref to track the connection state
13
+ // - Add a ping schedule to the socket
14
+ // - Add a retry schedule to the socket
15
+
16
+ export const layerProtocolSocketWithIsConnected = (options: {
17
+ readonly url: string
18
+ readonly retryTransientErrors?: Schedule.Schedule<unknown> | undefined
19
+ readonly isConnected: SubscriptionRef.SubscriptionRef<boolean>
20
+ readonly pingSchedule?: Schedule.Schedule<unknown> | undefined
21
+ }): Layer.Layer<Protocol, never, RpcSerialization.RpcSerialization | Socket.Socket> =>
22
+ Layer.scoped(Protocol, makeProtocolSocketWithIsConnected(options))
23
+
24
+ export const makeProtocolSocketWithIsConnected = (options: {
25
+ readonly url: string
26
+ readonly retryTransientErrors?: Schedule.Schedule<unknown> | undefined
27
+ // CHANGED: add isConnected subscription ref
28
+ readonly isConnected: SubscriptionRef.SubscriptionRef<boolean>
29
+ // CHANGED: add ping schedule
30
+ readonly pingSchedule?: Schedule.Schedule<unknown> | undefined
31
+ }): Effect.Effect<Protocol['Type'], never, Scope.Scope | RpcSerialization.RpcSerialization | Socket.Socket> =>
32
+ Protocol.make(
33
+ Effect.fnUntraced(function* (writeResponse) {
34
+ const socket = yield* Socket.Socket
35
+ const serialization = yield* RpcSerialization.RpcSerialization
36
+
37
+ const write = yield* socket.writer
38
+ const parser = serialization.unsafeMake()
39
+
40
+ const pinger = yield* makePinger(write(parser.encode(constPing)!), options?.pingSchedule)
41
+
42
+ yield* Effect.suspend(() => {
43
+ // We rely on the heartbeat watchdog while streaming arbitrarily long payloads.
44
+ // Reset the timer as soon as _any_ frame arrives so that large batches which
45
+ // don't contain explicit `Pong` messages don't trigger the open-timeout defect.
46
+ // (The actual pong handler still calls `onPong()` to resolve manual pings.)
47
+ // CHANGED: don't reset parser on every message
48
+ // parser = serialization.unsafeMake()
49
+ pinger.reset()
50
+ return socket
51
+ .runRaw((message) => {
52
+ try {
53
+ const responses = parser.decode(message) as Array<FromServerEncoded>
54
+ if (responses.length === 0) return
55
+ let i = 0
56
+ return Effect.whileLoop({
57
+ while: () => i < responses.length,
58
+ body: () => {
59
+ const response = responses[i++]!
60
+ // Keep extending the watchdog for each data frame to avoid
61
+ // disconnecting mid-stream when the server is busy sending batches.
62
+ pinger.reset()
63
+ if (response._tag === 'Pong') {
64
+ pinger.onPong()
65
+ }
66
+ return writeResponse(response).pipe(
67
+ // CHANGED: set isConnected to true on pong
68
+ Effect.tap(
69
+ Effect.fn(function* () {
70
+ if (options?.isConnected !== undefined) {
71
+ yield* SubscriptionRef.set(options.isConnected, true)
72
+ }
73
+ }),
74
+ ),
75
+ )
76
+ },
77
+ step: constVoid,
78
+ })
79
+ } catch (defect) {
80
+ return writeResponse({
81
+ _tag: 'ClientProtocolError',
82
+ error: new RpcClientError.RpcClientError({
83
+ reason: 'Protocol',
84
+ message: 'Error decoding message',
85
+ cause: Cause.fail(defect),
86
+ }),
87
+ })
88
+ }
89
+ })
90
+ .pipe(
91
+ Effect.raceFirst(
92
+ Effect.zipRight(
93
+ pinger.timeout,
94
+ Effect.fail(
95
+ new Socket.SocketGenericError({
96
+ reason: 'OpenTimeout',
97
+ cause: new Error('ping timeout'),
98
+ }),
99
+ ),
100
+ ),
101
+ ),
102
+ )
103
+ }).pipe(
104
+ Effect.zipRight(
105
+ Effect.fail(
106
+ new Socket.SocketCloseError({
107
+ reason: 'Close',
108
+ code: 1000,
109
+ closeReason: 'Closing connection',
110
+ }),
111
+ ),
112
+ ),
113
+ Effect.tapErrorCause(
114
+ Effect.fn(function* (cause) {
115
+ // CHANGED: set isConnected to false on error
116
+ if (options?.isConnected !== undefined) {
117
+ yield* SubscriptionRef.set(options.isConnected, false)
118
+ }
119
+
120
+ const error = Cause.failureOption(cause)
121
+ if (
122
+ options?.retryTransientErrors &&
123
+ Option.isSome(error) &&
124
+ (error.value.reason === 'Open' || error.value.reason === 'OpenTimeout')
125
+ ) {
126
+ return
127
+ }
128
+ // yield* Effect.logError('Error in socket', cause)
129
+ return yield* writeResponse({
130
+ _tag: 'ClientProtocolError',
131
+ error: new RpcClientError.RpcClientError({
132
+ reason: 'Protocol',
133
+ message: 'Error in socket',
134
+ cause: Cause.squash(cause),
135
+ }),
136
+ })
137
+ }),
138
+ ),
139
+ // CHANGED: make configurable via schedule
140
+ options?.retryTransientErrors ? Effect.retry(options.retryTransientErrors) : identity,
141
+ Effect.annotateLogs({
142
+ module: 'RpcClient',
143
+ method: 'makeProtocolSocket',
144
+ }),
145
+ Effect.interruptible,
146
+ Effect.ignore, // Errors are already handled
147
+ Effect.provide(Layer.setUnhandledErrorLogLevel(Option.none())),
148
+ Effect.forkScoped,
149
+ )
150
+
151
+ return {
152
+ send: (request) => {
153
+ const encoded = parser.encode(request)
154
+ if (encoded === undefined) return Effect.void
155
+
156
+ return Effect.orDie(write(encoded))
157
+ },
158
+ supportsAck: true,
159
+ supportsTransferables: false,
160
+ pinger,
161
+ }
162
+ }),
163
+ )
164
+
165
+ export const SocketPinger = Effect.map(RpcClient.Protocol, (protocol) => (protocol as any).pinger as SocketPinger)
166
+
167
+ export type SocketPinger = Effect.Effect.Success<ReturnType<typeof makePinger>>
168
+
169
+ const makePinger = Effect.fnUntraced(function* <A, E, R>(
170
+ writePing: Effect.Effect<A, E, R>,
171
+ pingSchedule: Schedule.Schedule<unknown> = Schedule.spaced(10000).pipe(Schedule.addDelay(() => 5000)),
172
+ ) {
173
+ // CHANGED: add manual ping deferreds
174
+ const manualPingDeferreds = new Set<Deferred.Deferred<void, never>>()
175
+
176
+ let recievedPong = true
177
+ const latch = Effect.unsafeMakeLatch()
178
+ const reset = () => {
179
+ recievedPong = true
180
+ latch.unsafeClose()
181
+ }
182
+ const onPong = () => {
183
+ recievedPong = true
184
+ // CHANGED: mark all manual ping deferreds as done
185
+ for (const deferred of manualPingDeferreds) {
186
+ Deferred.unsafeDone(deferred, Effect.void)
187
+ }
188
+ }
189
+ yield* Effect.suspend(() => {
190
+ // Starting new ping
191
+ if (!recievedPong) return latch.open
192
+ recievedPong = false
193
+ return writePing
194
+ }).pipe(
195
+ // CHANGED: make configurable via schedule
196
+ Effect.schedule(pingSchedule),
197
+ Effect.ignore,
198
+ Effect.forever,
199
+ Effect.interruptible,
200
+ Effect.forkScoped,
201
+ )
202
+
203
+ // CHANGED: add manual ping
204
+ const ping = Effect.gen(function* () {
205
+ const deferred = yield* Deferred.make<void, never>()
206
+ manualPingDeferreds.add(deferred)
207
+ yield* deferred
208
+ manualPingDeferreds.delete(deferred)
209
+ })
210
+
211
+ return { timeout: latch.await, reset, onPong, ping } as const
212
+ })
@@ -1,14 +1,13 @@
1
1
  import { Transferable } from '@effect/platform'
2
- import type { SchemaAST } from 'effect'
3
2
  import { Effect, Hash, ParseResult, Schema } from 'effect'
4
3
  import type { ParseError } from 'effect/ParseResult'
5
4
  import type { ParseOptions } from 'effect/SchemaAST'
5
+ import * as SchemaAST from 'effect/SchemaAST'
6
6
 
7
7
  import { shouldNeverHappen } from '../../mod.ts'
8
8
 
9
9
  export * from 'effect/Schema'
10
10
  export * from './debug-diff.ts'
11
- export * from './msgpack.ts'
12
11
 
13
12
  // NOTE this is a temporary workaround until Effect schema has a better way to hash schemas
14
13
  // https://github.com/Effect-TS/effect/issues/2719
@@ -24,6 +23,21 @@ export const hash = (schema: Schema.Schema<any>) => {
24
23
  }
25
24
  }
26
25
 
26
+ const resolveStructAst = (ast: SchemaAST.AST): SchemaAST.AST => {
27
+ if (SchemaAST.isTransformation(ast)) {
28
+ return resolveStructAst(ast.from)
29
+ }
30
+
31
+ return ast
32
+ }
33
+
34
+ export const getResolvedPropertySignatures = (
35
+ schema: Schema.Schema.AnyNoContext,
36
+ ): ReadonlyArray<SchemaAST.PropertySignature> => {
37
+ const resolvedAst = resolveStructAst(schema.ast)
38
+ return SchemaAST.getPropertySignatures(resolvedAst)
39
+ }
40
+
27
41
  export const encodeWithTransferables =
28
42
  <A, I, R>(schema: Schema.Schema<A, I, R>, options?: ParseOptions | undefined) =>
29
43
  (a: A, overrideOptions?: ParseOptions | undefined): Effect.Effect<[I, Transferable[]], ParseError, R> =>
@@ -82,4 +96,4 @@ export const JsonValue: Schema.Schema<JsonValue> = Schema.Union(
82
96
  Schema.Null,
83
97
  Schema.Array(Schema.suspend(() => JsonValue)),
84
98
  Schema.Record({ key: Schema.String, value: Schema.suspend(() => JsonValue) }),
85
- ).annotations({ title: 'JsonValue' })
99
+ ).annotations({ identifier: 'JsonValue' })
@@ -0,0 +1,127 @@
1
+ import { Effect, Option, Stream } from 'effect'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { concatWithLastElement, runCollectReadonlyArray } from './Stream.ts'
4
+
5
+ describe('concatWithLastElement', () => {
6
+ it('should concatenate streams with access to last element of first stream', async () => {
7
+ const stream1 = Stream.make(1, 2, 3)
8
+ const result = concatWithLastElement(stream1, (lastElement) =>
9
+ lastElement.pipe(
10
+ Option.match({
11
+ onNone: () => Stream.make('no-previous'),
12
+ onSome: (last) => Stream.make(`last-was-${last}`, 'continuing'),
13
+ }),
14
+ ),
15
+ )
16
+
17
+ const collected = await Effect.runPromise(runCollectReadonlyArray(result))
18
+ expect(collected).toEqual([1, 2, 3, 'last-was-3', 'continuing'])
19
+ })
20
+
21
+ it('should handle empty first stream', async () => {
22
+ const stream1 = Stream.empty
23
+ const result = concatWithLastElement(stream1, (lastElement) =>
24
+ lastElement.pipe(
25
+ Option.match({
26
+ onNone: () => Stream.make('no-previous-element'),
27
+ onSome: (last) => Stream.make(`last-was-${last}`),
28
+ }),
29
+ ),
30
+ )
31
+
32
+ const collected = await Effect.runPromise(runCollectReadonlyArray(result))
33
+ expect(collected).toEqual(['no-previous-element'])
34
+ })
35
+
36
+ it('should handle single element first stream', async () => {
37
+ const stream1 = Stream.make('single')
38
+ const result = concatWithLastElement(stream1, (lastElement) =>
39
+ lastElement.pipe(
40
+ Option.match({
41
+ onNone: () => Stream.make('unexpected'),
42
+ onSome: (last) => Stream.make(`after-${last}`),
43
+ }),
44
+ ),
45
+ )
46
+
47
+ const collected = await Effect.runPromise(runCollectReadonlyArray(result))
48
+ expect(collected).toEqual(['single', 'after-single'])
49
+ })
50
+
51
+ it('should handle empty second stream', async () => {
52
+ const stream1 = Stream.make(1, 2, 3)
53
+ const result = concatWithLastElement(stream1, () => Stream.empty)
54
+
55
+ const collected = await Effect.runPromise(runCollectReadonlyArray(result))
56
+ expect(collected).toEqual([1, 2, 3])
57
+ })
58
+
59
+ it('should preserve error handling from first stream', async () => {
60
+ const stream1 = Stream.fail('first-error')
61
+ const result = concatWithLastElement(stream1, () => Stream.make('should-not-reach'))
62
+
63
+ const outcome = await Effect.runPromise(Effect.either(runCollectReadonlyArray(result)))
64
+ expect(outcome._tag).toBe('Left')
65
+ if (outcome._tag === 'Left') {
66
+ expect(outcome.left).toBe('first-error')
67
+ }
68
+ })
69
+
70
+ it('should preserve error handling from second stream', async () => {
71
+ const stream1 = Stream.make(1, 2)
72
+ const result = concatWithLastElement(stream1, () => Stream.fail('second-error'))
73
+
74
+ const outcome = await Effect.runPromise(Effect.either(runCollectReadonlyArray(result)))
75
+ expect(outcome._tag).toBe('Left')
76
+ if (outcome._tag === 'Left') {
77
+ expect(outcome.left).toBe('second-error')
78
+ }
79
+ })
80
+
81
+ it('should work with different types in streams', async () => {
82
+ const stream1 = Stream.make(1, 2, 3)
83
+ const result = concatWithLastElement(stream1, (lastElement) =>
84
+ lastElement.pipe(
85
+ Option.match({
86
+ onNone: () => Stream.make('no-number') as Stream.Stream<number | string, never, never>,
87
+ onSome: (last) => Stream.make(last * 10, last * 100),
88
+ }),
89
+ ),
90
+ )
91
+
92
+ const collected = await Effect.runPromise(runCollectReadonlyArray(result))
93
+ expect(collected).toEqual([1, 2, 3, 30, 300])
94
+ })
95
+
96
+ it('should handle async effects in streams', async () => {
97
+ const stream1 = Stream.fromEffect(Effect.succeed('async-value'))
98
+ const result = concatWithLastElement(stream1, (lastElement) =>
99
+ lastElement.pipe(
100
+ Option.match({
101
+ onNone: () => Stream.fromEffect(Effect.succeed('no-async')),
102
+ onSome: (last) => Stream.fromEffect(Effect.succeed(`processed-${last}`)),
103
+ }),
104
+ ),
105
+ )
106
+
107
+ const collected = await Effect.runPromise(runCollectReadonlyArray(result))
108
+ expect(collected).toEqual(['async-value', 'processed-async-value'])
109
+ })
110
+
111
+ it('should work with dual function - piped style', async () => {
112
+ const stream1 = Stream.make('a', 'b', 'c')
113
+ const result = stream1.pipe(
114
+ concatWithLastElement((lastElement) =>
115
+ lastElement.pipe(
116
+ Option.match({
117
+ onNone: () => Stream.make('no-last'),
118
+ onSome: (last) => Stream.make(`last-${last}`, 'done'),
119
+ }),
120
+ ),
121
+ ),
122
+ )
123
+
124
+ const collected = await Effect.runPromise(runCollectReadonlyArray(result))
125
+ expect(collected).toEqual(['a', 'b', 'c', 'last-c', 'done'])
126
+ })
127
+ })
@@ -1,7 +1,8 @@
1
+ /** biome-ignore-all lint/suspicious/useIterableCallbackReturn: Biome bug */
1
2
  export * from 'effect/Stream'
2
3
 
3
- import type { Chunk } from 'effect'
4
- import { Effect, Option, pipe, Ref, Stream } from 'effect'
4
+ import { type Cause, Chunk, Effect, Option, pipe, Ref, Stream } from 'effect'
5
+ import { dual } from 'effect/Function'
5
6
 
6
7
  export const tapLog = <R, E, A>(stream: Stream.Stream<A, E, R>): Stream.Stream<A, E, R> =>
7
8
  tapChunk<never, never, A, void>(Effect.forEach((_) => Effect.succeed(console.log(_))))(stream)
@@ -61,3 +62,111 @@ export const skipRepeated_ = <R, E, A>(
61
62
  ),
62
63
  ),
63
64
  )
65
+
66
+ /**
67
+ * Returns the first element of the stream or `None` if the stream is empty.
68
+ * It's different than `Stream.runHead` which runs the stream to completion.
69
+ * */
70
+ export const runFirst = <A, E, R>(stream: Stream.Stream<A, E, R>): Effect.Effect<Option.Option<A>, E, R> =>
71
+ stream.pipe(Stream.take(1), Stream.runCollect, Effect.map(Chunk.head))
72
+
73
+ /**
74
+ * Returns the first element of the stream or throws a `NoSuchElementException` if the stream is empty.
75
+ * It's different than `Stream.runHead` which runs the stream to completion.
76
+ * */
77
+ export const runFirstUnsafe = <A, E, R>(
78
+ stream: Stream.Stream<A, E, R>,
79
+ ): Effect.Effect<A, Cause.NoSuchElementException | E, R> => runFirst(stream).pipe(Effect.flatten)
80
+
81
+ export const runCollectReadonlyArray = <A, E, R>(stream: Stream.Stream<A, E, R>): Effect.Effect<readonly A[], E, R> =>
82
+ stream.pipe(Stream.runCollect, Effect.map(Chunk.toReadonlyArray))
83
+
84
+ /**
85
+ * Concatenates two streams where the second stream has access to the last element
86
+ * of the first stream as an `Option`. If the first stream is empty, the callback
87
+ * receives `Option.none()`.
88
+ *
89
+ * @param stream - The first stream to consume
90
+ * @param getStream2 - Function that receives the last element from the first stream
91
+ * and returns the second stream to concatenate
92
+ * @returns A new stream containing all elements from both streams
93
+ *
94
+ * @example
95
+ * ```ts
96
+ * // Direct usage
97
+ * const result = concatWithLastElement(
98
+ * Stream.make(1, 2, 3),
99
+ * lastElement => lastElement.pipe(
100
+ * Option.match({
101
+ * onNone: () => Stream.make('empty'),
102
+ * onSome: last => Stream.make(`last-was-${last}`)
103
+ * })
104
+ * )
105
+ * )
106
+ *
107
+ * // Piped usage
108
+ * const result = Stream.make(1, 2, 3).pipe(
109
+ * concatWithLastElement(lastElement =>
110
+ * Stream.make(lastElement.pipe(Option.getOrElse(() => 0)) * 10)
111
+ * )
112
+ * )
113
+ * ```
114
+ */
115
+ export const concatWithLastElement: {
116
+ <A1, A2, E2, R2>(
117
+ getStream2: (lastElement: Option.Option<A1>) => Stream.Stream<A2, E2, R2>,
118
+ ): <E1, R1>(stream: Stream.Stream<A1, E1, R1>) => Stream.Stream<A1 | A2, E1 | E2, R1 | R2>
119
+ <A1, E1, R1, A2, E2, R2>(
120
+ stream: Stream.Stream<A1, E1, R1>,
121
+ getStream2: (lastElement: Option.Option<A1>) => Stream.Stream<A2, E2, R2>,
122
+ ): Stream.Stream<A1 | A2, E1 | E2, R1 | R2>
123
+ } = dual(
124
+ 2,
125
+ <A1, E1, R1, A2, E2, R2>(
126
+ stream1: Stream.Stream<A1, E1, R1>,
127
+ getStream2: (lastElement: Option.Option<A1>) => Stream.Stream<A2, E2, R2>,
128
+ ): Stream.Stream<A1 | A2, E1 | E2, R1 | R2> =>
129
+ pipe(
130
+ Ref.make<Option.Option<A1>>(Option.none()),
131
+ Stream.fromEffect,
132
+ Stream.flatMap((lastRef) =>
133
+ pipe(
134
+ stream1,
135
+ Stream.tap((value) => Ref.set(lastRef, Option.some(value))),
136
+ Stream.concat(pipe(Ref.get(lastRef), Effect.map(getStream2), Stream.unwrap)),
137
+ ),
138
+ ),
139
+ ),
140
+ )
141
+
142
+ /**
143
+ * Emits a default value if the stream is empty, otherwise passes through all elements.
144
+ * Uses `concatWithLastElement` internally to detect if the stream was empty.
145
+ *
146
+ * @param fallbackValue - The value to emit if the stream is empty
147
+ * @returns A dual function that can be used in pipe or direct call
148
+ *
149
+ * @example
150
+ * ```ts
151
+ * // Direct usage
152
+ * const result = emitIfEmpty(Stream.empty, 'default')
153
+ * // Emits: 'default'
154
+ *
155
+ * // Piped usage
156
+ * const result = Stream.make(1, 2, 3).pipe(emitIfEmpty('fallback'))
157
+ * // Emits: 1, 2, 3
158
+ *
159
+ * const empty = Stream.empty.pipe(emitIfEmpty('fallback'))
160
+ * // Emits: 'fallback'
161
+ * ```
162
+ */
163
+ export const emitIfEmpty: {
164
+ <A>(fallbackValue: A): <E, R>(stream: Stream.Stream<A, E, R>) => Stream.Stream<A, E, R>
165
+ <A, E, R>(stream: Stream.Stream<A, E, R>, fallbackValue: A): Stream.Stream<A, E, R>
166
+ } = dual(
167
+ 2,
168
+ <A, E, R>(stream: Stream.Stream<A, E, R>, fallbackValue: A): Stream.Stream<A, E, R> =>
169
+ concatWithLastElement(stream, (lastElement) =>
170
+ lastElement._tag === 'None' ? Stream.make(fallbackValue) : Stream.empty,
171
+ ),
172
+ )
@@ -1,5 +1,4 @@
1
- import type { SubscriptionRef } from 'effect'
2
- import { Chunk, Effect, pipe, Stream } from 'effect'
1
+ import { Chunk, Effect, pipe, Stream, SubscriptionRef } from 'effect'
3
2
  import { dual } from 'effect/Function'
4
3
  import type { Predicate, Refinement } from 'effect/Predicate'
5
4
 
@@ -20,3 +19,16 @@ export const waitUntil: {
20
19
  } = dual(2, <A>(sref: SubscriptionRef.SubscriptionRef<A>, predicate: (a: A) => boolean) =>
21
20
  pipe(sref.changes, Stream.filter(predicate), Stream.take(1), Stream.runCollect, Effect.map(Chunk.unsafeHead)),
22
21
  )
22
+
23
+ export const fromStream = <A>(stream: Stream.Stream<A>, initialValue: A) =>
24
+ Effect.gen(function* () {
25
+ const sref = yield* SubscriptionRef.make(initialValue)
26
+
27
+ yield* stream.pipe(
28
+ Stream.tap((a) => SubscriptionRef.set(sref, a)),
29
+ Stream.runDrain,
30
+ Effect.forkScoped,
31
+ )
32
+
33
+ return sref
34
+ })
@@ -2,7 +2,7 @@ import * as Vitest from '@effect/vitest'
2
2
  import { Effect, Schema, Stream } from 'effect'
3
3
  import { JSDOM } from 'jsdom'
4
4
 
5
- import * as WebChannel from './WebChannel.ts'
5
+ import * as WebChannel from '../../browser/WebChannelBrowser.ts'
6
6
 
7
7
  Vitest.describe('WebChannel', () => {
8
8
  Vitest.describe('windowChannel', () => {