@@ -21,6 +21,16 @@
|
|||||||
"effect": "^3.21.0",
|
"effect": "^3.21.0",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"packages/effect-lens-next": {
|
||||||
|
"name": "effect-lens-next",
|
||||||
|
"version": "0.1.0-beta.0",
|
||||||
|
"devDependencies": {
|
||||||
|
"effect": "4.0.0-beta.85",
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"effect": "4.0.0-beta.85",
|
||||||
|
},
|
||||||
|
},
|
||||||
"packages/example": {
|
"packages/example": {
|
||||||
"name": "@effect-lens/example",
|
"name": "@effect-lens/example",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
@@ -140,10 +150,14 @@
|
|||||||
|
|
||||||
"effect-lens": ["effect-lens@workspace:packages/effect-lens"],
|
"effect-lens": ["effect-lens@workspace:packages/effect-lens"],
|
||||||
|
|
||||||
|
"effect-lens-next": ["effect-lens-next@workspace:packages/effect-lens-next"],
|
||||||
|
|
||||||
"fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="],
|
"fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="],
|
||||||
|
|
||||||
"find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="],
|
"find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="],
|
||||||
|
|
||||||
|
"ini": ["ini@7.0.0", "", {}, "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w=="],
|
||||||
|
|
||||||
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
||||||
|
|
||||||
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
||||||
@@ -168,14 +182,44 @@
|
|||||||
|
|
||||||
"pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="],
|
"pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="],
|
||||||
|
|
||||||
|
"toml": ["toml@4.1.1", "", {}, "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw=="],
|
||||||
|
|
||||||
"turbo": ["turbo@2.9.16", "", { "optionalDependencies": { "@turbo/darwin-64": "2.9.16", "@turbo/darwin-arm64": "2.9.16", "@turbo/linux-64": "2.9.16", "@turbo/linux-arm64": "2.9.16", "@turbo/windows-64": "2.9.16", "@turbo/windows-arm64": "2.9.16" }, "bin": { "turbo": "bin/turbo" } }, "sha512-NqgRQy6j6dPYcdSdv0q1g9QsZg7SWg87RERM8otw/1AtKU2yTFVClOM7cbwKzOonZr/Ek1blTBucw64L9H0Bwg=="],
|
"turbo": ["turbo@2.9.16", "", { "optionalDependencies": { "@turbo/darwin-64": "2.9.16", "@turbo/darwin-arm64": "2.9.16", "@turbo/linux-64": "2.9.16", "@turbo/linux-arm64": "2.9.16", "@turbo/windows-64": "2.9.16", "@turbo/windows-arm64": "2.9.16" }, "bin": { "turbo": "bin/turbo" } }, "sha512-NqgRQy6j6dPYcdSdv0q1g9QsZg7SWg87RERM8otw/1AtKU2yTFVClOM7cbwKzOonZr/Ek1blTBucw64L9H0Bwg=="],
|
||||||
|
|
||||||
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
|
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
|
||||||
|
|
||||||
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||||
|
|
||||||
"uuid": ["uuid@11.1.1", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ=="],
|
"uuid": ["uuid@14.0.1", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew=="],
|
||||||
|
|
||||||
"ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="],
|
"ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="],
|
||||||
|
|
||||||
|
"yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
|
||||||
|
|
||||||
|
"@effect/experimental/uuid": ["uuid@11.1.1", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ=="],
|
||||||
|
|
||||||
|
"@effect/sql/uuid": ["uuid@11.1.1", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ=="],
|
||||||
|
|
||||||
|
"effect-lens-next/effect": ["effect@4.0.0-beta.85", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-Cjv9YQyv4CiIccmRAQIWAoeESCpCpiuHYY8zb5vqiYs3Ac2yE5RQAnKq7z4Ir/3VJYZb8kxh6rS8czDDnpTdkQ=="],
|
||||||
|
|
||||||
|
"effect-lens-next/effect/fast-check": ["fast-check@4.8.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg=="],
|
||||||
|
|
||||||
|
"effect-lens-next/effect/msgpackr": ["msgpackr@2.0.4", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-o1C5KRmuRt+apqMr1HuGSqWStZoRBUpEsCsl15uM9VdAF1qHLtvMOU2En747EnTyEl6c4pzPewRMFF31s1CNbA=="],
|
||||||
|
|
||||||
|
"effect-lens-next/effect/fast-check/pure-rand": ["pure-rand@8.4.0", "", {}, "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A=="],
|
||||||
|
|
||||||
|
"effect-lens-next/effect/msgpackr/msgpackr-extract": ["msgpackr-extract@3.0.4", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw=="],
|
||||||
|
|
||||||
|
"effect-lens-next/effect/msgpackr/msgpackr-extract/@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ=="],
|
||||||
|
|
||||||
|
"effect-lens-next/effect/msgpackr/msgpackr-extract/@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w=="],
|
||||||
|
|
||||||
|
"effect-lens-next/effect/msgpackr/msgpackr-extract/@msgpackr-extract/msgpackr-extract-linux-arm": ["@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4", "", { "os": "linux", "cpu": "arm" }, "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw=="],
|
||||||
|
|
||||||
|
"effect-lens-next/effect/msgpackr/msgpackr-extract/@msgpackr-extract/msgpackr-extract-linux-arm64": ["@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw=="],
|
||||||
|
|
||||||
|
"effect-lens-next/effect/msgpackr/msgpackr-extract/@msgpackr-extract/msgpackr-extract-linux-x64": ["@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ=="],
|
||||||
|
|
||||||
|
"effect-lens-next/effect/msgpackr/msgpackr-extract/@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4", "", { "os": "win32", "cpu": "x64" }, "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ=="],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
# Effect Lens Next
|
||||||
|
|
||||||
|
An effectful Lens for Effect v4.
|
||||||
|
|
||||||
|
`effect-lens-next` provides a bidirectional view into shared state. A Lens can read, observe, and atomically update a value while focusing on a nested part of a larger structure.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install effect@4.0.0-beta.85 effect-lens-next
|
||||||
|
```
|
||||||
|
|
||||||
|
This release targets Effect `4.0.0-beta.85` exactly while Effect v4 is in beta.
|
||||||
|
|
||||||
|
## Quickstart
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { Effect, Stream, SubscriptionRef } from "effect"
|
||||||
|
import { Lens } from "effect-lens-next"
|
||||||
|
|
||||||
|
const program = Effect.gen(function*() {
|
||||||
|
const state = yield* SubscriptionRef.make({ count: 0, label: "Counter" })
|
||||||
|
|
||||||
|
const count = state.pipe(
|
||||||
|
Lens.fromSubscriptionRef,
|
||||||
|
Lens.focusObjectOn("count")
|
||||||
|
)
|
||||||
|
|
||||||
|
yield* Lens.update(count, (value) => value + 1)
|
||||||
|
const current = yield* Lens.get(count)
|
||||||
|
|
||||||
|
yield* count.changes.pipe(
|
||||||
|
Stream.take(1),
|
||||||
|
Stream.runForEach((value) => Effect.log(`count: ${value}`))
|
||||||
|
)
|
||||||
|
|
||||||
|
return current
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Lens Type
|
||||||
|
|
||||||
|
```ts
|
||||||
|
Lens.Lens<
|
||||||
|
A, // focused value
|
||||||
|
ER, // read errors
|
||||||
|
EW, // write errors
|
||||||
|
RR, // read requirements
|
||||||
|
RW // write requirements
|
||||||
|
>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Constructors
|
||||||
|
|
||||||
|
- `Lens.fromSubscriptionRef` creates a reactive Lens.
|
||||||
|
- `Lens.fromSynchronizedRef` creates a non-reactive Lens whose stream emits its value when evaluated.
|
||||||
|
- `Lens.fromRef` creates a non-reactive Lens and returns it in an Effect because it allocates a semaphore.
|
||||||
|
- `Lens.make` creates a Lens from custom `get`, `changes`, `commit`, and `lock` operations.
|
||||||
|
- `Lens.unwrap` flattens an effect that produces a Lens.
|
||||||
|
|
||||||
|
## Focusing
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { Effect, SubscriptionRef } from "effect"
|
||||||
|
import { Lens } from "effect-lens-next"
|
||||||
|
|
||||||
|
const program = Effect.gen(function*() {
|
||||||
|
const state = yield* SubscriptionRef.make({
|
||||||
|
users: [{ name: "Ada", score: 10 }]
|
||||||
|
})
|
||||||
|
|
||||||
|
const score = state.pipe(
|
||||||
|
Lens.fromSubscriptionRef,
|
||||||
|
Lens.focusObjectOn("users"),
|
||||||
|
Lens.focusArrayAt(0),
|
||||||
|
Lens.focusObjectOn("score")
|
||||||
|
)
|
||||||
|
|
||||||
|
yield* Lens.set(score, 11)
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Available focus operations include object fields, mutable object fields, arrays, mutable arrays, tuples, mutable tuples, `Chunk` values, and `Option` values. Safe indexed operations fail with `Cause.NoSuchElementError` when the index is absent.
|
||||||
|
|
||||||
|
Effect v4 no longer treats `Option` as an Effect. Custom effectful mappings should convert optional values explicitly:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { Array, Effect } from "effect"
|
||||||
|
|
||||||
|
Lens.mapEffect(
|
||||||
|
(values: ReadonlyArray<number>) => Effect.fromOption(Array.get(values, 0)),
|
||||||
|
(values, value) => Effect.fromOption(Array.replace(values, 0, value))
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Updating
|
||||||
|
|
||||||
|
The update API follows Effect v4's `SubscriptionRef` conventions:
|
||||||
|
|
||||||
|
- `get`, `set`, `getAndSet`, `setAndGet`
|
||||||
|
- `modify`, `modifyEffect`
|
||||||
|
- `update`, `updateEffect`
|
||||||
|
- `getAndUpdate`, `getAndUpdateEffect`
|
||||||
|
- `updateAndGet`, `updateAndGetEffect`
|
||||||
|
- `modifySome`, `modifySomeEffect`
|
||||||
|
- `getAndUpdateSome`, `getAndUpdateSomeEffect`
|
||||||
|
- `updateSome`, `updateSomeEffect`
|
||||||
|
- `updateSomeAndGet`, `updateSomeAndGetEffect`
|
||||||
|
|
||||||
|
Conditional updates use `Option.none()` to skip both the write and change notification:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { Effect, Option } from "effect"
|
||||||
|
|
||||||
|
yield* Lens.updateSome(lens, (value) =>
|
||||||
|
value < 10 ? Option.some(value + 1) : Option.none()
|
||||||
|
)
|
||||||
|
|
||||||
|
yield* Lens.updateSomeEffect(lens, (value) =>
|
||||||
|
Effect.succeed(value < 10 ? Option.some(value + 1) : Option.none())
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Subscribable
|
||||||
|
|
||||||
|
Effect v4 removed its `Readable` and `Subscribable` modules. This package provides a small compatible abstraction with `get` and `changes` properties:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { Subscribable } from "effect-lens-next"
|
||||||
|
|
||||||
|
const users = Subscribable.fromSubscriptionRef(usersRef)
|
||||||
|
const count = Subscribable.focusArrayLength(users)
|
||||||
|
|
||||||
|
const current = yield* count.get
|
||||||
|
```
|
||||||
|
|
||||||
|
The module includes `make`, `map`, `mapEffect`, `unwrap`, `fromSubscriptionRef`, and the same readonly focus helpers used by Lens.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://biomejs.dev/schemas/latest/schema.json",
|
||||||
|
"root": false,
|
||||||
|
"extends": "//",
|
||||||
|
"files": {
|
||||||
|
"includes": ["./src/**"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"name": "effect-lens-next",
|
||||||
|
"description": "An effectful Lens type for Effect v4",
|
||||||
|
"version": "0.1.0-beta.0",
|
||||||
|
"type": "module",
|
||||||
|
"files": [
|
||||||
|
"./README.md",
|
||||||
|
"./dist"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"repository": {
|
||||||
|
"url": "git+https://github.com/Thiladev/effect-lens.git"
|
||||||
|
},
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"default": "./dist/index.js"
|
||||||
|
},
|
||||||
|
"./Lens": {
|
||||||
|
"types": "./dist/Lens.d.ts",
|
||||||
|
"default": "./dist/Lens.js"
|
||||||
|
},
|
||||||
|
"./Subscribable": {
|
||||||
|
"types": "./dist/Subscribable.d.ts",
|
||||||
|
"default": "./dist/Subscribable.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.build.json",
|
||||||
|
"lint:tsc": "tsc --noEmit",
|
||||||
|
"lint:biome": "biome lint",
|
||||||
|
"pack": "npm pack",
|
||||||
|
"clean:cache": "rm -rf .turbo tsconfig.tsbuildinfo",
|
||||||
|
"clean:dist": "rm -rf dist",
|
||||||
|
"clean:modules": "rm -rf node_modules"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"effect": "4.0.0-beta.85"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"effect": "4.0.0-beta.85"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,565 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { Chunk, Context, Effect, Fiber, identity, Option, Ref, Result, Stream, SubscriptionRef, SynchronizedRef } from "effect"
|
||||||
|
import * as Lens from "./Lens.js"
|
||||||
|
|
||||||
|
|
||||||
|
describe("Lens", () => {
|
||||||
|
class Offset extends Context.Service<Offset, { readonly value: number }>()("Offset") {}
|
||||||
|
|
||||||
|
test("mapErrorRead transforms read errors", async () => {
|
||||||
|
const lens = Lens.mapErrorRead(
|
||||||
|
Lens.make<number, "read", never, never, never>({
|
||||||
|
get: Effect.fail("read" as const),
|
||||||
|
changes: Stream.fail("read" as const),
|
||||||
|
commit: () => Effect.void,
|
||||||
|
lock: Effect.succeed(identity),
|
||||||
|
}),
|
||||||
|
error => `mapped:${ error }`,
|
||||||
|
)
|
||||||
|
|
||||||
|
const result = await Effect.runPromise(Effect.result(Lens.get(lens)))
|
||||||
|
|
||||||
|
expect(result).toEqual(Result.fail("mapped:read"))
|
||||||
|
})
|
||||||
|
|
||||||
|
test("mapErrorWrite transforms modify errors", async () => {
|
||||||
|
const lens = Lens.mapErrorWrite(
|
||||||
|
Lens.make<number, never, "write", never, never>({
|
||||||
|
get: Effect.succeed(1),
|
||||||
|
changes: Stream.make(1),
|
||||||
|
commit: () => Effect.fail("write" as const),
|
||||||
|
lock: Effect.succeed(identity),
|
||||||
|
}),
|
||||||
|
() => "mapped-write",
|
||||||
|
)
|
||||||
|
|
||||||
|
const result = await Effect.runPromise(Effect.result(Lens.set(lens, 2)))
|
||||||
|
|
||||||
|
expect(result).toEqual(Result.fail("mapped-write"))
|
||||||
|
})
|
||||||
|
|
||||||
|
test("mapError transforms read and modify errors", async () => {
|
||||||
|
const lens = Lens.mapError(
|
||||||
|
Lens.make<number, "read", "write", never, never>({
|
||||||
|
get: Effect.fail("read" as const),
|
||||||
|
changes: Stream.fail("read" as const),
|
||||||
|
commit: () => Effect.fail("write" as const),
|
||||||
|
lock: Effect.succeed(identity),
|
||||||
|
}),
|
||||||
|
() => "mapped",
|
||||||
|
)
|
||||||
|
|
||||||
|
const result = await Effect.runPromise(Effect.all([
|
||||||
|
Effect.result(Lens.get(lens)),
|
||||||
|
Effect.result(Lens.set(lens, 1)),
|
||||||
|
] as const))
|
||||||
|
|
||||||
|
expect(result[0]).toEqual(Result.fail("mapped"))
|
||||||
|
expect(result[1]).toEqual(Result.fail("mapped"))
|
||||||
|
})
|
||||||
|
|
||||||
|
test("tapErrorRead runs an effect on read failures", async () => {
|
||||||
|
const result = await Effect.runPromise(
|
||||||
|
Effect.flatMap(
|
||||||
|
SubscriptionRef.make(0),
|
||||||
|
counter => {
|
||||||
|
const lens = Lens.tapErrorRead(
|
||||||
|
Lens.make<number, "read", never, never, never>({
|
||||||
|
get: Effect.fail("read" as const),
|
||||||
|
changes: Stream.fail("read" as const),
|
||||||
|
commit: () => Effect.void,
|
||||||
|
lock: Effect.succeed(identity),
|
||||||
|
}),
|
||||||
|
() => SubscriptionRef.modify(counter, n => [void 0, n + 1] as const),
|
||||||
|
)
|
||||||
|
return Effect.flatMap(
|
||||||
|
Effect.result(Lens.get(lens)),
|
||||||
|
() => SubscriptionRef.get(counter),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("tapErrorWrite runs an effect on modify failures", async () => {
|
||||||
|
const result = await Effect.runPromise(
|
||||||
|
Effect.flatMap(
|
||||||
|
SubscriptionRef.make(0),
|
||||||
|
counter => {
|
||||||
|
const lens = Lens.tapErrorWrite(
|
||||||
|
Lens.make<number, never, "write", never, never>({
|
||||||
|
get: Effect.succeed(1),
|
||||||
|
changes: Stream.make(1),
|
||||||
|
commit: () => Effect.fail("write" as const),
|
||||||
|
lock: Effect.succeed(identity),
|
||||||
|
}),
|
||||||
|
() => SubscriptionRef.modify(counter, n => [void 0, n + 1] as const),
|
||||||
|
)
|
||||||
|
return Effect.flatMap(
|
||||||
|
Effect.result(Lens.set(lens, 2)),
|
||||||
|
() => SubscriptionRef.get(counter),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("mapOption transforms Some values and preserves None", async () => {
|
||||||
|
const result = await Effect.runPromise(
|
||||||
|
Effect.flatMap(
|
||||||
|
SubscriptionRef.make<Option.Option<number>>(Option.some(42)),
|
||||||
|
parent => {
|
||||||
|
const lens = Lens.mapOption(
|
||||||
|
Lens.fromSubscriptionRef(parent),
|
||||||
|
n => n * 2,
|
||||||
|
(_n, doubled) => doubled / 2,
|
||||||
|
)
|
||||||
|
return Effect.flatMap(
|
||||||
|
Lens.get(lens),
|
||||||
|
value => Effect.flatMap(
|
||||||
|
Lens.set(lens, Option.some(100)),
|
||||||
|
() => Effect.map(SubscriptionRef.get(parent), parentValue => [value, parentValue] as const),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result[0]).toEqual(Option.some(84)) // 42 * 2
|
||||||
|
expect(result[1]).toEqual(Option.some(50)) // 100 / 2
|
||||||
|
})
|
||||||
|
|
||||||
|
test("mapOptionEffect transforms Some values with effects", async () => {
|
||||||
|
const result = await Effect.runPromise(
|
||||||
|
Effect.flatMap(
|
||||||
|
SubscriptionRef.make<Option.Option<number>>(Option.some(42)),
|
||||||
|
parent => {
|
||||||
|
const lens = Lens.mapOptionEffect(
|
||||||
|
Lens.fromSubscriptionRef(parent),
|
||||||
|
n => Effect.succeed(n * 2),
|
||||||
|
(_n, doubled) => Effect.succeed(doubled / 2),
|
||||||
|
)
|
||||||
|
return Effect.flatMap(
|
||||||
|
Lens.get(lens),
|
||||||
|
value => Effect.flatMap(
|
||||||
|
Lens.set(lens, Option.some(100)),
|
||||||
|
() => Effect.map(SubscriptionRef.get(parent), parentValue => [value, parentValue] as const),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result[0]).toEqual(Option.some(84)) // 42 * 2
|
||||||
|
expect(result[1]).toEqual(Option.some(50)) // 100 / 2
|
||||||
|
})
|
||||||
|
|
||||||
|
test("provideContext supplies a service to get and modify", async () => {
|
||||||
|
const result = await Effect.runPromise(
|
||||||
|
Effect.flatMap(
|
||||||
|
SubscriptionRef.make(10),
|
||||||
|
parent => {
|
||||||
|
const lens = Lens.provideContext(
|
||||||
|
Lens.mapEffect(
|
||||||
|
Lens.fromSubscriptionRef(parent),
|
||||||
|
n => Effect.map(Offset, ({ value }) => n + value),
|
||||||
|
(_n, next) => Effect.map(Offset, ({ value }) => next - value),
|
||||||
|
),
|
||||||
|
Context.make(Offset, { value: 5 }),
|
||||||
|
)
|
||||||
|
|
||||||
|
return Effect.flatMap(
|
||||||
|
Lens.get(lens),
|
||||||
|
value => Effect.flatMap(
|
||||||
|
Lens.set(lens, 30),
|
||||||
|
() => Effect.map(SubscriptionRef.get(parent), parentValue => [value, parentValue] as const),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result[0]).toBe(15)
|
||||||
|
expect(result[1]).toBe(25)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("Ref and SynchronizedRef adapters read and update their sources", async () => {
|
||||||
|
const result = await Effect.runPromise(Effect.gen(function*() {
|
||||||
|
const ref = yield* Ref.make(1)
|
||||||
|
const refLens = yield* Lens.fromRef(ref)
|
||||||
|
yield* Lens.update(refLens, n => n + 1)
|
||||||
|
|
||||||
|
const synchronizedRef = yield* SynchronizedRef.make(10)
|
||||||
|
const synchronizedLens = Lens.fromSynchronizedRef(synchronizedRef)
|
||||||
|
yield* Lens.updateEffect(synchronizedLens, n => Effect.succeed(n + 5))
|
||||||
|
|
||||||
|
return [yield* Ref.get(ref), yield* SynchronizedRef.get(synchronizedRef)] as const
|
||||||
|
}))
|
||||||
|
|
||||||
|
expect(result).toEqual([2, 15])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("modifyEffect updates are atomic under concurrency", async () => {
|
||||||
|
const iterations = 100
|
||||||
|
|
||||||
|
const result = await Effect.runPromise(Effect.flatMap(
|
||||||
|
SubscriptionRef.make({ count: 0 }),
|
||||||
|
parent => {
|
||||||
|
const countLens = Lens.focusObjectOn(Lens.fromSubscriptionRef(parent), "count")
|
||||||
|
|
||||||
|
return Effect.flatMap(
|
||||||
|
Effect.forEach(
|
||||||
|
Array.from({ length: iterations }),
|
||||||
|
() => Lens.updateEffect(
|
||||||
|
countLens,
|
||||||
|
count => Effect.as(Effect.yieldNow, count + 1),
|
||||||
|
),
|
||||||
|
{ concurrency: "unbounded", discard: true },
|
||||||
|
),
|
||||||
|
() => SubscriptionRef.get(parent),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
))
|
||||||
|
|
||||||
|
expect(result.count).toBe(iterations)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("unwrap delegates reads, writes, and locking to the inner lens", async () => {
|
||||||
|
const iterations = 100
|
||||||
|
|
||||||
|
const result = await Effect.runPromise(Effect.flatMap(
|
||||||
|
SubscriptionRef.make(0),
|
||||||
|
parent => {
|
||||||
|
const lens = Lens.unwrap(Effect.succeed(Lens.fromSubscriptionRef(parent)))
|
||||||
|
|
||||||
|
return Effect.flatMap(
|
||||||
|
Effect.forEach(
|
||||||
|
Array.from({ length: iterations }),
|
||||||
|
() => Lens.updateEffect(
|
||||||
|
lens,
|
||||||
|
count => Effect.as(Effect.yieldNow, count + 1),
|
||||||
|
),
|
||||||
|
{ concurrency: "unbounded", discard: true },
|
||||||
|
),
|
||||||
|
() => Effect.all([Lens.get(lens), SubscriptionRef.get(parent)] as const),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
))
|
||||||
|
|
||||||
|
expect(result).toEqual([iterations, iterations])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("focusObjectOn focuses a nested property without touching other fields", async () => {
|
||||||
|
const [initialCount, updatedState] = await Effect.runPromise(
|
||||||
|
Effect.flatMap(
|
||||||
|
SubscriptionRef.make({ count: 1, label: "original" }),
|
||||||
|
parent => {
|
||||||
|
const countLens = Lens.focusObjectOn(Lens.fromSubscriptionRef(parent), "count")
|
||||||
|
return Effect.flatMap(
|
||||||
|
Lens.get(countLens),
|
||||||
|
count => Effect.flatMap(
|
||||||
|
Lens.set(countLens, count + 5),
|
||||||
|
() => Effect.map(SubscriptionRef.get(parent), state => [count, state] as const),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(initialCount).toBe(1)
|
||||||
|
expect(updatedState).toEqual({ count: 6, label: "original" })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("focusObjectOnWritable preserves the root identity when mutating in place", async () => {
|
||||||
|
const original = { detail: "keep" }
|
||||||
|
const updated = await Effect.runPromise(
|
||||||
|
Effect.flatMap(
|
||||||
|
SubscriptionRef.make(original),
|
||||||
|
parent => {
|
||||||
|
const detailLens = Lens.focusObjectOnWritable(Lens.fromSubscriptionRef(parent), "detail")
|
||||||
|
return Effect.flatMap(
|
||||||
|
Lens.set(detailLens, "mutated"),
|
||||||
|
() => SubscriptionRef.get(parent),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(updated).toBe(original)
|
||||||
|
expect(updated.detail).toBe("mutated")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("focusArrayAt updates the selected index", async () => {
|
||||||
|
const updated = await Effect.runPromise(
|
||||||
|
Effect.flatMap(
|
||||||
|
SubscriptionRef.make([10, 20, 30]),
|
||||||
|
parent => {
|
||||||
|
const elementLens = Lens.focusArrayAt(Lens.fromSubscriptionRef(parent), 1)
|
||||||
|
return Effect.flatMap(
|
||||||
|
Lens.update(elementLens, value => value + 5),
|
||||||
|
() => SubscriptionRef.get(parent),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(updated).toEqual([10, 25, 30])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("focusMutableArrayAt mutates the array reference in place", async () => {
|
||||||
|
const original = ["foo", "bar"]
|
||||||
|
const updated = await Effect.runPromise(
|
||||||
|
Effect.flatMap(
|
||||||
|
SubscriptionRef.make(original),
|
||||||
|
parent => {
|
||||||
|
const elementLens = Lens.focusMutableArrayAt(Lens.fromSubscriptionRef(parent), 0)
|
||||||
|
return Effect.flatMap(
|
||||||
|
Lens.set(elementLens, "baz"),
|
||||||
|
() => SubscriptionRef.get(parent),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(updated).toBe(original)
|
||||||
|
expect(updated).toEqual(["baz", "bar"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("focusTupleAt updates the selected tuple index immutably", async () => {
|
||||||
|
const updated = await Effect.runPromise(
|
||||||
|
Effect.flatMap(
|
||||||
|
SubscriptionRef.make<readonly [string, string, string]>(["a", "b", "c"]),
|
||||||
|
parent => {
|
||||||
|
const elementLens = Lens.focusTupleAt(Lens.fromSubscriptionRef(parent), 1)
|
||||||
|
return Effect.flatMap(
|
||||||
|
Lens.set(elementLens, "updated"),
|
||||||
|
() => SubscriptionRef.get(parent),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(updated).toEqual(["a", "updated", "c"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("focusMutableTupleAt mutates the tuple reference in place", async () => {
|
||||||
|
const original: [string, string] = ["foo", "bar"]
|
||||||
|
const updated = await Effect.runPromise(
|
||||||
|
Effect.flatMap(
|
||||||
|
SubscriptionRef.make(original),
|
||||||
|
parent => {
|
||||||
|
const elementLens = Lens.focusMutableTupleAt(Lens.fromSubscriptionRef(parent), 0)
|
||||||
|
return Effect.flatMap(
|
||||||
|
Lens.set(elementLens, "baz"),
|
||||||
|
() => SubscriptionRef.get(parent),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(updated).toBe(original)
|
||||||
|
expect(updated).toEqual(["baz", "bar"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("focusChunkAt replaces the focused chunk element", async () => {
|
||||||
|
const updated = await Effect.runPromise(
|
||||||
|
Effect.flatMap(
|
||||||
|
SubscriptionRef.make(Chunk.make(1, 2, 3) as Chunk.Chunk<number>),
|
||||||
|
parent => {
|
||||||
|
const elementLens = Lens.focusChunkAt(Lens.fromSubscriptionRef(parent), 2)
|
||||||
|
return Effect.flatMap(
|
||||||
|
Lens.set(elementLens, 99),
|
||||||
|
() => SubscriptionRef.get(parent),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(Chunk.toReadonlyArray(updated)).toEqual([1, 2, 99])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("focusOption reads and writes the inner Some value", async () => {
|
||||||
|
const result = await Effect.runPromise(
|
||||||
|
Effect.flatMap(
|
||||||
|
SubscriptionRef.make<Option.Option<number>>(Option.some(42)),
|
||||||
|
parent => {
|
||||||
|
const lens = Lens.focusOption(Lens.fromSubscriptionRef(parent))
|
||||||
|
return Effect.flatMap(
|
||||||
|
Lens.get(lens),
|
||||||
|
value => Effect.flatMap(
|
||||||
|
Lens.set(lens, 100),
|
||||||
|
() => Effect.map(SubscriptionRef.get(parent), parentValue => [value, parentValue] as const),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result[0]).toBe(42)
|
||||||
|
expect(result[1]).toEqual(Option.some(100))
|
||||||
|
})
|
||||||
|
|
||||||
|
test("focusOption fails when the parent option is None", async () => {
|
||||||
|
const result = await Effect.runPromise(
|
||||||
|
Effect.flatMap(
|
||||||
|
SubscriptionRef.make<Option.Option<number>>(Option.none()),
|
||||||
|
parent => {
|
||||||
|
const lens = Lens.focusOption(Lens.fromSubscriptionRef(parent))
|
||||||
|
return Effect.all([
|
||||||
|
Effect.result(Lens.get(lens)),
|
||||||
|
Effect.result(Lens.set(lens, 100)),
|
||||||
|
SubscriptionRef.get(parent),
|
||||||
|
] as const)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result[0]._tag).toBe("Failure")
|
||||||
|
expect(result[1]._tag).toBe("Failure")
|
||||||
|
expect(result[2]).toEqual(Option.none())
|
||||||
|
})
|
||||||
|
|
||||||
|
test("modify and modifyEffect atomically update and return a result", async () => {
|
||||||
|
const result = await Effect.runPromise(Effect.gen(function*() {
|
||||||
|
const parent = yield* SubscriptionRef.make(1)
|
||||||
|
const lens = Lens.fromSubscriptionRef(parent)
|
||||||
|
const previous = yield* Lens.modify(lens, n => [`value:${ n }`, n + 1] as const)
|
||||||
|
const doubled = yield* lens.pipe(Lens.modifyEffect(n => Effect.succeed([n * 2, n + 2] as const)))
|
||||||
|
const current = yield* SubscriptionRef.get(parent)
|
||||||
|
return [previous, doubled, current] as const
|
||||||
|
}))
|
||||||
|
|
||||||
|
expect(result).toEqual(["value:1", 4, 4])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("conditional synchronous operations preserve values on None", async () => {
|
||||||
|
const result = await Effect.runPromise(Effect.gen(function*() {
|
||||||
|
const parent = yield* SubscriptionRef.make(1)
|
||||||
|
const lens = Lens.fromSubscriptionRef(parent)
|
||||||
|
const fallback = yield* Lens.modifySome(lens, () => ["fallback", Option.none()] as const)
|
||||||
|
const modified = yield* lens.pipe(Lens.modifySome(n => {
|
||||||
|
n satisfies number
|
||||||
|
return [`value:${ n }`, Option.some(n + 1)] as const
|
||||||
|
}))
|
||||||
|
const previous = yield* lens.pipe(Lens.getAndUpdateSome(n => {
|
||||||
|
n satisfies number
|
||||||
|
return Option.some(n + 1)
|
||||||
|
}))
|
||||||
|
yield* Lens.updateSome(lens, () => Option.none())
|
||||||
|
yield* lens.pipe(Lens.updateSome(n => {
|
||||||
|
n satisfies number
|
||||||
|
return Option.some(n + 1)
|
||||||
|
}))
|
||||||
|
const current = yield* Lens.updateSomeAndGet(lens, () => Option.none())
|
||||||
|
const updated = yield* lens.pipe(Lens.updateSomeAndGet(n => {
|
||||||
|
n satisfies number
|
||||||
|
return Option.some(n + 1)
|
||||||
|
}))
|
||||||
|
return [fallback, modified, previous, current, updated, yield* SubscriptionRef.get(parent)] as const
|
||||||
|
}))
|
||||||
|
|
||||||
|
expect(result).toEqual(["fallback", "value:1", 2, 4, 5, 5])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("conditional effectful operations preserve values on None", async () => {
|
||||||
|
const result = await Effect.runPromise(Effect.gen(function*() {
|
||||||
|
const parent = yield* SubscriptionRef.make(10)
|
||||||
|
const lens = Lens.fromSubscriptionRef(parent)
|
||||||
|
const modifyNone: Effect.Effect<string> = Lens.modifySomeEffect(
|
||||||
|
lens,
|
||||||
|
() => Effect.succeed(["fallback", Option.none()] as const),
|
||||||
|
)
|
||||||
|
const fallback = yield* modifyNone
|
||||||
|
const modify: Effect.Effect<string> = lens.pipe(Lens.modifySomeEffect(
|
||||||
|
n => {
|
||||||
|
n satisfies number
|
||||||
|
return Effect.succeed([`value:${ n }`, Option.some(n + 1)] as const)
|
||||||
|
},
|
||||||
|
))
|
||||||
|
const modified = yield* modify
|
||||||
|
const getAndUpdate: Effect.Effect<number> = lens.pipe(Lens.getAndUpdateSomeEffect(n => {
|
||||||
|
n satisfies number
|
||||||
|
return Effect.succeed(Option.some(n + 1))
|
||||||
|
}))
|
||||||
|
const getAndUpdateNone: Effect.Effect<number> = lens.pipe(Lens.getAndUpdateSomeEffect(() => Effect.succeed(Option.none())))
|
||||||
|
const updateNone: Effect.Effect<void> = lens.pipe(Lens.updateSomeEffect(() => Effect.succeed(Option.none())))
|
||||||
|
const update: Effect.Effect<void> = lens.pipe(Lens.updateSomeEffect(n => {
|
||||||
|
n satisfies number
|
||||||
|
return Effect.succeed(Option.some(n + 1))
|
||||||
|
}))
|
||||||
|
const previous = yield* getAndUpdate
|
||||||
|
const unchanged = yield* getAndUpdateNone
|
||||||
|
yield* updateNone
|
||||||
|
yield* update
|
||||||
|
const updateAndGetNone: Effect.Effect<number> = lens.pipe(Lens.updateSomeAndGetEffect(() => Effect.succeed(Option.none())))
|
||||||
|
const current = yield* updateAndGetNone
|
||||||
|
const updated = yield* lens.pipe(Lens.updateSomeAndGetEffect(n => {
|
||||||
|
n satisfies number
|
||||||
|
return Effect.succeed(Option.some(n + 1))
|
||||||
|
}))
|
||||||
|
return [fallback, modified, previous, unchanged, current, updated, yield* SubscriptionRef.get(parent)] as const
|
||||||
|
}))
|
||||||
|
|
||||||
|
expect(result).toEqual(["fallback", "value:10", 11, 12, 13, 14, 14])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("conditional updates do not publish when the next value is None", async () => {
|
||||||
|
const events = await Effect.runPromise(Effect.gen(function*() {
|
||||||
|
const parent = yield* SubscriptionRef.make(0)
|
||||||
|
const lens = Lens.fromSubscriptionRef(parent)
|
||||||
|
const fiber = yield* Effect.forkChild(Stream.runCollect(Stream.take(lens.changes, 2)))
|
||||||
|
|
||||||
|
yield* Effect.yieldNow
|
||||||
|
yield* Lens.updateSome(lens, () => Option.none())
|
||||||
|
yield* Lens.updateSome(lens, n => Option.some(n + 1))
|
||||||
|
|
||||||
|
return yield* Fiber.join(fiber)
|
||||||
|
}))
|
||||||
|
|
||||||
|
expect(events).toEqual([0, 1])
|
||||||
|
})
|
||||||
|
|
||||||
|
// test("changes stream emits updates when lens mutates state", async () => {
|
||||||
|
// const events = await Effect.runPromise(
|
||||||
|
// Effect.flatMap(
|
||||||
|
// SubscriptionRef.make({ count: 0 }),
|
||||||
|
// parent => {
|
||||||
|
// const lens = Lens.mapField(Lens.fromSubscriptionRef(parent), "count")
|
||||||
|
// return Effect.fork(Stream.runCollect(Stream.take(lens.changes, 2))).pipe(
|
||||||
|
// Effect.tap(Lens.set(lens, 1)),
|
||||||
|
// Effect.tap(Lens.set(lens, 1)),
|
||||||
|
// Effect.andThen(Fiber.join),
|
||||||
|
// Effect.map(Chunk.toReadonlyArray),
|
||||||
|
// )
|
||||||
|
// },
|
||||||
|
// ),
|
||||||
|
// )
|
||||||
|
|
||||||
|
// expect(events).toEqual([1, 2])
|
||||||
|
// })
|
||||||
|
|
||||||
|
// test("mapped changes stream can derive transformed values", async () => {
|
||||||
|
// const derived = await Effect.runPromise(
|
||||||
|
// Effect.flatMap(
|
||||||
|
// SubscriptionRef.make({ count: 10 }),
|
||||||
|
// parent => {
|
||||||
|
// const lens = Lens.mapField(Lens.fromSubscriptionRef(parent), "count")
|
||||||
|
// const transformed = Stream.map(lens.changes, count => `count:${ count }`)
|
||||||
|
// return Effect.scoped(() => Effect.flatMap(
|
||||||
|
// Effect.forkScoped(Stream.runCollect(Stream.take(transformed, 1))),
|
||||||
|
// fiber => Effect.flatMap(
|
||||||
|
// Lens.set(lens, 42),
|
||||||
|
// () => Effect.join(fiber),
|
||||||
|
// ),
|
||||||
|
// ))
|
||||||
|
// },
|
||||||
|
// ),
|
||||||
|
// )
|
||||||
|
|
||||||
|
// expect(derived).toEqual(["count:42"])
|
||||||
|
// })
|
||||||
|
})
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,66 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { Chunk, Effect, SubscriptionRef } from "effect"
|
||||||
|
import * as Subscribable from "./Subscribable.js"
|
||||||
|
|
||||||
|
|
||||||
|
describe("Subscribable", () => {
|
||||||
|
test("focusArrayLength reads the current array length and reflects updates", async () => {
|
||||||
|
const result = await Effect.runPromise(
|
||||||
|
Effect.flatMap(
|
||||||
|
SubscriptionRef.make([1, 2, 3]),
|
||||||
|
parent => {
|
||||||
|
const sizeSub = Subscribable.focusArrayLength(Subscribable.fromSubscriptionRef(parent))
|
||||||
|
return Effect.flatMap(
|
||||||
|
sizeSub.get,
|
||||||
|
initial => Effect.flatMap(
|
||||||
|
SubscriptionRef.set(parent, [1, 2, 3, 4, 5]),
|
||||||
|
() => Effect.map(sizeSub.get, next => [initial, next] as const),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result).toEqual([3, 5])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("focusChunkSize reads the current chunk size and reflects updates", async () => {
|
||||||
|
const result = await Effect.runPromise(
|
||||||
|
Effect.flatMap(
|
||||||
|
SubscriptionRef.make(Chunk.make(1, 2) as Chunk.Chunk<number>),
|
||||||
|
parent => {
|
||||||
|
const sizeSub = Subscribable.focusChunkSize(Subscribable.fromSubscriptionRef(parent))
|
||||||
|
return Effect.flatMap(
|
||||||
|
sizeSub.get,
|
||||||
|
initial => Effect.flatMap(
|
||||||
|
SubscriptionRef.set(parent, Chunk.make(1, 2, 3, 4)),
|
||||||
|
() => Effect.map(sizeSub.get, next => [initial, next] as const),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result).toEqual([2, 4])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("focusIterableSize also works for array values", async () => {
|
||||||
|
const result = await Effect.runPromise(
|
||||||
|
Effect.flatMap(
|
||||||
|
SubscriptionRef.make([1, 2, 3]),
|
||||||
|
parent => {
|
||||||
|
const sizeSub = Subscribable.focusIterableSize(Subscribable.fromSubscriptionRef(parent))
|
||||||
|
return Effect.flatMap(
|
||||||
|
sizeSub.get,
|
||||||
|
initial => Effect.flatMap(
|
||||||
|
SubscriptionRef.set(parent, [1, 2, 3, 4, 5]),
|
||||||
|
() => Effect.map(sizeSub.get, next => [initial, next] as const),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result).toEqual([3, 5])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import {
|
||||||
|
Array,
|
||||||
|
type Cause,
|
||||||
|
Chunk,
|
||||||
|
Effect,
|
||||||
|
Function,
|
||||||
|
Iterable,
|
||||||
|
Option,
|
||||||
|
Pipeable,
|
||||||
|
Predicate,
|
||||||
|
Stream,
|
||||||
|
SubscriptionRef,
|
||||||
|
} from "effect"
|
||||||
|
|
||||||
|
|
||||||
|
export const TypeId: unique symbol = Symbol.for("@effect-fc/Lens/v4/Subscribable")
|
||||||
|
export type TypeId = typeof TypeId
|
||||||
|
|
||||||
|
export interface Subscribable<in out A, in out E = never, in out R = never> extends Pipeable.Pipeable {
|
||||||
|
readonly [TypeId]: TypeId
|
||||||
|
readonly get: Effect.Effect<A, E, R>
|
||||||
|
readonly changes: Stream.Stream<A, E, R>
|
||||||
|
}
|
||||||
|
|
||||||
|
class SubscribableImpl<in out A, in out E, in out R>
|
||||||
|
extends Pipeable.Class implements Subscribable<A, E, R> {
|
||||||
|
readonly [TypeId]: TypeId = TypeId
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
readonly get: Effect.Effect<A, E, R>,
|
||||||
|
readonly changes: Stream.Stream<A, E, R>,
|
||||||
|
) {
|
||||||
|
super()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isSubscribable = (u: unknown): u is Subscribable<unknown, unknown, unknown> => Predicate.hasProperty(u, TypeId)
|
||||||
|
|
||||||
|
export const make = <A, E, R>(options: {
|
||||||
|
readonly get: Effect.Effect<A, E, R>
|
||||||
|
readonly changes: Stream.Stream<A, E, R>
|
||||||
|
}): Subscribable<A, E, R> => new SubscribableImpl(options.get, options.changes)
|
||||||
|
|
||||||
|
export const fromSubscriptionRef = <A>(self: SubscriptionRef.SubscriptionRef<A>): Subscribable<A> => make({
|
||||||
|
get: SubscriptionRef.get(self),
|
||||||
|
changes: SubscriptionRef.changes(self),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const map: {
|
||||||
|
<A, B>(f: (a: NoInfer<A>) => B): <E, R>(self: Subscribable<A, E, R>) => Subscribable<B, E, R>
|
||||||
|
<A, E, R, B>(self: Subscribable<A, E, R>, f: (a: NoInfer<A>) => B): Subscribable<B, E, R>
|
||||||
|
} = Function.dual(2, <A, E, R, B>(self: Subscribable<A, E, R>, f: (a: NoInfer<A>) => B) => make({
|
||||||
|
get: Effect.map(self.get, f),
|
||||||
|
changes: Stream.map(self.changes, f),
|
||||||
|
}))
|
||||||
|
|
||||||
|
export const mapEffect: {
|
||||||
|
<A, B, E2, R2>(f: (a: NoInfer<A>) => Effect.Effect<B, E2, R2>): <E, R>(self: Subscribable<A, E, R>) => Subscribable<B, E | E2, R | R2>
|
||||||
|
<A, E, R, B, E2, R2>(self: Subscribable<A, E, R>, f: (a: NoInfer<A>) => Effect.Effect<B, E2, R2>): Subscribable<B, E | E2, R | R2>
|
||||||
|
} = Function.dual(2, <A, E, R, B, E2, R2>(
|
||||||
|
self: Subscribable<A, E, R>,
|
||||||
|
f: (a: NoInfer<A>) => Effect.Effect<B, E2, R2>,
|
||||||
|
) => make({
|
||||||
|
get: Effect.flatMap(self.get, f),
|
||||||
|
changes: Stream.mapEffect(self.changes, f),
|
||||||
|
}))
|
||||||
|
|
||||||
|
export const unwrap = <A, E, R, E1, R1>(
|
||||||
|
effect: Effect.Effect<Subscribable<A, E, R>, E1, R1>,
|
||||||
|
): Subscribable<A, E | E1, R | R1> => make({
|
||||||
|
get: Effect.flatMap(effect, self => self.get),
|
||||||
|
changes: Stream.unwrap(Effect.map(effect, self => self.changes)),
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Maps over an `Option` value in the `Subscribable`. */
|
||||||
|
export const mapOption: {
|
||||||
|
<A, B>(f: (a: A) => B): <E, R>(self: Subscribable<Option.Option<A>, E, R>) => Subscribable<Option.Option<B>, E, R>
|
||||||
|
<A, B, E, R>(self: Subscribable<Option.Option<A>, E, R>, f: (a: A) => B): Subscribable<Option.Option<B>, E, R>
|
||||||
|
} = Function.dual(2, <A, B, E, R>(self: Subscribable<Option.Option<A>, E, R>, f: (a: A) => B) =>
|
||||||
|
map(self, Option.map(f)),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Maps over an `Option` value in the `Subscribable` with an Effect. */
|
||||||
|
export const mapOptionEffect: {
|
||||||
|
<A, B, E2, R2>(f: (a: A) => Effect.Effect<B, E2, R2>): <E, R>(self: Subscribable<Option.Option<A>, E, R>) => Subscribable<Option.Option<B>, E | E2, R | R2>
|
||||||
|
<A, B, E, R, E2, R2>(self: Subscribable<Option.Option<A>, E, R>, f: (a: A) => Effect.Effect<B, E2, R2>): Subscribable<Option.Option<B>, E | E2, R | R2>
|
||||||
|
} = Function.dual(2, <A, B, E, R, E2, R2>(
|
||||||
|
self: Subscribable<Option.Option<A>, E, R>,
|
||||||
|
f: (a: A) => Effect.Effect<B, E2, R2>,
|
||||||
|
) => mapEffect(self, Option.match({
|
||||||
|
onSome: a => Effect.map(f(a), Option.some),
|
||||||
|
onNone: () => Effect.succeed(Option.none()),
|
||||||
|
})))
|
||||||
|
|
||||||
|
/** Narrows the focus to a field of an object. */
|
||||||
|
export const focusObjectOn: {
|
||||||
|
<A extends object, K extends keyof A>(key: K): <E, R>(self: Subscribable<A, E, R>) => Subscribable<A[K], E, R>
|
||||||
|
<A extends object, K extends keyof A, E, R>(self: Subscribable<A, E, R>, key: K): Subscribable<A[K], E, R>
|
||||||
|
} = Function.dual(2, <A extends object, K extends keyof A, E, R>(self: Subscribable<A, E, R>, key: K) =>
|
||||||
|
map(self, a => a[key]),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Narrows the focus to an indexed element of an array. */
|
||||||
|
export const focusArrayAt: {
|
||||||
|
<A extends readonly any[]>(index: number): <E, R>(self: Subscribable<A, E, R>) => Subscribable<A[number], E | Cause.NoSuchElementError, R>
|
||||||
|
<A extends readonly any[], E, R>(self: Subscribable<A, E, R>, index: number): Subscribable<A[number], E | Cause.NoSuchElementError, R>
|
||||||
|
} = Function.dual(2, <A extends readonly any[], E, R>(self: Subscribable<A, E, R>, index: number) =>
|
||||||
|
mapEffect(self, a => Effect.fromOption(Array.get(a, index))),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const focusArrayLength = <A extends readonly any[], E, R>(
|
||||||
|
self: Subscribable<A, E, R>,
|
||||||
|
): Subscribable<number, E, R> => map(self, Array.length)
|
||||||
|
|
||||||
|
/** Narrows the focus to an indexed element of a readonly tuple. */
|
||||||
|
export const focusTupleAt: {
|
||||||
|
<T extends readonly [any, ...any[]], I extends number>(index: I): <E, R>(self: Subscribable<T, E, R>) => Subscribable<T[I], E, R>
|
||||||
|
<T extends readonly [any, ...any[]], I extends number, E, R>(self: Subscribable<T, E, R>, index: I): Subscribable<T[I], E, R>
|
||||||
|
} = Function.dual(2, <T extends readonly [any, ...any[]], I extends number, E, R>(self: Subscribable<T, E, R>, index: I) =>
|
||||||
|
map(self, Array.getUnsafe(index)),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Narrows the focus to an indexed element of `Chunk`. */
|
||||||
|
export const focusChunkAt: {
|
||||||
|
<A>(index: number): <E, R>(self: Subscribable<Chunk.Chunk<A>, E, R>) => Subscribable<A, E | Cause.NoSuchElementError, R>
|
||||||
|
<A, E, R>(self: Subscribable<Chunk.Chunk<A>, E, R>, index: number): Subscribable<A, E | Cause.NoSuchElementError, R>
|
||||||
|
} = Function.dual(2, <A, E, R>(self: Subscribable<Chunk.Chunk<A>, E, R>, index: number) =>
|
||||||
|
mapEffect(self, chunk => Effect.fromOption(Chunk.get(chunk, index))),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const focusChunkSize = <A, E, R>(
|
||||||
|
self: Subscribable<Chunk.Chunk<A>, E, R>,
|
||||||
|
): Subscribable<number, E, R> => map(self, Chunk.size)
|
||||||
|
|
||||||
|
export const focusIterableSize = <A extends Iterable<any>, E, R>(
|
||||||
|
self: Subscribable<A, E, R>,
|
||||||
|
): Subscribable<number, E, R> => map(self, Iterable.size)
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * as Lens from "./Lens.js"
|
||||||
|
export * as Subscribable from "./Subscribable.js"
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"exclude": ["**/*.test.ts", "**/*.spec.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
// Enable latest features
|
||||||
|
"lib": ["ESNext", "DOM"],
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"types": ["bun"],
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
// Bundler mode
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
// "allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
// "noEmit": true,
|
||||||
|
|
||||||
|
// Best practices
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
|
||||||
|
// Some stricter flags (disabled by default)
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noPropertyAccessFromIndexSignature": false,
|
||||||
|
|
||||||
|
// Build
|
||||||
|
"rootDir": "./src",
|
||||||
|
"outDir": "./dist",
|
||||||
|
"declaration": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
|
||||||
|
"plugins": [
|
||||||
|
{ "name": "@effect/language-service" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
"include": ["./src"],
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user