@livestore/utils 0.4.0-dev.3 → 0.4.0-dev.6

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 (92) 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/effect/Effect.d.ts +1 -2
  6. package/dist/effect/Effect.d.ts.map +1 -1
  7. package/dist/effect/Effect.js +2 -2
  8. package/dist/effect/Effect.js.map +1 -1
  9. package/dist/effect/Error.d.ts +1 -1
  10. package/dist/effect/Error.js.map +1 -1
  11. package/dist/effect/Logger.d.ts +4 -1
  12. package/dist/effect/Logger.d.ts.map +1 -1
  13. package/dist/effect/Logger.js +12 -3
  14. package/dist/effect/Logger.js.map +1 -1
  15. package/dist/effect/OtelTracer.d.ts +5 -0
  16. package/dist/effect/OtelTracer.d.ts.map +1 -0
  17. package/dist/effect/OtelTracer.js +8 -0
  18. package/dist/effect/OtelTracer.js.map +1 -0
  19. package/dist/effect/RpcClient.d.ts +32 -0
  20. package/dist/effect/RpcClient.d.ts.map +1 -0
  21. package/dist/effect/RpcClient.js +142 -0
  22. package/dist/effect/RpcClient.js.map +1 -0
  23. package/dist/effect/Schema/index.d.ts +0 -1
  24. package/dist/effect/Schema/index.d.ts.map +1 -1
  25. package/dist/effect/Schema/index.js +0 -1
  26. package/dist/effect/Schema/index.js.map +1 -1
  27. package/dist/effect/Stream.d.ts +73 -2
  28. package/dist/effect/Stream.d.ts.map +1 -1
  29. package/dist/effect/Stream.js +68 -1
  30. package/dist/effect/Stream.js.map +1 -1
  31. package/dist/effect/Stream.test.d.ts +2 -0
  32. package/dist/effect/Stream.test.d.ts.map +1 -0
  33. package/dist/effect/Stream.test.js +84 -0
  34. package/dist/effect/Stream.test.js.map +1 -0
  35. package/dist/effect/SubscriptionRef.d.ts +2 -2
  36. package/dist/effect/SubscriptionRef.d.ts.map +1 -1
  37. package/dist/effect/SubscriptionRef.js +6 -1
  38. package/dist/effect/SubscriptionRef.js.map +1 -1
  39. package/dist/effect/WebSocket.js +1 -1
  40. package/dist/effect/WebSocket.js.map +1 -1
  41. package/dist/effect/index.d.ts +9 -5
  42. package/dist/effect/index.d.ts.map +1 -1
  43. package/dist/effect/index.js +10 -7
  44. package/dist/effect/index.js.map +1 -1
  45. package/dist/global.d.ts +1 -0
  46. package/dist/global.d.ts.map +1 -1
  47. package/dist/global.js.map +1 -1
  48. package/dist/mod.d.ts +2 -0
  49. package/dist/mod.d.ts.map +1 -1
  50. package/dist/mod.js +4 -0
  51. package/dist/mod.js.map +1 -1
  52. package/dist/node/ChildProcessRunner/ChildProcessRunner.d.ts.map +1 -1
  53. package/dist/node/ChildProcessRunner/ChildProcessRunner.js +54 -10
  54. package/dist/node/ChildProcessRunner/ChildProcessRunner.js.map +1 -1
  55. package/dist/node/ChildProcessRunner/ChildProcessRunnerTest/ChildProcessRunner.test.js +182 -3
  56. package/dist/node/ChildProcessRunner/ChildProcessRunnerTest/ChildProcessRunner.test.js.map +1 -1
  57. package/dist/node/ChildProcessRunner/ChildProcessRunnerTest/schema.d.ts +12 -3
  58. package/dist/node/ChildProcessRunner/ChildProcessRunnerTest/schema.d.ts.map +1 -1
  59. package/dist/node/ChildProcessRunner/ChildProcessRunnerTest/schema.js +7 -1
  60. package/dist/node/ChildProcessRunner/ChildProcessRunnerTest/schema.js.map +1 -1
  61. package/dist/node/ChildProcessRunner/ChildProcessRunnerTest/serializedWorker.js +11 -1
  62. package/dist/node/ChildProcessRunner/ChildProcessRunnerTest/serializedWorker.js.map +1 -1
  63. package/dist/node/ChildProcessRunner/ChildProcessWorker.d.ts +16 -0
  64. package/dist/node/ChildProcessRunner/ChildProcessWorker.d.ts.map +1 -1
  65. package/dist/node/ChildProcessRunner/ChildProcessWorker.js +98 -2
  66. package/dist/node/ChildProcessRunner/ChildProcessWorker.js.map +1 -1
  67. package/dist/node/mod.d.ts +1 -1
  68. package/dist/node/mod.d.ts.map +1 -1
  69. package/dist/node/mod.js +2 -2
  70. package/dist/node/mod.js.map +1 -1
  71. package/package.json +31 -32
  72. package/src/NoopTracer.ts +1 -0
  73. package/src/effect/Effect.ts +15 -3
  74. package/src/effect/Error.ts +1 -1
  75. package/src/effect/Logger.ts +14 -4
  76. package/src/effect/OtelTracer.ts +11 -0
  77. package/src/effect/RpcClient.ts +205 -0
  78. package/src/effect/Schema/index.ts +0 -1
  79. package/src/effect/Stream.test.ts +127 -0
  80. package/src/effect/Stream.ts +111 -2
  81. package/src/effect/SubscriptionRef.ts +14 -2
  82. package/src/effect/WebSocket.ts +1 -1
  83. package/src/effect/index.ts +16 -5
  84. package/src/global.ts +1 -0
  85. package/src/mod.ts +9 -0
  86. package/src/node/ChildProcessRunner/ChildProcessRunner.ts +59 -10
  87. package/src/node/ChildProcessRunner/ChildProcessRunnerTest/ChildProcessRunner.test.ts +253 -3
  88. package/src/node/ChildProcessRunner/ChildProcessRunnerTest/schema.ts +14 -1
  89. package/src/node/ChildProcessRunner/ChildProcessRunnerTest/serializedWorker.ts +14 -1
  90. package/src/node/ChildProcessRunner/ChildProcessWorker.ts +111 -3
  91. package/src/node/mod.ts +3 -3
  92. package/src/effect/Schema/msgpack.ts +0 -8
package/package.json CHANGED
@@ -1,9 +1,10 @@
1
1
  {
2
2
  "name": "@livestore/utils",
3
- "version": "0.4.0-dev.3",
3
+ "version": "0.4.0-dev.6",
4
4
  "type": "module",
5
5
  "sideEffects": [
6
- "./src/global.ts"
6
+ "./src/global.ts",
7
+ "./dist/global.js"
7
8
  ],
8
9
  "exports": {
9
10
  ".": {
@@ -35,32 +36,32 @@
35
36
  },
36
37
  "dependencies": {
37
38
  "@standard-schema/spec": "1.0.0",
38
- "msgpackr": "1.11.5",
39
39
  "nanoid": "5.1.5",
40
- "pretty-bytes": "7.0.0"
40
+ "pretty-bytes": "7.0.1"
41
41
  },
42
42
  "devDependencies": {
43
- "@effect/cli": "^0.69.0",
44
- "@effect/cluster": "^0.46.2",
45
- "@effect/experimental": "^0.54.3",
46
- "@effect/opentelemetry": "^0.56.1",
47
- "@effect/platform": "^0.90.0",
43
+ "@effect/ai": "^0.26.0",
44
+ "@effect/cli": "^0.69.2",
45
+ "@effect/cluster": "^0.48.2",
46
+ "@effect/experimental": "^0.54.6",
47
+ "@effect/opentelemetry": "0.56.4",
48
+ "@effect/platform": "^0.90.6",
48
49
  "@effect/platform-browser": "^0.70.0",
49
- "@effect/platform-bun": "^0.77.0",
50
- "@effect/platform-node": "^0.94.1",
50
+ "@effect/platform-bun": "^0.79.0",
51
+ "@effect/platform-node": "^0.96.0",
51
52
  "@effect/printer": "^0.45.0",
52
53
  "@effect/printer-ansi": "^0.45.0",
53
- "@effect/rpc": "^0.68.3",
54
- "@effect/sql": "^0.44.0",
54
+ "@effect/rpc": "^0.69.1",
55
+ "@effect/sql": "^0.44.2",
55
56
  "@effect/typeclass": "^0.36.0",
56
- "@effect/vitest": "^0.25.0",
57
- "@opentelemetry/api": "^1.9.0",
57
+ "@effect/vitest": "^0.25.1",
58
+ "@opentelemetry/api": "1.9.0",
58
59
  "@opentelemetry/resources": "^2.0.1",
59
- "@types/bun": "^1.2.19",
60
+ "@types/bun": "^1.2.21",
60
61
  "@types/jsdom": "^21.1.7",
61
62
  "@types/node": "24.2.0",
62
- "@types/web": "^0.0.260",
63
- "effect": "^3.17.6",
63
+ "@types/web": "^0.0.264",
64
+ "effect": "3.17.9",
64
65
  "jsdom": "^26.1.0",
65
66
  "vitest": "3.2.4"
66
67
  },
@@ -71,28 +72,26 @@
71
72
  ],
72
73
  "license": "Apache-2.0",
73
74
  "peerDependencies": {
74
- "@effect/cli": "^0.69.0",
75
- "@effect/cluster": "^0.46.2",
76
- "@effect/experimental": "^0.54.3",
77
- "@effect/opentelemetry": "^0.56.1",
78
- "@effect/platform": "^0.90.0",
75
+ "@effect/ai": "^0.26.0",
76
+ "@effect/cli": "^0.69.2",
77
+ "@effect/cluster": "^0.48.2",
78
+ "@effect/experimental": "^0.54.6",
79
+ "@effect/opentelemetry": "^0.56.4",
80
+ "@effect/platform": "^0.90.6",
79
81
  "@effect/platform-browser": "^0.70.0",
80
- "@effect/platform-bun": "^0.77.0",
81
- "@effect/platform-node": "^0.94.1",
82
+ "@effect/platform-bun": "^0.79.0",
83
+ "@effect/platform-node": "^0.96.0",
82
84
  "@effect/printer": "^0.45.0",
83
85
  "@effect/printer-ansi": "^0.45.0",
84
- "@effect/rpc": "^0.68.3",
85
- "@effect/sql": "^0.44.0",
86
+ "@effect/rpc": "^0.69.1",
87
+ "@effect/sql": "^0.44.2",
86
88
  "@effect/typeclass": "^0.36.0",
87
89
  "@opentelemetry/api": "^1.9.0",
88
90
  "@opentelemetry/resources": "^2.0.1",
89
- "effect": "^3.17.6"
91
+ "effect": "^3.17.9"
90
92
  },
91
93
  "publishConfig": {
92
- "access": "public",
93
- "sideEffects": [
94
- "./dist/global.js"
95
- ]
94
+ "access": "public"
96
95
  },
97
96
  "react-native": "./dist/index.js",
98
97
  "scripts": {
package/src/NoopTracer.ts CHANGED
@@ -9,6 +9,7 @@ export const makeNoopSpan = () => {
9
9
  setAttribute: () => null,
10
10
  setAttributes: () => null,
11
11
  addEvent: () => null,
12
+ addLink: () => null,
12
13
  setStatus: () => null,
13
14
  updateName: () => null,
14
15
  recordException: () => null,
@@ -1,6 +1,18 @@
1
1
  import * as OtelTracer from '@effect/opentelemetry/Tracer'
2
- import type { Context, Duration, Stream } from 'effect'
3
- import { Cause, Deferred, Effect, Fiber, FiberRef, HashSet, Logger, pipe, Scope } from 'effect'
2
+ import {
3
+ Cause,
4
+ type Context,
5
+ Deferred,
6
+ Duration,
7
+ Effect,
8
+ Fiber,
9
+ FiberRef,
10
+ HashSet,
11
+ Logger,
12
+ pipe,
13
+ Scope,
14
+ type Stream,
15
+ } from 'effect'
4
16
  import type { UnknownException } from 'effect/Cause'
5
17
  import { log } from 'effect/Console'
6
18
  import type { LazyArg } from 'effect/Function'
@@ -166,7 +178,7 @@ export const logDuration =
166
178
  const start = Date.now()
167
179
  const res = yield* eff
168
180
  const end = Date.now()
169
- yield* Effect.log(`${label}: ${end - start}ms`)
181
+ yield* Effect.log(`${label}: ${Duration.format(end - start)}`)
170
182
  return res
171
183
  })
172
184
 
@@ -1,6 +1,6 @@
1
1
  import { Schema } from 'effect'
2
2
 
3
- export class UnknownError extends Schema.TaggedError<'UnknownError'>()('UnknownError', {
3
+ export class UnknownError extends Schema.TaggedError<UnknownError>()('UnknownError', {
4
4
  cause: Schema.Any,
5
5
  payload: Schema.optional(Schema.Any),
6
6
  }) {}
@@ -8,20 +8,24 @@ const defaultDateFormat = (date: Date): string =>
8
8
  .toString()
9
9
  .padStart(2, '0')}.${date.getMilliseconds().toString().padStart(3, '0')}`
10
10
 
11
- export const prettyWithThread = (threadName: string) =>
11
+ export const prettyWithThread = (threadName: string, options: { mode?: 'tty' | 'browser' } = {}) =>
12
12
  Logger.replace(
13
13
  Logger.defaultLogger,
14
14
  Logger.prettyLogger({
15
15
  formatDate: (date) => `${defaultDateFormat(date)} ${threadName}`,
16
+ mode: options.mode,
16
17
  }),
17
- // consoleLogger(threadName),
18
18
  )
19
19
 
20
20
  export const consoleLogger = (threadName: string) =>
21
21
  Logger.make(({ message, annotations, date, logLevel, cause }) => {
22
+ const isCloudflareWorker = typeof navigator !== 'undefined' && navigator.userAgent === 'Cloudflare-Workers'
22
23
  const consoleFn =
23
24
  logLevel === LogLevel.Debug
24
- ? console.debug
25
+ ? // Cloudflare Workers doesn't support console.debug 🤷
26
+ isCloudflareWorker
27
+ ? console.log
28
+ : console.debug
25
29
  : logLevel === LogLevel.Info
26
30
  ? console.info
27
31
  : logLevel === LogLevel.Warning
@@ -35,5 +39,11 @@ export const consoleLogger = (threadName: string) =>
35
39
  messages.push(Cause.pretty(cause, { renderErrorCause: true }))
36
40
  }
37
41
 
38
- consoleFn(`[${defaultDateFormat(date)} ${threadName}]`, ...messages, annotationsObj)
42
+ if (Object.keys(annotationsObj).length > 0) {
43
+ messages.push(annotationsObj)
44
+ }
45
+
46
+ consoleFn(`[${defaultDateFormat(date)} ${threadName}]`, ...messages)
39
47
  })
48
+
49
+ export const consoleWithThread = (threadName: string) => Logger.replace(Logger.defaultLogger, consoleLogger(threadName))
@@ -0,0 +1,11 @@
1
+ import { makeExternalSpan } from '@effect/opentelemetry/Tracer'
2
+ import type { Link as OtelSpanLink } from '@opentelemetry/api'
3
+ import type { SpanLink as EffectSpanLink } from 'effect/Tracer'
4
+
5
+ export * from '@effect/opentelemetry/Tracer'
6
+
7
+ export const makeSpanLink = (otelSpanLink: OtelSpanLink): EffectSpanLink => ({
8
+ _tag: 'SpanLink',
9
+ span: makeExternalSpan(otelSpanLink.context),
10
+ attributes: otelSpanLink.attributes ?? {},
11
+ })
@@ -0,0 +1,205 @@
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
+ // CHANGED: don't reset parser on every message
44
+ // parser = serialization.unsafeMake()
45
+ pinger.reset()
46
+ return socket
47
+ .runRaw((message) => {
48
+ try {
49
+ const responses = parser.decode(message) as Array<FromServerEncoded>
50
+ if (responses.length === 0) return
51
+ let i = 0
52
+ return Effect.whileLoop({
53
+ while: () => i < responses.length,
54
+ body: () => {
55
+ const response = responses[i++]!
56
+ if (response._tag === 'Pong') {
57
+ pinger.onPong()
58
+ }
59
+ return writeResponse(response).pipe(
60
+ // CHANGED: set isConnected to true on pong
61
+ Effect.tap(
62
+ Effect.fn(function* () {
63
+ if (options?.isConnected !== undefined) {
64
+ yield* SubscriptionRef.set(options.isConnected, true)
65
+ }
66
+ }),
67
+ ),
68
+ )
69
+ },
70
+ step: constVoid,
71
+ })
72
+ } catch (defect) {
73
+ return writeResponse({
74
+ _tag: 'ClientProtocolError',
75
+ error: new RpcClientError.RpcClientError({
76
+ reason: 'Protocol',
77
+ message: 'Error decoding message',
78
+ cause: Cause.fail(defect),
79
+ }),
80
+ })
81
+ }
82
+ })
83
+ .pipe(
84
+ Effect.raceFirst(
85
+ Effect.zipRight(
86
+ pinger.timeout,
87
+ Effect.fail(
88
+ new Socket.SocketGenericError({
89
+ reason: 'OpenTimeout',
90
+ cause: new Error('ping timeout'),
91
+ }),
92
+ ),
93
+ ),
94
+ ),
95
+ )
96
+ }).pipe(
97
+ Effect.zipRight(
98
+ Effect.fail(
99
+ new Socket.SocketCloseError({
100
+ reason: 'Close',
101
+ code: 1000,
102
+ closeReason: 'Closing connection',
103
+ }),
104
+ ),
105
+ ),
106
+ Effect.tapErrorCause(
107
+ Effect.fn(function* (cause) {
108
+ // CHANGED: set isConnected to false on error
109
+ if (options?.isConnected !== undefined) {
110
+ yield* SubscriptionRef.set(options.isConnected, false)
111
+ }
112
+
113
+ const error = Cause.failureOption(cause)
114
+ if (
115
+ options?.retryTransientErrors &&
116
+ Option.isSome(error) &&
117
+ (error.value.reason === 'Open' || error.value.reason === 'OpenTimeout')
118
+ ) {
119
+ return
120
+ }
121
+ // yield* Effect.logError('Error in socket', cause)
122
+ return yield* writeResponse({
123
+ _tag: 'ClientProtocolError',
124
+ error: new RpcClientError.RpcClientError({
125
+ reason: 'Protocol',
126
+ message: 'Error in socket',
127
+ cause: Cause.squash(cause),
128
+ }),
129
+ })
130
+ }),
131
+ ),
132
+ // CHANGED: make configurable via schedule
133
+ options?.retryTransientErrors ? Effect.retry(options.retryTransientErrors) : identity,
134
+ Effect.annotateLogs({
135
+ module: 'RpcClient',
136
+ method: 'makeProtocolSocket',
137
+ }),
138
+ Effect.interruptible,
139
+ Effect.ignore, // Errors are already handled
140
+ Effect.provide(Layer.setUnhandledErrorLogLevel(Option.none())),
141
+ Effect.forkScoped,
142
+ )
143
+
144
+ return {
145
+ send: (request) => {
146
+ const encoded = parser.encode(request)
147
+ if (encoded === undefined) return Effect.void
148
+
149
+ return Effect.orDie(write(encoded))
150
+ },
151
+ supportsAck: true,
152
+ supportsTransferables: false,
153
+ pinger,
154
+ }
155
+ }),
156
+ )
157
+
158
+ export const SocketPinger = Effect.map(RpcClient.Protocol, (protocol) => (protocol as any).pinger as SocketPinger)
159
+
160
+ export type SocketPinger = Effect.Effect.Success<ReturnType<typeof makePinger>>
161
+
162
+ const makePinger = Effect.fnUntraced(function* <A, E, R>(
163
+ writePing: Effect.Effect<A, E, R>,
164
+ pingSchedule: Schedule.Schedule<unknown> = Schedule.spaced(10000).pipe(Schedule.addDelay(() => 5000)),
165
+ ) {
166
+ // CHANGED: add manual ping deferreds
167
+ const manualPingDeferreds = new Set<Deferred.Deferred<void, never>>()
168
+
169
+ let recievedPong = true
170
+ const latch = Effect.unsafeMakeLatch()
171
+ const reset = () => {
172
+ recievedPong = true
173
+ latch.unsafeClose()
174
+ }
175
+ const onPong = () => {
176
+ recievedPong = true
177
+ // CHANGED: mark all manual ping deferreds as done
178
+ for (const deferred of manualPingDeferreds) {
179
+ Deferred.unsafeDone(deferred, Effect.void)
180
+ }
181
+ }
182
+ yield* Effect.suspend(() => {
183
+ // Starting new ping
184
+ if (!recievedPong) return latch.open
185
+ recievedPong = false
186
+ return writePing
187
+ }).pipe(
188
+ // CHANGED: make configurable via schedule
189
+ Effect.schedule(pingSchedule),
190
+ Effect.ignore,
191
+ Effect.forever,
192
+ Effect.interruptible,
193
+ Effect.forkScoped,
194
+ )
195
+
196
+ // CHANGED: add manual ping
197
+ const ping = Effect.gen(function* () {
198
+ const deferred = yield* Deferred.make<void, never>()
199
+ manualPingDeferreds.add(deferred)
200
+ yield* deferred
201
+ manualPingDeferreds.delete(deferred)
202
+ })
203
+
204
+ return { timeout: latch.await, reset, onPong, ping } as const
205
+ })
@@ -8,7 +8,6 @@ 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
@@ -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
+ )