From 3618d49d68e8f573f1c01ba12fe75c992898750f Mon Sep 17 00:00:00 2001 From: Chase Moskal Date: Sun, 3 May 2026 15:45:21 -0700 Subject: [PATCH 01/18] nova: setting forth a new vision --- s/nova/primitives/signal/signal.ts | 0 s/nova/tracker/index.ts | 3 + s/nova/tracker/test.ts | 129 +++++++++++++++++++++++++++++ s/nova/tracker/tracker.ts | 92 ++++++++++++++++++++ s/test.ts | 15 +--- 5 files changed, 228 insertions(+), 11 deletions(-) create mode 100644 s/nova/primitives/signal/signal.ts create mode 100644 s/nova/tracker/index.ts create mode 100644 s/nova/tracker/test.ts create mode 100644 s/nova/tracker/tracker.ts diff --git a/s/nova/primitives/signal/signal.ts b/s/nova/primitives/signal/signal.ts new file mode 100644 index 0000000..e69de29 diff --git a/s/nova/tracker/index.ts b/s/nova/tracker/index.ts new file mode 100644 index 0000000..3388e88 --- /dev/null +++ b/s/nova/tracker/index.ts @@ -0,0 +1,3 @@ + +export * from "./tracker.js" + diff --git a/s/nova/tracker/test.ts b/s/nova/tracker/test.ts new file mode 100644 index 0000000..b4c46a9 --- /dev/null +++ b/s/nova/tracker/test.ts @@ -0,0 +1,129 @@ + +import {Tracker} from "../tracker.js" +import {science, test, expect} from "@e280/science" + +export default science.suite({ + "subscriptions": test(async() => { + const tracker = new Tracker() + let alpha = 0 + let bravo = 0 + const item = {} + const alphaOff = tracker.subscribe(item, () => alpha++) + tracker.subscribe(item, () => bravo++) + + tracker.write(item) + expect(alpha).is(1) + expect(bravo).is(1) + + tracker.write(item) + expect(alpha).is(2) + expect(bravo).is(2) + + alphaOff() + tracker.write(item) + expect(alpha).is(2) + expect(bravo).is(3) + }), + + "observe": test(async() => { + const tracker = new Tracker() + const item = {} + + const {seen, ret} = tracker.observe(() => { + tracker.read(item) + return 123 + }) + + expect(seen.has(item)).is(true) + expect(ret).is(123) + }), + + "nested observe layers are isolated": test(async() => { + const tracker = new Tracker() + const outer = {} + const inner = {} + + const observed = tracker.observe(() => { + tracker.read(outer) + + const nested = tracker.observe(() => { + tracker.read(inner) + }) + + expect(nested.seen.has(inner)).is(true) + expect(nested.seen.has(outer)).is(false) + }) + + expect(observed.seen.has(outer)).is(true) + expect(observed.seen.has(inner)).is(false) + }), + + "batch dedupes subscriber calls": test(async() => { + const tracker = new Tracker() + let called = 0 + const item = {} + + tracker.subscribe(item, () => called++) + + tracker.batch(() => { + tracker.write(item) + tracker.write(item) + tracker.write(item) + }) + + expect(called).is(1) + }), + + "nested batch flushes only once": test(async() => { + const tracker = new Tracker() + let called = 0 + const item = {} + + tracker.subscribe(item, () => called++) + + tracker.batch(() => { + tracker.write(item) + + tracker.batch(() => { + tracker.write(item) + expect(called).is(0) + }) + + expect(called).is(0) + }) + + expect(called).is(1) + }), + + "batch flushes cascading writes in waves": test(async() => { + const tracker = new Tracker() + const a = {} + const b = {} + const calls: string[] = [] + + tracker.subscribe(a, () => { + calls.push("a") + tracker.write(b) + }) + + tracker.subscribe(b, () => { + calls.push("b") + }) + + tracker.batch(() => { + tracker.write(a) + }) + + expect(calls.join(",")).is("a,b") + }), + + "circularity is forbidden": test(async() => { + const tracker = new Tracker() + const item = {} + + const fn = () => tracker.write(item) + tracker.subscribe(item, fn) + + expect(() => tracker.write(item)).throws() + }), +}) diff --git a/s/nova/tracker/tracker.ts b/s/nova/tracker/tracker.ts new file mode 100644 index 0000000..beafdbd --- /dev/null +++ b/s/nova/tracker/tracker.ts @@ -0,0 +1,92 @@ + +import {GWeakMap} from "@e280/stz" + +export type TrackableItem = object | symbol + +/** + * reactivity integration hub + */ +export class Tracker { + #busy = new Set<() => void>() + #observationLayers: Set[] = [] + #subscriptions = new GWeakMap void>>() + + #batchDepth = 0 + #batchPending = new Set<() => void>() + + /** indicate to observers that this item was accessed */ + read(item: Item) { + const top = this.#observationLayers.at(-1) + top?.add(item) + } + + /** invoke all subscriptions for this item */ + write(item: Item) { + const fns = this.#subscriptions.get(item) + if (!fns) return + + for (const fn of fns) + this.#batchPending.add(fn) + + if (this.#batchDepth === 0) + this.#flush() + } + + /** collect items that were read during fn */ + observe(fn: () => R) { + const seen = new Set() + this.#observationLayers.push(seen) + try { + const ret = fn() + return {seen, ret} + } + finally { + this.#observationLayers.pop() + } + } + + /** fn will be called when item changes */ + subscribe(item: Item, fn: () => void) { + const fns = this.#subscriptions.guarantee(item, () => new Set()) + fns.add(fn) + return () => { + fns.delete(fn) + if (fns.size === 0) + this.#subscriptions.delete(item) + } + } + + batch = (fn: () => R) => { + this.#batchDepth++ + try { + return fn() + } + finally { + this.#batchDepth-- + if (this.#batchDepth === 0) + this.#flush() + } + } + + #run(fn: () => void) { + if (this.#busy.has(fn)) + throw new Error("circularity forbidden") + this.#busy.add(fn) + try { fn() } + finally { this.#busy.delete(fn) } + } + + #flush() { + while (this.#batchPending.size > 0) { + const pending = [...this.#batchPending] + this.#batchPending.clear() + + for (const fn of pending) + this.#run(fn) + } + } +} + +/** standard global tracker for integrations */ +export const tracker: Tracker = (globalThis as any)[Symbol.for("e280.tracker.2")] ??= new Tracker() + diff --git a/s/test.ts b/s/test.ts index b3e1e94..9f4bc36 100644 --- a/s/test.ts +++ b/s/test.ts @@ -1,15 +1,8 @@ -import {Science} from "@e280/science" +import {science} from "@e280/science" +import nova from "./nova/tracker/test.js" -import prism from "./prism/test.js" -import signals from "./signals/test.js" -import tracker from "./tracker/test.js" -import wait from "./wait/test.js" - -await Science.run({ - prism, - signals, - tracker, - wait, +await science.run({ + nova, }) From 86a83bf5d0e654e3217abba05341e32e12e3dbbb Mon Sep 17 00:00:00 2001 From: Chase Moskal Date: Sun, 3 May 2026 16:59:00 -0700 Subject: [PATCH 02/18] sketch: nova signals, effect, derived --- s/nova/core/derived.ts | 50 ++++++++++++++++++++++++ s/nova/core/effect.ts | 28 +++++++++++++ s/nova/core/signal.ts | 22 +++++++++++ s/nova/core/test.ts | 63 ++++++++++++++++++++++++++++++ s/nova/core/utils/watch.ts | 10 +++++ s/nova/primitives/signal/signal.ts | 0 s/nova/tracker/test.ts | 22 +++++++++-- s/nova/tracker/tracker.ts | 10 ++--- s/test.ts | 6 ++- 9 files changed, 201 insertions(+), 10 deletions(-) create mode 100644 s/nova/core/derived.ts create mode 100644 s/nova/core/effect.ts create mode 100644 s/nova/core/signal.ts create mode 100644 s/nova/core/test.ts create mode 100644 s/nova/core/utils/watch.ts delete mode 100644 s/nova/primitives/signal/signal.ts diff --git a/s/nova/core/derived.ts b/s/nova/core/derived.ts new file mode 100644 index 0000000..e82efe4 --- /dev/null +++ b/s/nova/core/derived.ts @@ -0,0 +1,50 @@ + +import {watch} from "./utils/watch.js" +import {tracker} from "../tracker/tracker.js" + +export type Derived = { + (): Value + dispose: () => void +} + +export function derived(fn: () => Value): Derived { + let value!: Value + let dirty = true + let unwatch = () => {} + + const dispose = () => { + unwatch() + unwatch = () => {} + dirty = true + } + + const invalidate = () => { + if (!dirty) { + dirty = true + tracker.write(d) + } + } + + const compute = () => { + const watched = watch(fn, invalidate) + + unwatch() + unwatch = watched.dispose + + value = watched.value + dirty = false + } + + function d() { + tracker.read(d) + + if (dirty) + compute() + + return value + } + + d.dispose = dispose + return d +} + diff --git a/s/nova/core/effect.ts b/s/nova/core/effect.ts new file mode 100644 index 0000000..9afbd00 --- /dev/null +++ b/s/nova/core/effect.ts @@ -0,0 +1,28 @@ + +import {watch} from "./utils/watch.js" + +export function effect( + collector: () => Collected, + responder: (collected: Collected) => void = () => {}, + ) { + + let unwatch = () => {} + + const dispose = () => { + unwatch() + unwatch = () => {} + } + + const update = () => { + dispose() + const watched = watch(collector, update) + unwatch = watched.dispose + responder(watched.value) + } + + const watched = watch(collector, update) + unwatch = watched.dispose + + return dispose +} + diff --git a/s/nova/core/signal.ts b/s/nova/core/signal.ts new file mode 100644 index 0000000..f93a099 --- /dev/null +++ b/s/nova/core/signal.ts @@ -0,0 +1,22 @@ + +import {tracker} from "../tracker/tracker.js" + +export type Signal = { + (): Value + (value: Value): Value +} + +export function signal(value: Value): Signal { + return function sig() { + if (arguments.length === 0) { + tracker.read(sig) + return value + } + else { + value = arguments[0] + tracker.write(sig) + return value + } + } +} + diff --git a/s/nova/core/test.ts b/s/nova/core/test.ts new file mode 100644 index 0000000..0bf3c75 --- /dev/null +++ b/s/nova/core/test.ts @@ -0,0 +1,63 @@ + +import {expect, science, test} from "@e280/science" +import {effect} from "./effect.js" +import {signal} from "./signal.js" +import {tracker} from "../tracker/tracker.js" + +export default science.suite({ + "effect": test(async() => { + const item = {} + let calls = 0 + const stop = effect(() => tracker.read(item), () => calls++) + expect(calls).is(0) + + tracker.write(item) + expect(calls).is(1) + + stop() + tracker.write(item) + expect(calls).is(1) + }), + + "signal read/write": test(async() => { + const $count = signal(1) + expect($count()).is(1) + $count(2) + expect($count()).is(2) + }), + + "signal triggers effects": test(async() => { + const $count = signal(1) + let calls = 0 + effect(() => $count(), () => calls++) + expect(calls).is(0) + $count(2) + expect(calls).is(1) + }), + + "direct circularity forbidden": test(async() => { + const $count = signal(1) + effect(() => $count(), count => $count(count + 1)) + expect(() => $count(2)).throws() + }), + + "indirect effect circular write is forbidden": test(async() => { + const $alpha = signal(1) + const $bravo = signal(1) + let calls = 0 + + effect(() => $alpha(), a => { + calls++ + $bravo(a + 1) + }) + + effect(() => $bravo(), b => { + calls++ + $alpha(b + 1) + }) + + expect(() => $alpha(2)).throws() + expect(calls).is(2) + }), +}) + diff --git a/s/nova/core/utils/watch.ts b/s/nova/core/utils/watch.ts new file mode 100644 index 0000000..d66b387 --- /dev/null +++ b/s/nova/core/utils/watch.ts @@ -0,0 +1,10 @@ + +import {tracker} from "../../tracker/tracker.js" + +export function watch(collector: () => Value, onChange: () => void) { + const {seen, value} = tracker.observe(collector) + const disposers = [...seen].map(item => tracker.subscribe(item, onChange)) + const dispose = () => disposers.forEach(d => d()) + return {value, dispose} +} + diff --git a/s/nova/primitives/signal/signal.ts b/s/nova/primitives/signal/signal.ts deleted file mode 100644 index e69de29..0000000 diff --git a/s/nova/tracker/test.ts b/s/nova/tracker/test.ts index b4c46a9..ad59c12 100644 --- a/s/nova/tracker/test.ts +++ b/s/nova/tracker/test.ts @@ -1,5 +1,5 @@ -import {Tracker} from "../tracker.js" +import {Tracker} from "./tracker.js" import {science, test, expect} from "@e280/science" export default science.suite({ @@ -29,13 +29,13 @@ export default science.suite({ const tracker = new Tracker() const item = {} - const {seen, ret} = tracker.observe(() => { + const {seen, value} = tracker.observe(() => { tracker.read(item) return 123 }) expect(seen.has(item)).is(true) - expect(ret).is(123) + expect(value).is(123) }), "nested observe layers are isolated": test(async() => { @@ -126,4 +126,20 @@ export default science.suite({ expect(() => tracker.write(item)).throws() }), + + "same subscriber rescheduled during flush is forbidden": test(async() => { + const tracker = new Tracker() + const item = {} + let calls = 0 + + const fn = () => { + calls++ + tracker.write(item) + } + + tracker.subscribe(item, fn) + + expect(() => tracker.write(item)).throws() + expect(calls).is(1) + }), }) diff --git a/s/nova/tracker/tracker.ts b/s/nova/tracker/tracker.ts index beafdbd..b3debdc 100644 --- a/s/nova/tracker/tracker.ts +++ b/s/nova/tracker/tracker.ts @@ -1,12 +1,12 @@ import {GWeakMap} from "@e280/stz" -export type TrackableItem = object | symbol +export type Trackable = object | symbol /** * reactivity integration hub */ -export class Tracker { +export class Tracker { #busy = new Set<() => void>() #observationLayers: Set[] = [] #subscriptions = new GWeakMap void>>() @@ -33,12 +33,12 @@ export class Tracker { } /** collect items that were read during fn */ - observe(fn: () => R) { + observe(fn: () => Value) { const seen = new Set() this.#observationLayers.push(seen) try { - const ret = fn() - return {seen, ret} + const value = fn() + return {seen, value} } finally { this.#observationLayers.pop() diff --git a/s/test.ts b/s/test.ts index 9f4bc36..a902fdb 100644 --- a/s/test.ts +++ b/s/test.ts @@ -1,8 +1,10 @@ import {science} from "@e280/science" -import nova from "./nova/tracker/test.js" +import core from "./nova/core/test.js" +import tracker from "./nova/tracker/test.js" await science.run({ - nova, + tracker, + core, }) From 74cfeb7299f9ca8e8f834b90627a372a13aa414c Mon Sep 17 00:00:00 2001 From: Chase Moskal Date: Sun, 3 May 2026 17:14:21 -0700 Subject: [PATCH 03/18] improve: testing, export batch --- s/nova/core/batch.ts | 5 +++ s/nova/core/index.ts | 6 ++++ s/nova/core/test.ts | 75 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+) create mode 100644 s/nova/core/batch.ts create mode 100644 s/nova/core/index.ts diff --git a/s/nova/core/batch.ts b/s/nova/core/batch.ts new file mode 100644 index 0000000..dc399dc --- /dev/null +++ b/s/nova/core/batch.ts @@ -0,0 +1,5 @@ + +import {tracker} from "../tracker/tracker.js" + +export const batch = tracker.batch + diff --git a/s/nova/core/index.ts b/s/nova/core/index.ts new file mode 100644 index 0000000..7b1d74d --- /dev/null +++ b/s/nova/core/index.ts @@ -0,0 +1,6 @@ + +export * from "./batch.js" +export * from "./derived.js" +export * from "./effect.js" +export * from "./signal.js" + diff --git a/s/nova/core/test.ts b/s/nova/core/test.ts index 0bf3c75..ad5042e 100644 --- a/s/nova/core/test.ts +++ b/s/nova/core/test.ts @@ -1,7 +1,9 @@ import {expect, science, test} from "@e280/science" +import {batch} from "./batch.js" import {effect} from "./effect.js" import {signal} from "./signal.js" +import {derived} from "./derived.js" import {tracker} from "../tracker/tracker.js" export default science.suite({ @@ -35,6 +37,79 @@ export default science.suite({ expect(calls).is(1) }), + "shorthand effect syntax": test(async() => { + const $alpha = signal(2) + const $bravo = signal(10) + const calls: number[] = [] + effect(() => calls.push($alpha() * $bravo())) + expect(calls.length).is(1) + $alpha(3) + expect(calls.length).is(2) + expect(calls.at(-1)!).is(30) + }), + + "derived": test(async() => { + const $alpha = signal(2) + const $bravo = signal(10) + const $derived = derived(() => $alpha() * $bravo()) + expect($derived()).is(20) + $alpha(3) + expect($derived()).is(30) + }), + + "derived is lazy": test(async() => { + const $alpha = signal(2) + const $bravo = signal(10) + let calls = 0 + const $derived = derived(() => { + calls++ + return $alpha() * $bravo() + }) + expect(calls).is(0) + expect($derived()).is(20) + expect(calls).is(1) + expect($derived()).is(20) + expect(calls).is(1) + $alpha(3) + $alpha(4) + expect(calls).is(1) + expect($derived()).is(40) + expect(calls).is(2) + }), + + "derived triggers effects": test(async() => { + const $alpha = signal(2) + const $bravo = signal(10) + let derivedCalls = 0 + let effectCalls = 0 + const $derived = derived(() => { + derivedCalls++ + return $alpha() * $bravo() + }) + effect(() => $derived(), () => effectCalls++) + expect(derivedCalls).is(1) + expect(effectCalls).is(0) + $alpha(3) + expect(derivedCalls).is(2) + expect(effectCalls).is(1) + }), + + "batching signal effects seems to work": test(async() => { + const $alpha = signal(2) + const $bravo = signal(10) + let calls: number[] = [] + effect(() => $alpha() * $bravo(), value => calls.push(value)) + batch(() => { + $alpha(3) + $alpha(4) + $alpha(5) + $bravo(1) + $bravo(11) + }) + expect(calls.length).is(1) + expect(calls[0]).is(55) + }), + "direct circularity forbidden": test(async() => { const $count = signal(1) effect(() => $count(), count => $count(count + 1)) From f0cd1a67e0be24b1bd1283ab237ace0ccc9b40f0 Mon Sep 17 00:00:00 2001 From: Chase Moskal Date: Sun, 3 May 2026 17:19:39 -0700 Subject: [PATCH 04/18] port: react bindings --- s/nova/bindings/index.ts | 3 +++ s/nova/bindings/react.ts | 52 ++++++++++++++++++++++++++++++++++++++++ s/nova/index.ts | 5 ++++ 3 files changed, 60 insertions(+) create mode 100644 s/nova/bindings/index.ts create mode 100644 s/nova/bindings/react.ts create mode 100644 s/nova/index.ts diff --git a/s/nova/bindings/index.ts b/s/nova/bindings/index.ts new file mode 100644 index 0000000..cc870e2 --- /dev/null +++ b/s/nova/bindings/index.ts @@ -0,0 +1,3 @@ + +export * from "./react.js" + diff --git a/s/nova/bindings/react.ts b/s/nova/bindings/react.ts new file mode 100644 index 0000000..9b5a751 --- /dev/null +++ b/s/nova/bindings/react.ts @@ -0,0 +1,52 @@ + +import {signal} from "../core/signal.js" +import {derived} from "../core/derived.js" +import {tracker} from "../tracker/tracker.js" + +export function react(react: { + useEffect: (fn: () => void | (() => void), deps?: unknown[]) => void + useState: (x: X | (() => X)) => [ + value: X, + set: (value: X | ((x: X) => X)) => void + ] + }) { + + const useTracker = (fn: () => X) => { + const [, setTick] = react.useState(0) + const {seen, value} = tracker.observe(fn) + + react.useEffect(() => { + const rerender = () => setTick(tick => tick + 1) + const stoppers = [...seen].map(item => tracker.subscribe(item, rerender)) + return () => stoppers.forEach(stop => stop()) + }) + + return value + } + + const component =

(render: (props: P) => R) => { + const c = (props: P) => useTracker(() => render(props)) + c.displayName = (render as any).displayName ?? render.name ?? "Component" + return c + } + + const useOnce = (fn: () => X) => { + const [value] = react.useState(fn) + return value + } + + const useSignal = (value: X) => { + const $signal = useOnce(() => signal(value)) + void useTracker(() => $signal()) + return $signal + } + + const useDerived = (formula: () => X) => { + const $derived = useOnce(() => derived(formula)) + void useTracker(() => $derived()) + return $derived + } + + return {component, useTracker, useOnce, useSignal, useDerived} +} + diff --git a/s/nova/index.ts b/s/nova/index.ts new file mode 100644 index 0000000..cd17b03 --- /dev/null +++ b/s/nova/index.ts @@ -0,0 +1,5 @@ + +export * from "./bindings/index.js" +export * from "./core/index.js" +export * from "./tracker/index.js" + From f2a52204f149926b43235e5b0b00aed4a4564be5 Mon Sep 17 00:00:00 2001 From: Chase Moskal Date: Sun, 3 May 2026 18:15:21 -0700 Subject: [PATCH 05/18] write: new readme, various tweakums --- s/nova/README.md | 202 +++++++++++++++++++++++++++++++++++++++ s/nova/bindings/react.ts | 12 +-- s/nova/core/derived.ts | 6 +- s/nova/core/index.ts | 1 + s/nova/core/signal.ts | 6 +- s/nova/core/types.ts | 13 +++ 6 files changed, 224 insertions(+), 16 deletions(-) create mode 100644 s/nova/README.md create mode 100644 s/nova/core/types.ts diff --git a/s/nova/README.md b/s/nova/README.md new file mode 100644 index 0000000..541ff86 --- /dev/null +++ b/s/nova/README.md @@ -0,0 +1,202 @@ + +![](https://i.imgur.com/h7FohWa.jpeg) + + + +

+ +# ⛏️ strata + +### get in loser, we're managing state +📦 `npm install @e280/strata` +✨ it's basically about automagically rerendering ui when data changes +🦝 powers auto-reactivity in our view library [@e280/sly](https://github.com/e280/sly) +🧙‍♂️ probably my tenth state management library, lol +🧑‍💻 a project by https://e280.org/ + +🚦 [**#signals,**](#signals) sweet little bundles of state +🪄 [**#tracker,**](#tracker) agnostic reactivity integration hub +⚛️ [**#react,**](#react) optional bindings for react + + + +

+ + + +## 🍋 strata signals +> *reactive bundles of joy* + +```ts +import {signal, derived, effect, batch} from "@e280/strata" +``` + +### 🚦 signal +- **a signal holds a value** + ```ts + const $count = signal(0) + ``` + > *we kinda like the `$` convention for signals* +- **read the signal** + ```ts + $count() // 0 + ``` +- **write the signal** + ```ts + $count(1) + ``` + +### 🚦 derived +- **combine signals like a formula** + ```ts + const $alpha = signal(1) + const $bravo = signal(10) + const $product = derived(() => $alpha() * $bravo()) +- **it automatically updates** + ```ts + $product() // 10 + $alpha(2) + $product() // 🪄 20 + ``` +- **btw,** + deriveds are lazy and don't run the formula fn unless actually demanded. also, they have a `.dispose()` fn you can use to stop them. + +### 🚦 effects +- **effects run when the relevant signals change** + ```ts + effect(() => console.log($count())) + // 1 + // the system detects '$count' is relevant + + $count(2) + // 2 + // when $count is changed, the effect fn is run + ``` +- **btw,** + effects return a dispose fn you can use to stop them. also, you can optionally pass a second parameter fn that receives what the first fn returns, and it's not initially called. + +### 🚦 types +- **`Signal`** — it's a signal fn +- **`Derived`** — it's a derived fn +- **`Valuable`** — could be `Signal` or `Derived` + + + +

+ + + +## 🍋 strata tracker +> *reactivity integration hub* + +```ts +import {tracker} from "@e280/strata/tracker" +``` + +this is the inner sanctum of strata. use the tracker to jack into the reactivity system, you can make anything fully strata-compatible and you'll be reactin' and triggerin' with the best of 'em. the tracker is also what you'll need if you're trying to create bindings for your own frontend framework to trigger your ui to rerender and stuff. + +### 🪄 invent your own novel state concept +- let's invent a very simple thing, so you can see how simple the tracker really is. + ```ts + export class BoomerSignal { + constructor(private value: Value) {} + + get() { + tracker.read(this) // 🪄 inform tracker our thing was accessed + return this.value + } + + set(value: Value) { + this.value = value + tracker.write(this) // 🪄 inform tracker our thing was changed + } + } + ``` +- boom, that's it! now we have a new reactive thing we can use, and it'll rerender our ui or whatever. + ```ts + const $count = new BoomerSignal(1) + + effect(() => console.log($count.get())) + // 1 + + $count.set(2) + // 2 + ``` + +### 🪄 integrate your ui framework for auto-rerendering +- put on your big-kid pants and have your ai agent read the [source code](./tracker/tracker.ts) +- use `tracker.observe` to check what is touched by a fn +- use `tracker.subscribe` to subscribe to the seen items that `observe` returns +- you'll figure it out, lol, or reach out to me on discord + + + +

+ + + +## 🍋 react bindings + +```ts +``` + +### ⚛️ setup your `strata.ts` module +```ts +import * as react from "react" +import {reactBindings} from "@e280/strata" + +export const { + component, + useTracked, + useOnce, + useSignal, + useDerived, +} = reactBindings(react) +``` + +### ⚛️ `component` enables fully automatic reactive re-rendering +```ts +import {signal} from "@e280/strata" +import {component} from "./strata.js" + +const $count = signal(0) + +export const MyCounter = component(() => { + const add = () => $count($count() + 1) + return +}) +``` + +### ⚛️ `useTracked` for a manual hands-on approach (plays nicer with hmr) +```ts +import {signal} from "@e280/strata" +import {useTracked} from "./strata.js" + +const $count = signal(0) + +export const MyCounter = () => { + const count = useTracked(() => $count()) + const add = () => $count($count() + 1) + return +} +``` + +### ⚛️ `useSignal` for local component state (and `useDerived`) +```ts +import {useSignal} from "./strata.js" + +export const MyCounter = () => { + const $count = useSignal(0) + const add = () => $count($count() + 1) + return +} +``` + + + +

+ +## 🧑‍💻 strata is by e280 +free and open source by https://e280.org/ +join us if you're cool and good at dev + diff --git a/s/nova/bindings/react.ts b/s/nova/bindings/react.ts index 9b5a751..b794242 100644 --- a/s/nova/bindings/react.ts +++ b/s/nova/bindings/react.ts @@ -3,7 +3,7 @@ import {signal} from "../core/signal.js" import {derived} from "../core/derived.js" import {tracker} from "../tracker/tracker.js" -export function react(react: { +export function reactBindings(react: { useEffect: (fn: () => void | (() => void), deps?: unknown[]) => void useState: (x: X | (() => X)) => [ value: X, @@ -11,7 +11,7 @@ export function react(react: { ] }) { - const useTracker = (fn: () => X) => { + const useTracked = (fn: () => X) => { const [, setTick] = react.useState(0) const {seen, value} = tracker.observe(fn) @@ -25,7 +25,7 @@ export function react(react: { } const component =

(render: (props: P) => R) => { - const c = (props: P) => useTracker(() => render(props)) + const c = (props: P) => useTracked(() => render(props)) c.displayName = (render as any).displayName ?? render.name ?? "Component" return c } @@ -37,16 +37,16 @@ export function react(react: { const useSignal = (value: X) => { const $signal = useOnce(() => signal(value)) - void useTracker(() => $signal()) + void useTracked(() => $signal()) return $signal } const useDerived = (formula: () => X) => { const $derived = useOnce(() => derived(formula)) - void useTracker(() => $derived()) + void useTracked(() => $derived()) return $derived } - return {component, useTracker, useOnce, useSignal, useDerived} + return {component, useTracked, useOnce, useSignal, useDerived} } diff --git a/s/nova/core/derived.ts b/s/nova/core/derived.ts index e82efe4..06e21dd 100644 --- a/s/nova/core/derived.ts +++ b/s/nova/core/derived.ts @@ -1,12 +1,8 @@ +import {Derived} from "./types.js" import {watch} from "./utils/watch.js" import {tracker} from "../tracker/tracker.js" -export type Derived = { - (): Value - dispose: () => void -} - export function derived(fn: () => Value): Derived { let value!: Value let dirty = true diff --git a/s/nova/core/index.ts b/s/nova/core/index.ts index 7b1d74d..fc337cf 100644 --- a/s/nova/core/index.ts +++ b/s/nova/core/index.ts @@ -3,4 +3,5 @@ export * from "./batch.js" export * from "./derived.js" export * from "./effect.js" export * from "./signal.js" +export * from "./types.js" diff --git a/s/nova/core/signal.ts b/s/nova/core/signal.ts index f93a099..f310d4b 100644 --- a/s/nova/core/signal.ts +++ b/s/nova/core/signal.ts @@ -1,11 +1,7 @@ +import {Signal} from "./types.js" import {tracker} from "../tracker/tracker.js" -export type Signal = { - (): Value - (value: Value): Value -} - export function signal(value: Value): Signal { return function sig() { if (arguments.length === 0) { diff --git a/s/nova/core/types.ts b/s/nova/core/types.ts new file mode 100644 index 0000000..9354196 --- /dev/null +++ b/s/nova/core/types.ts @@ -0,0 +1,13 @@ + +export type Signal = { + (): Value + (value: Value): Value +} + +export type Derived = { + (): Value + dispose: () => void +} + +export type Valuable = Signal | Derived + From eca46e0b66bff12324a5afbeada67f44a2fa4742 Mon Sep 17 00:00:00 2001 From: Chase Moskal Date: Sun, 3 May 2026 18:24:20 -0700 Subject: [PATCH 06/18] update: readme --- s/nova/README.md | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/s/nova/README.md b/s/nova/README.md index 541ff86..a8e3c59 100644 --- a/s/nova/README.md +++ b/s/nova/README.md @@ -34,16 +34,16 @@ import {signal, derived, effect, batch} from "@e280/strata" ### 🚦 signal - **a signal holds a value** ```ts - const $count = signal(0) + const $count = signal(1) ``` > *we kinda like the `$` convention for signals* - **read the signal** ```ts - $count() // 0 + $count() // 1 ``` - **write the signal** ```ts - $count(1) + $count(2) ``` ### 🚦 derived @@ -52,6 +52,7 @@ import {signal, derived, effect, batch} from "@e280/strata" const $alpha = signal(1) const $bravo = signal(10) const $product = derived(() => $alpha() * $bravo()) + ``` - **it automatically updates** ```ts $product() // 10 @@ -59,22 +60,33 @@ import {signal, derived, effect, batch} from "@e280/strata" $product() // 🪄 20 ``` - **btw,** - deriveds are lazy and don't run the formula fn unless actually demanded. also, they have a `.dispose()` fn you can use to stop them. + deriveds are lazy and don't run the formula fn unless actually demanded (unless an effect is watching them). also, they have a `.dispose()` fn you can use to stop them. ### 🚦 effects - **effects run when the relevant signals change** ```ts effect(() => console.log($count())) - // 1 + // 2 // the system detects '$count' is relevant - $count(2) - // 2 + $count(3) + // 3 // when $count is changed, the effect fn is run ``` - **btw,** effects return a dispose fn you can use to stop them. also, you can optionally pass a second parameter fn that receives what the first fn returns, and it's not initially called. +### 🚦 batch +- **optimize multiple writes into one fat update** + ```ts + // call downstream effects only once + batch(() => { + $count(4) + $count(5) + $count(6) + }) + ``` + ### 🚦 types - **`Signal`** — it's a signal fn - **`Derived`** — it's a derived fn @@ -124,10 +136,9 @@ this is the inner sanctum of strata. use the tracker to jack into the reactivity ``` ### 🪄 integrate your ui framework for auto-rerendering -- put on your big-kid pants and have your ai agent read the [source code](./tracker/tracker.ts) - use `tracker.observe` to check what is touched by a fn - use `tracker.subscribe` to subscribe to the seen items that `observe` returns -- you'll figure it out, lol, or reach out to me on discord +- see the [source code](./tracker/tracker.ts) From 8a8689ef2d2d5420397461b7d6b9198be8cade62 Mon Sep 17 00:00:00 2001 From: Chase Moskal Date: Sun, 3 May 2026 18:50:28 -0700 Subject: [PATCH 07/18] port: wait --- CHANGELOG.md | 11 +++++++++++ s/nova/wait/index.ts | 8 ++++++++ s/nova/wait/parts/get.ts | 26 +++++++++++++++++++++++++ s/nova/wait/parts/is.ts | 8 ++++++++ s/nova/wait/parts/new.ts | 10 ++++++++++ s/nova/wait/parts/select.ts | 21 ++++++++++++++++++++ s/nova/wait/parts/type.ts | 20 +++++++++++++++++++ s/nova/wait/parts/wait.ts | 39 +++++++++++++++++++++++++++++++++++++ s/nova/wait/test.ts | 35 +++++++++++++++++++++++++++++++++ s/test.ts | 2 ++ 10 files changed, 180 insertions(+) create mode 100644 s/nova/wait/index.ts create mode 100644 s/nova/wait/parts/get.ts create mode 100644 s/nova/wait/parts/is.ts create mode 100644 s/nova/wait/parts/new.ts create mode 100644 s/nova/wait/parts/select.ts create mode 100644 s/nova/wait/parts/type.ts create mode 100644 s/nova/wait/parts/wait.ts create mode 100644 s/nova/wait/test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b092fd..a45d9c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ +
+ +## v0.4 + +### v0.4.0 +- 🟥 huge core rewrite +- 🟥 wait + - 🟥 `WaitDone` renamed to `WaitResult` to better match ok/err/result + + +
## v0.3 diff --git a/s/nova/wait/index.ts b/s/nova/wait/index.ts new file mode 100644 index 0000000..a1dc2df --- /dev/null +++ b/s/nova/wait/index.ts @@ -0,0 +1,8 @@ + +export * from "./parts/get.js" +export * from "./parts/is.js" +export * from "./parts/new.js" +export * from "./parts/select.js" +export * from "./parts/wait.js" +export * from "./parts/type.js" + diff --git a/s/nova/wait/parts/get.ts b/s/nova/wait/parts/get.ts new file mode 100644 index 0000000..10caf8a --- /dev/null +++ b/s/nova/wait/parts/get.ts @@ -0,0 +1,26 @@ + +import {getErr, getOk, needErr, needOk} from "@e280/stz" +import {Wait} from "./type.js" + +export function waitGetOk(wait: Wait) { + return wait.done + ? getOk(wait) + : undefined +} + +export function waitNeedOk(wait: Wait) { + if (!wait.done) throw new Error("wait not done") + return needOk(wait) +} + +export function waitGetErr(wait: Wait) { + return wait.done + ? getErr(wait) + : undefined +} + +export function waitNeedErr(wait: Wait) { + if (!wait.done) throw new Error("wait not done") + return needErr(wait) +} + diff --git a/s/nova/wait/parts/is.ts b/s/nova/wait/parts/is.ts new file mode 100644 index 0000000..64a629c --- /dev/null +++ b/s/nova/wait/parts/is.ts @@ -0,0 +1,8 @@ + +import {Wait, WaitDone, WaitErr, WaitOk, WaitPending} from "./type.js" + +export const isWaitPending = (wait: Wait): wait is WaitPending => !wait.done +export const isWaitDone = (wait: Wait): wait is WaitDone => wait.done +export const isWaitOk = (wait: Wait): wait is WaitOk => wait.done && wait.ok +export const isWaitErr = (wait: Wait): wait is WaitErr => wait.done && !wait.ok + diff --git a/s/nova/wait/parts/new.ts b/s/nova/wait/parts/new.ts new file mode 100644 index 0000000..b4d4231 --- /dev/null +++ b/s/nova/wait/parts/new.ts @@ -0,0 +1,10 @@ + +import {Result} from "@e280/stz" +import {Wait} from "./type.js" + +export function newWait(result?: Result): Wait { + return result + ? {done: true, ...result} + : {done: false} +} + diff --git a/s/nova/wait/parts/select.ts b/s/nova/wait/parts/select.ts new file mode 100644 index 0000000..ac73ad9 --- /dev/null +++ b/s/nova/wait/parts/select.ts @@ -0,0 +1,21 @@ + +import {Wait} from "./type.js" +import {isWaitOk, isWaitPending} from "./is.js" +import {waitNeedErr, waitNeedOk} from "./get.js" + +export function waitSelect(wait: Wait, select: { + pending: () => Ret, + ok: (value: Value) => Ret + err: (error: E) => Ret + }) { + + if (isWaitPending(wait)) + return select.pending() + + else if (isWaitOk(wait)) + return select.ok(waitNeedOk(wait)) + + else + return select.err(waitNeedErr(wait)) +} + diff --git a/s/nova/wait/parts/type.ts b/s/nova/wait/parts/type.ts new file mode 100644 index 0000000..184cebf --- /dev/null +++ b/s/nova/wait/parts/type.ts @@ -0,0 +1,20 @@ + +import {Err, Ok, Result} from "@e280/stz" +import {Derived} from "../../core/types.js" + +export type WaitPending = {done: false} +export type WaitDone = {done: true} & Result +export type WaitOk = {done: true} & Ok +export type WaitErr = {done: true} & Err + +export type Wait = + | WaitPending + | WaitDone + +export type WaitSignal = + & { + ready: Promise + result: Promise> + } + & Derived> + diff --git a/s/nova/wait/parts/wait.ts b/s/nova/wait/parts/wait.ts new file mode 100644 index 0000000..c2da6b0 --- /dev/null +++ b/s/nova/wait/parts/wait.ts @@ -0,0 +1,39 @@ + +import {attemptAsync, getOk, Result} from "@e280/stz" + +import {newWait} from "./new.js" +import {signal} from "../../core/signal.js" +import {derived} from "../../core/derived.js" +import {Wait, WaitDone, WaitSignal} from "./type.js" + +export function wait( + input: Promise | (() => Promise), + ) { + return waitResult(attemptAsync(input)) +} + +export function waitResult( + input: Promise> | (() => Promise>), + ) { + return waitResultPromise( + (typeof input === "function") + ? input() + : input + ) +} + +function waitResultPromise(promise: Promise>) { + const $wait = signal>(newWait()) + const $derived = derived(() => $wait()) as WaitSignal + + $derived.result = promise.then(result => { + const r: WaitDone = {done: true, ...result} + $wait(r) + return r + }) + + $derived.ready = $derived.result.then(getOk) + + return $derived +} + diff --git a/s/nova/wait/test.ts b/s/nova/wait/test.ts new file mode 100644 index 0000000..264da5d --- /dev/null +++ b/s/nova/wait/test.ts @@ -0,0 +1,35 @@ + +import {nap, needOk} from "@e280/stz" +import {expect, suite, test} from "@e280/science" + +import {wait} from "./parts/wait.js" +import {waitNeedErr, waitNeedOk} from "./parts/get.js" +import {isWaitDone, isWaitErr, isWaitPending} from "./parts/is.js" + +export default suite({ + "wait fn, done": test(async() => { + const $wait = wait(async() => { + await nap() + return 123 + }) + expect(isWaitPending($wait())).is(true) + expect(await $wait.ready).is(123) + expect(needOk(await $wait.result)).is(123) + expect(isWaitDone($wait())).is(true) + expect(waitNeedOk($wait())).is(123) + }), + + "wait fn, failed": test(async() => { + const $wait = wait(async() => { + await nap() + if (!!true) throw new Error("uh oh") + return 123 + }) + expect(isWaitPending($wait())).is(true) + expect(await $wait.ready).is(undefined) + expect((await $wait.result).ok).is(false) + expect(isWaitErr($wait())).is(true) + expect(waitNeedErr($wait()).message).is("uh oh") + }), +}) + diff --git a/s/test.ts b/s/test.ts index a902fdb..1478c81 100644 --- a/s/test.ts +++ b/s/test.ts @@ -1,10 +1,12 @@ import {science} from "@e280/science" import core from "./nova/core/test.js" +import wait from "./nova/wait/test.js" import tracker from "./nova/tracker/test.js" await science.run({ tracker, core, + wait, }) From d1420bf465f94acc31036bdb974982725c9505be Mon Sep 17 00:00:00 2001 From: Chase Moskal Date: Sun, 3 May 2026 18:53:02 -0700 Subject: [PATCH 08/18] tweaks: to wait system and changelog --- CHANGELOG.md | 3 ++- s/nova/wait/index.ts | 2 +- s/nova/wait/parts/is.ts | 4 ++-- s/nova/wait/parts/{new.ts => make.ts} | 2 +- s/nova/wait/parts/type.ts | 14 ++++++-------- s/nova/wait/parts/wait.ts | 8 ++++---- 6 files changed, 16 insertions(+), 17 deletions(-) rename s/nova/wait/parts/{new.ts => make.ts} (59%) diff --git a/CHANGELOG.md b/CHANGELOG.md index a45d9c8..9e7a828 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,8 @@ ### v0.4.0 - 🟥 huge core rewrite - 🟥 wait - - 🟥 `WaitDone` renamed to `WaitResult` to better match ok/err/result + - 🟥 renamed `WaitDone` to `WaitResult` to better match ok/err/result + - 🟥 renamed `newWait` to `makeWait` because i like it more diff --git a/s/nova/wait/index.ts b/s/nova/wait/index.ts index a1dc2df..538790d 100644 --- a/s/nova/wait/index.ts +++ b/s/nova/wait/index.ts @@ -1,7 +1,7 @@ export * from "./parts/get.js" export * from "./parts/is.js" -export * from "./parts/new.js" +export * from "./parts/make.js" export * from "./parts/select.js" export * from "./parts/wait.js" export * from "./parts/type.js" diff --git a/s/nova/wait/parts/is.ts b/s/nova/wait/parts/is.ts index 64a629c..31710eb 100644 --- a/s/nova/wait/parts/is.ts +++ b/s/nova/wait/parts/is.ts @@ -1,8 +1,8 @@ -import {Wait, WaitDone, WaitErr, WaitOk, WaitPending} from "./type.js" +import {Wait, WaitResult, WaitErr, WaitOk, WaitPending} from "./type.js" export const isWaitPending = (wait: Wait): wait is WaitPending => !wait.done -export const isWaitDone = (wait: Wait): wait is WaitDone => wait.done +export const isWaitDone = (wait: Wait): wait is WaitResult => wait.done export const isWaitOk = (wait: Wait): wait is WaitOk => wait.done && wait.ok export const isWaitErr = (wait: Wait): wait is WaitErr => wait.done && !wait.ok diff --git a/s/nova/wait/parts/new.ts b/s/nova/wait/parts/make.ts similarity index 59% rename from s/nova/wait/parts/new.ts rename to s/nova/wait/parts/make.ts index b4d4231..d351e9f 100644 --- a/s/nova/wait/parts/new.ts +++ b/s/nova/wait/parts/make.ts @@ -2,7 +2,7 @@ import {Result} from "@e280/stz" import {Wait} from "./type.js" -export function newWait(result?: Result): Wait { +export function makeWait(result?: Result): Wait { return result ? {done: true, ...result} : {done: false} diff --git a/s/nova/wait/parts/type.ts b/s/nova/wait/parts/type.ts index 184cebf..49640fc 100644 --- a/s/nova/wait/parts/type.ts +++ b/s/nova/wait/parts/type.ts @@ -3,18 +3,16 @@ import {Err, Ok, Result} from "@e280/stz" import {Derived} from "../../core/types.js" export type WaitPending = {done: false} -export type WaitDone = {done: true} & Result +export type WaitResult = {done: true} & Result export type WaitOk = {done: true} & Ok export type WaitErr = {done: true} & Err export type Wait = | WaitPending - | WaitDone + | WaitResult -export type WaitSignal = - & { - ready: Promise - result: Promise> - } - & Derived> +export type WaitSignal = Derived> & { + ready: Promise + result: Promise> +} diff --git a/s/nova/wait/parts/wait.ts b/s/nova/wait/parts/wait.ts index c2da6b0..75ab502 100644 --- a/s/nova/wait/parts/wait.ts +++ b/s/nova/wait/parts/wait.ts @@ -1,10 +1,10 @@ import {attemptAsync, getOk, Result} from "@e280/stz" -import {newWait} from "./new.js" +import {makeWait} from "./make.js" import {signal} from "../../core/signal.js" import {derived} from "../../core/derived.js" -import {Wait, WaitDone, WaitSignal} from "./type.js" +import {Wait, WaitResult, WaitSignal} from "./type.js" export function wait( input: Promise | (() => Promise), @@ -23,11 +23,11 @@ export function waitResult( } function waitResultPromise(promise: Promise>) { - const $wait = signal>(newWait()) + const $wait = signal>(makeWait()) const $derived = derived(() => $wait()) as WaitSignal $derived.result = promise.then(result => { - const r: WaitDone = {done: true, ...result} + const r: WaitResult = {done: true, ...result} $wait(r) return r }) From bc84ba369c5b3f8de6af021159019e31e0770dbd Mon Sep 17 00:00:00 2001 From: Chase Moskal Date: Sun, 3 May 2026 18:59:16 -0700 Subject: [PATCH 09/18] improve: wait naming and readme --- CHANGELOG.md | 2 + s/nova/README.md | 118 ++++++++++++++++++++++++++++++++++++++ s/nova/wait/parts/type.ts | 2 +- s/nova/wait/parts/wait.ts | 12 ++-- 4 files changed, 127 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e7a828..0ad3c98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ - 🟥 wait - 🟥 renamed `WaitDone` to `WaitResult` to better match ok/err/result - 🟥 renamed `newWait` to `makeWait` because i like it more + - 🟥 renamed `WaitSignal` to `WaitDerived` because that's what it is + - 🟥 renamed `waitResult` to `waitFormal` because i said so diff --git a/s/nova/README.md b/s/nova/README.md index a8e3c59..a58d9d0 100644 --- a/s/nova/README.md +++ b/s/nova/README.md @@ -15,6 +15,7 @@ 🧑‍💻 a project by https://e280.org/ 🚦 [**#signals,**](#signals) sweet little bundles of state +⌛ [**#wait,**](#wait) helpers for async operation states 🪄 [**#tracker,**](#tracker) agnostic reactivity integration hub ⚛️ [**#react,**](#react) optional bindings for react @@ -94,6 +95,123 @@ import {signal, derived, effect, batch} from "@e280/strata" +

+ + + +## 🍋 strata wait +> *represent async operations* + +- wait is designed to vibe with [stz#ok](https://github.com/e280/stz#ok) + ```ts + import {ok, err} from "@e280/stz" + ``` + +### ⌛ make some wait +- imports + ```ts + import {makeWait} from "@e280/strata" + ``` +- helpers to create a `Wait` + ```ts + // loading + makeWait() + // {done: false} + + // done, ok + makeWait(ok(123)) + // {done: true, ok: true, value: 123} + + // done, err + makeWait(err("uh oh")) + // {done: true, ok: false, error: "uh oh"} + ``` + +### ⌛ make a magic reactive waiter +- imports + ```ts + import {nap} from "@e280/stz" + import {wait} from "@e280/strata" + ``` +- it's a derived signal (readonly) that tracks the result of an async fn or promise + ```ts + const $wait = wait(async() => { + await nap(100) // do some async stuff + return 123 // return your value + }) + ``` +- read the current state from the signal + ```ts + $wait() + // {done: false} + ``` +- later, when it's ready + ```ts + await $wait.ready + // 123 + // undefined if there was an error + + $wait() + // {done: true, ok: true, value: 123} + ``` + +### ⌛ waitFormal, persnickety belt-and-suspenders mode +- imports + ```ts + import {waitFormal} from "@e280/strata" + ``` +- do formal rigid error handling because you're super strict and serious + ```ts + const $wait = waitFormal(async() => { + if (Math.random() > 0.5) + return ok(123) + + if (Math.random() < 0.01) + return err("unlikely lol") + + else + return err("bad roll") + }) + ``` +- listen for the formal result + ```ts + await $wait.result + // {done: true, ok: true, value: 123} or + // {done: true, ok: false, error: "bad roll"} + ``` +- btw, wait and waitFormal will actually accept a promise if you like + ```ts + const $wait = wait(Promise.resolve(123)) + ``` + +### ⌛ wait helpers +- check the state + ```ts + isWaitPending($wait()) + isWaitDone($wait()) + isWaitOk($wait()) + isWaitErr($wait()) + ``` +- get the finished value or error + ```ts + waitGetOk($wait()) // 123 | undefined + waitNeedOk($wait()) // 123 (or throws an error) + ``` + ```ts + waitGetErr($wait()) // "bad roll" | undefined + waitNeedErr($wait()) // "bad roll" (or throws an error) + ``` +- select based on the state + ```ts + const text = waitSelect($wait(), { + pending: () => "still loading...", + ok: value => `ready: ${value}`, + err: error => `ack! ${error}`, + }) + ``` + + +

diff --git a/s/nova/wait/parts/type.ts b/s/nova/wait/parts/type.ts index 49640fc..b1855b7 100644 --- a/s/nova/wait/parts/type.ts +++ b/s/nova/wait/parts/type.ts @@ -11,7 +11,7 @@ export type Wait = | WaitPending | WaitResult -export type WaitSignal = Derived> & { +export type WaitDerived = Derived> & { ready: Promise result: Promise> } diff --git a/s/nova/wait/parts/wait.ts b/s/nova/wait/parts/wait.ts index 75ab502..89f1b3a 100644 --- a/s/nova/wait/parts/wait.ts +++ b/s/nova/wait/parts/wait.ts @@ -4,27 +4,27 @@ import {attemptAsync, getOk, Result} from "@e280/stz" import {makeWait} from "./make.js" import {signal} from "../../core/signal.js" import {derived} from "../../core/derived.js" -import {Wait, WaitResult, WaitSignal} from "./type.js" +import {Wait, WaitResult, WaitDerived} from "./type.js" export function wait( input: Promise | (() => Promise), ) { - return waitResult(attemptAsync(input)) + return waitFormal(attemptAsync(input)) } -export function waitResult( +export function waitFormal( input: Promise> | (() => Promise>), ) { - return waitResultPromise( + return waitFormalPromise( (typeof input === "function") ? input() : input ) } -function waitResultPromise(promise: Promise>) { +function waitFormalPromise(promise: Promise>) { const $wait = signal>(makeWait()) - const $derived = derived(() => $wait()) as WaitSignal + const $derived = derived(() => $wait()) as WaitDerived $derived.result = promise.then(result => { const r: WaitResult = {done: true, ...result} From df7b06f8082aa8edc866b3cedb96f30e5120e713 Mon Sep 17 00:00:00 2001 From: Chase Moskal Date: Sun, 3 May 2026 21:46:00 -0700 Subject: [PATCH 10/18] port: prism --- s/nova/prism/chrono/chronicle.ts | 11 + s/nova/prism/chrono/chrono.ts | 95 ++++++++ s/nova/prism/chrono/types.ts | 12 + s/nova/prism/index.ts | 11 + s/nova/prism/lens.ts | 60 +++++ s/nova/prism/prism.ts | 39 ++++ s/nova/prism/test.ts | 337 +++++++++++++++++++++++++++++ s/nova/prism/types.ts | 28 +++ s/nova/prism/utils/cache-cell.ts | 22 ++ s/nova/prism/utils/immute.ts | 8 + s/nova/prism/utils/optic-symbol.ts | 3 + s/nova/prism/vault/local-store.ts | 31 +++ s/nova/prism/vault/types.ts | 22 ++ s/nova/prism/vault/vault.ts | 19 ++ s/test.ts | 2 + 15 files changed, 700 insertions(+) create mode 100644 s/nova/prism/chrono/chronicle.ts create mode 100644 s/nova/prism/chrono/chrono.ts create mode 100644 s/nova/prism/chrono/types.ts create mode 100644 s/nova/prism/index.ts create mode 100644 s/nova/prism/lens.ts create mode 100644 s/nova/prism/prism.ts create mode 100644 s/nova/prism/test.ts create mode 100644 s/nova/prism/types.ts create mode 100644 s/nova/prism/utils/cache-cell.ts create mode 100644 s/nova/prism/utils/immute.ts create mode 100644 s/nova/prism/utils/optic-symbol.ts create mode 100644 s/nova/prism/vault/local-store.ts create mode 100644 s/nova/prism/vault/types.ts create mode 100644 s/nova/prism/vault/vault.ts diff --git a/s/nova/prism/chrono/chronicle.ts b/s/nova/prism/chrono/chronicle.ts new file mode 100644 index 0000000..2926e1f --- /dev/null +++ b/s/nova/prism/chrono/chronicle.ts @@ -0,0 +1,11 @@ + +import {Chronicle} from "./types.js" + +export function chronicle(state: State): Chronicle { + return { + past: [], + present: state, + future: [], + } +} + diff --git a/s/nova/prism/chrono/chrono.ts b/s/nova/prism/chrono/chrono.ts new file mode 100644 index 0000000..449c2ab --- /dev/null +++ b/s/nova/prism/chrono/chrono.ts @@ -0,0 +1,95 @@ + +import {deep} from "@e280/stz" +import {Lens} from "../lens.js" +import {Chronicle} from "./types.js" +import {LensLike} from "../types.js" +import {_optic} from "../utils/optic-symbol.js" + +export class Chrono implements LensLike { + constructor( + public limit: number, + private basis: Lens>, + ) {} + + get chronicle() { + return this.basis.frozen + } + + get state() { + return this.basis.state.present + } + + get frozen() { + return this.basis.frozen.present + } + + get undoable() { + return this.chronicle.past.length + } + + get redoable() { + return this.chronicle.future.length + } + + #mut(chronicle: Chronicle, fn: (state: State) => R) { + const limit = Math.max(0, this.limit) + const snapshot = deep.clone(this.chronicle.present) as State + const result = fn(chronicle.present) + chronicle.past.push(snapshot) + chronicle.past = chronicle.past.slice(-limit) + chronicle.future = [] + return result + } + + /** progress forwards, depositing history into the past */ + mutate(fn: (state: State) => R): R { + return this.basis.mutate(chronicle => this.#mut(chronicle, fn)) + } + + /** step backwards into the past, by n steps */ + undo(n = 1) { + this.basis.mutate(chronicle => { + const snapshots = chronicle.past.slice(-n) + if (snapshots.length >= n) { + const oldPresent = chronicle.present + chronicle.present = snapshots.shift()! + chronicle.past = chronicle.past.slice(0, -n) + chronicle.future.unshift(oldPresent, ...snapshots) + } + }) + } + + /** step forwards into the future, by n steps */ + redo(n = 1) { + this.basis.mutate(chronicle => { + const snapshots = chronicle.future.slice(0, n) + if (snapshots.length >= n) { + const oldPresent = chronicle.present + chronicle.present = snapshots.shift()! + chronicle.past.push(oldPresent, ...snapshots) + chronicle.future = chronicle.future.slice(n) + } + }) + } + + /** wipe past and future snapshots */ + wipe() { + this.basis.mutate(chronicle => { + chronicle.past = [] + chronicle.future = [] + }) + } + + lens(selector: (state: State) => State2) { + const lens = new Lens({ + registerLens: this.basis[_optic].registerLens, + getState: () => selector(this.basis[_optic].getState().present), + mutate: fn => this.basis[_optic].mutate(chronicle => { + return this.#mut(chronicle, state => fn(selector(state))) + }), + }) + this.basis[_optic].registerLens(lens) + return lens + } +} + diff --git a/s/nova/prism/chrono/types.ts b/s/nova/prism/chrono/types.ts new file mode 100644 index 0000000..d5e4e41 --- /dev/null +++ b/s/nova/prism/chrono/types.ts @@ -0,0 +1,12 @@ + +export type Chronicle = { + // [abc] d [efg] + // \ \ \ + // \ \ future + // \ present + // past + past: State[] + present: State + future: State[] +} + diff --git a/s/nova/prism/index.ts b/s/nova/prism/index.ts new file mode 100644 index 0000000..dcbe3d9 --- /dev/null +++ b/s/nova/prism/index.ts @@ -0,0 +1,11 @@ + +export * from "./chrono/chronicle.js" +export * from "./chrono/chrono.js" +export * from "./chrono/types.js" +export * from "./vault/local-store.js" +export * from "./vault/vault.js" +export * from "./vault/types.js" +export * from "./lens.js" +export * from "./prism.js" +export * from "./types.js" + diff --git a/s/nova/prism/lens.ts b/s/nova/prism/lens.ts new file mode 100644 index 0000000..55334c8 --- /dev/null +++ b/s/nova/prism/lens.ts @@ -0,0 +1,60 @@ + +import {deep} from "@e280/stz" +import {immute} from "./utils/immute.js" +import {tracker} from "../tracker/tracker.js" +import {_optic} from "./utils/optic-symbol.js" +import {CacheCell} from "./utils/cache-cell.js" +import {Immutable, LensLike, Optic} from "./types.js" + +/** reactive view into a state prism, with formalized mutations */ +export class Lens implements LensLike { + ;[_optic]: Optic + #previous: State + #stateCache: CacheCell + #frozenCache: CacheCell> + + constructor(optic: Optic) { + this[_optic] = optic + this.#previous = deep.clone(optic.getState()) + this.#stateCache = new CacheCell(() => deep.clone(optic.getState())) + this.#frozenCache = new CacheCell(() => immute(optic.getState())) + } + + update() { + const state = this[_optic].getState() + const isChanged = !deep.equal(state, this.#previous) + if (isChanged) { + this.#stateCache.invalidate() + this.#frozenCache.invalidate() + this.#previous = deep.clone(state) + tracker.write(this) + } + } + + /** get a snapshot of the current state. it's typed as mutable, but you should not mutate it. */ + get state() { + tracker.read(this) + return this.#stateCache.get() + } + + /** get an immutable readonly snapshot of the current state. */ + get frozen() { + tracker.read(this) + return this.#frozenCache.get() + } + + mutate(fn: (state: State) => R) { + return this[_optic].mutate(fn) + } + + lens(selector: (state: State) => State2) { + const lens = new Lens({ + getState: () => selector(this[_optic].getState()), + mutate: fn => this[_optic].mutate(state => fn(selector(state))), + registerLens: this[_optic].registerLens, + }) + this[_optic].registerLens(lens) + return lens + } +} + diff --git a/s/nova/prism/prism.ts b/s/nova/prism/prism.ts new file mode 100644 index 0000000..1f603c0 --- /dev/null +++ b/s/nova/prism/prism.ts @@ -0,0 +1,39 @@ + +import {Lens} from "./lens.js" +import {tracker} from "../tracker/tracker.js" + +/** state mangagement source-of-truth */ +export class Prism { + #state: State + #lenses = new Set>() + + constructor(state: State) { + this.#state = state + } + + get() { + tracker.read(this) + return this.#state + } + + set(state: State) { + this.#state = state + for (const lens of this.#lenses) lens.update() + tracker.write(this) + } + + lens(selector: (state: State) => State2) { + const lens = new Lens({ + getState: () => selector(this.#state), + mutate: fn => { + const result = fn(selector(this.#state)) + this.set(this.#state) + return result + }, + registerLens: lens => this.#lenses.add(lens), + }) + this.#lenses.add(lens) + return lens + } +} + diff --git a/s/nova/prism/test.ts b/s/nova/prism/test.ts new file mode 100644 index 0000000..6be32c3 --- /dev/null +++ b/s/nova/prism/test.ts @@ -0,0 +1,337 @@ + +import {suite, test, expect} from "@e280/science" + +import {Prism} from "./prism.js" +import {effect} from "../core/effect.js" +import {Chrono} from "./chrono/chrono.js" +import {chronicle} from "./chrono/chronicle.js" +import { batch } from "../core/batch.js" + +export default suite({ + "prism": suite({ + "get/set state": test(async() => { + const prism = new Prism({count: 1}) + expect(prism.get().count).is(1) + prism.set({count: 2}) + expect(prism.get().count).is(2) + }), + + "get/set state can trigger effects": test(async() => { + const prism = new Prism({count: 1}) + let triggered = 0 + effect(() => { + void prism.get().count + triggered++ + }) + expect(triggered).is(1) + prism.set({count: 2}) + expect(triggered).is(2) + }), + }), + + "lens": suite({ + "get state": test(async() => { + const prism = new Prism({data: {count: 1}}) + const lens = prism.lens(s => s.data) + expect(lens.frozen.count).is(1) + }), + + "state is immutable": test(async() => { + const prism = new Prism({data: {count: 1}}) + const lens = prism.lens(s => s.data) + expect(() => (lens.frozen as any).count++).throws() + }), + + "proper mutation": test(async() => { + const prism = new Prism({data: {count: 1}}) + const lens = prism.lens(s => s.data) + lens.mutate(s => s.count++) + expect(lens.frozen.count).is(2) + lens.mutate(s => s.count++) + expect(lens.frozen.count).is(3) + }), + + "state after mutation is frozen": test(async() => { + const prism = new Prism({data: {count: 1}}) + const lens = prism.lens(s => s) + lens.mutate(s => s.data = {count: 2}) + expect(lens.frozen.data.count).is(2) + expect(() => (lens.frozen.data as any).count++).throws() + }), + + "effect reacts": test(async() => { + const prism = new Prism({data: {count: 1}}) + const lens = prism.lens(s => s.data) + let happenings = 0 + effect(() => { + void lens.frozen.count + happenings++ + }) + lens.mutate(s => s.count++) + expect(happenings).is(2) + }), + + "effects can be batched": test(async() => { + const prism = new Prism({data: {count: 1}}) + const lens = prism.lens(s => s.data) + let happenings = 0 + effect(() => { + void lens.frozen.count + happenings++ + }) + batch(() => { + lens.mutate(s => s.count++) + lens.mutate(s => s.count++) + }) + expect(happenings).is(2) + }), + + "array pushes are reactive": test(async() => { + const prism = new Prism({data: {array: ["lol"]}}) + const lens = prism.lens(s => s.data) + let happenings = 0 + effect(() => lens.state, () => happenings++) + lens.mutate(s => s.array.push("lmao")) + expect(happenings).is(1) + expect(lens.frozen.array.length).is(2) + }), + + "sync coherence": test(async() => { + const prism = new Prism({data: {count: 1}}) + const lens = prism.lens(s => s.data) + lens.mutate(s => s.count++) + expect(lens.frozen.count).is(2) + lens.mutate(s => s.count++) + expect(lens.frozen.count).is(3) + }), + + "nullable selector": test(async() => { + type S = {a?: {b: {count: number}}} + const prism = new Prism({a: {b: {count: 1}}}) + const lens = prism.lens(s => s.a?.b) + expect(lens.frozen?.count).is(1) + prism.set({a: undefined}) + expect(lens.frozen?.count).is(undefined) + }), + + "deep composition": test(async() => { + const prism = new Prism({a: {b: {count: 1}}}) + const lensA = prism.lens(s => s.a) + const lensB = lensA.lens(s => s.b) + expect(prism.get().a.b.count).is(1) + expect(lensA.frozen.b.count).is(1) + expect(lensB.frozen.count).is(1) + }), + + "deep mutations": test(async() => { + const prism = new Prism({a: {b: {count: 1}}}) + const lensA = prism.lens(s => s.a) + const lensB = lensA.lens(s => s.b) + lensB.mutate(s => s.count++) + expect(prism.get().a.b.count).is(2) + expect(lensA.frozen.b.count).is(2) + expect(lensB.frozen.count).is(2) + lensA.mutate(s => s.b = {count: 3}) + expect(prism.get().a.b.count).is(3) + expect(lensA.frozen.b.count).is(3) + expect(lensB.frozen.count).is(3) + }), + + "outside mutations ignored": test(async() => { + const prism = new Prism({a: {count: 1}, b: {count: 101}}) + const lensA = prism.lens(s => s.a) + const lensB = prism.lens(s => s.b) + let happeningsA = 0 + let happeningsB = 0 + effect(() => lensA.state, () => happeningsA++) + effect(() => lensB.state, () => happeningsB++) + lensA.mutate(s => s.count++) + expect(happeningsA).is(1) + expect(happeningsB).is(0) + }), + + "outside mutations ignored for effects": test(async() => { + const prism = new Prism({a: {count: 1}, b: {count: 101}}) + const lensA = prism.lens(s => s.a) + const lensB = prism.lens(s => s.b) + let happeningsA = 0 + let happeningsB = 0 + effect(() => { + void lensA.frozen.count + happeningsA++ + }) + effect(() => { + void lensB.frozen.count + happeningsB++ + }) + lensA.mutate(s => s.count++) + expect(happeningsA).is(2) + expect(happeningsB).is(1) + }), + }), + + "chrono": suite({ + "get present state": test(async() => { + const prism = new Prism({data: chronicle({count: 1})}) + const lens = prism.lens(s => s.data) + const chrono = new Chrono(64, lens) + expect(chrono.frozen.count).is(1) + }), + + "mutation": test(async() => { + const prism = new Prism({data: chronicle({count: 1})}) + const lens = prism.lens(s => s.data) + const chrono = new Chrono(64, lens) + chrono.mutate(s => s.count++) + expect(chrono.frozen.count).is(2) + chrono.mutate(s => s.count++) + expect(chrono.frozen.count).is(3) + }), + + "undoable/redoable": test(async() => { + const prism = new Prism({data: chronicle({count: 1})}) + const lens = prism.lens(s => s.data) + const chrono = new Chrono(64, lens) + expect(chrono.undoable).is(0) + expect(chrono.redoable).is(0) + chrono.mutate(s => s.count++) + expect(chrono.undoable).is(1) + chrono.mutate(s => s.count++) + expect(chrono.undoable).is(2) + chrono.undo() + expect(chrono.undoable).is(1) + expect(chrono.redoable).is(1) + chrono.undo() + expect(chrono.undoable).is(0) + expect(chrono.redoable).is(2) + chrono.redo() + expect(chrono.undoable).is(1) + expect(chrono.redoable).is(1) + }), + + "undo": test(async() => { + const prism = new Prism({data: chronicle({count: 1})}) + const lens = prism.lens(s => s.data) + const chrono = new Chrono(64, lens) + + chrono.mutate(s => s.count++) + expect(chrono.frozen.count).is(2) + + chrono.undo() + expect(chrono.frozen.count).is(1) + }), + + "sync undo": test(async() => { + const prism = new Prism({data: chronicle({count: 1})}) + const lens = prism.lens(s => s.data) + const chrono = new Chrono(64, lens) + + chrono.mutate(s => s.count++) + expect(chrono.frozen.count).is(2) + + chrono.undo() + expect(chrono.frozen.count).is(1) + }), + + "redo": test(async() => { + const prism = new Prism({data: chronicle({count: 1})}) + const lens = prism.lens(s => s.data) + const chrono = new Chrono(64, lens) + + chrono.mutate(s => s.count++) + expect(chrono.frozen.count).is(2) + + chrono.undo() + expect(chrono.frozen.count).is(1) + + chrono.redo() + expect(chrono.frozen.count).is(2) + }), + + "undo/redo is orderly": test(async() => { + const prism = new Prism({data: chronicle({count: 1})}) + const lens = prism.lens(s => s.data) + const chrono = new Chrono(64, lens) + + chrono.mutate(s => s.count++) + chrono.mutate(s => s.count++) + chrono.mutate(s => s.count++) + expect(chrono.frozen.count).is(4) + + chrono.undo() + expect(chrono.frozen.count).is(3) + + chrono.undo() + expect(chrono.frozen.count).is(2) + + chrono.redo() + expect(chrono.frozen.count).is(3) + + chrono.redo() + expect(chrono.frozen.count).is(4) + + chrono.undo() + expect(chrono.frozen.count).is(3) + + chrono.undo() + expect(chrono.frozen.count).is(2) + + chrono.undo() + expect(chrono.frozen.count).is(1) + }), + + "undo nothing does nothing": test(async() => { + const prism = new Prism({data: chronicle({count: 1})}) + const lens = prism.lens(s => s.data) + const chrono = new Chrono(64, lens) + chrono.undo() + expect(chrono.frozen.count).is(1) + }), + + "redo nothing does nothing": test(async() => { + const prism = new Prism({data: chronicle({count: 1})}) + const lens = prism.lens(s => s.data) + const chrono = new Chrono(64, lens) + chrono.redo() + expect(chrono.frozen.count).is(1) + }), + + "undo 2x": test(async() => { + const prism = new Prism({data: chronicle({count: 1})}) + const lens = prism.lens(s => s.data) + const chrono = new Chrono(64, lens) + chrono.mutate(s => s.count++) + chrono.mutate(s => s.count++) + chrono.mutate(s => s.count++) + expect(chrono.frozen.count).is(4) + chrono.undo(2) + expect(chrono.frozen.count).is(2) + }), + + "redo 2x": test(async() => { + const prism = new Prism({data: chronicle({count: 1})}) + const lens = prism.lens(s => s.data) + const chrono = new Chrono(64, lens) + chrono.mutate(s => s.count++) + chrono.mutate(s => s.count++) + chrono.mutate(s => s.count++) + expect(chrono.frozen.count).is(4) + chrono.undo(2) + expect(chrono.frozen.count).is(2) + chrono.redo(2) + expect(chrono.frozen.count).is(4) + }), + + "sublens mutations are undoable": test(async() => { + const prism = new Prism({data: chronicle({a: {count: 1}})}) + const chrono = new Chrono(64, prism.lens(s => s.data)) + const sublens = chrono.lens(s => s.a) + expect(sublens.frozen.count).is(1) + sublens.mutate(s => s.count++) + expect(sublens.frozen.count).is(2) + chrono.undo() + expect(sublens.frozen.count).is(1) + }), + }), +}) + diff --git a/s/nova/prism/types.ts b/s/nova/prism/types.ts new file mode 100644 index 0000000..0f00556 --- /dev/null +++ b/s/nova/prism/types.ts @@ -0,0 +1,28 @@ + +import {Lens} from "./lens.js" + +export type LensLike = { + readonly state: State + readonly frozen: Immutable + mutate(fn: (state: State) => R): R + lens(selector: (state: State) => S2): Lens +} + +export type Optic = { + getState: () => State + mutate: (fn: (state: State) => R) => R + registerLens: (lens: Lens) => void +} + +export type Immutable = + T extends (...args: any[]) => any ? T : + T extends readonly any[] ? ReadonlyArray> : + T extends object ? { readonly [K in keyof T]: Immutable } : + T + +export type Mutable = + T extends (...args: any[]) => any ? T : + T extends ReadonlyArray ? Mutable[] : + T extends object ? { -readonly [K in keyof T]: Mutable } : + T + diff --git a/s/nova/prism/utils/cache-cell.ts b/s/nova/prism/utils/cache-cell.ts new file mode 100644 index 0000000..db06c86 --- /dev/null +++ b/s/nova/prism/utils/cache-cell.ts @@ -0,0 +1,22 @@ + +export class CacheCell { + #dirty = false + #value: R + + constructor(private calculate: () => R) { + this.#value = calculate() + } + + get() { + if (this.#dirty) { + this.#dirty = false + this.#value = this.calculate() + } + return this.#value + } + + invalidate() { + this.#dirty = true + } +} + diff --git a/s/nova/prism/utils/immute.ts b/s/nova/prism/utils/immute.ts new file mode 100644 index 0000000..4e11d5a --- /dev/null +++ b/s/nova/prism/utils/immute.ts @@ -0,0 +1,8 @@ + +import {deep} from "@e280/stz" +import {Immutable} from "../types.js" + +export function immute(s: S) { + return deep.freeze(deep.clone(s)) as Immutable +} + diff --git a/s/nova/prism/utils/optic-symbol.ts b/s/nova/prism/utils/optic-symbol.ts new file mode 100644 index 0000000..3fd0437 --- /dev/null +++ b/s/nova/prism/utils/optic-symbol.ts @@ -0,0 +1,3 @@ + +export const _optic = Symbol("optic") + diff --git a/s/nova/prism/vault/local-store.ts b/s/nova/prism/vault/local-store.ts new file mode 100644 index 0000000..83238bb --- /dev/null +++ b/s/nova/prism/vault/local-store.ts @@ -0,0 +1,31 @@ + +import {Cubby} from "./types.js" + +export class LocalStore implements Cubby { + constructor( + private key: string, + private storage: Storage = window.localStorage, + ) {} + + async get() { + const json = this.storage.getItem(this.key) + return json + ? JSON.parse(json) + : undefined + } + + async set(data: X) { + const json = JSON.stringify(data) + this.storage.setItem(this.key, json) + } + + onStorageEvent(fn: () => void) { + const listener = (event: StorageEvent) => { + if (event.storageArea === this.storage && event.key === this.key) + fn() + } + window.addEventListener("storage", listener) + return () => window.removeEventListener("storage", listener) + } +} + diff --git a/s/nova/prism/vault/types.ts b/s/nova/prism/vault/types.ts new file mode 100644 index 0000000..1de264a --- /dev/null +++ b/s/nova/prism/vault/types.ts @@ -0,0 +1,22 @@ + +import {Prism} from "../prism.js" + +export type Versioned = { + state: State + version: number +} + +export type Cubby = { + get(): Promise + set(data: X | undefined): Promise +} + +/** @deprecated renamed to `Cubby` */ +export type EzStore = Cubby + +export type VaultOptions = { + version: number + prism: Prism + store: Cubby> +} + diff --git a/s/nova/prism/vault/vault.ts b/s/nova/prism/vault/vault.ts new file mode 100644 index 0000000..c2b21c2 --- /dev/null +++ b/s/nova/prism/vault/vault.ts @@ -0,0 +1,19 @@ + +import {VaultOptions} from "./types.js" + +export class Vault { + constructor(private options: VaultOptions) {} + + load = async() => { + const {store, version, prism} = this.options + const pickle = await store.get() + if (pickle && pickle.version === version) + prism.set(pickle.state) + } + + save = async() => { + const {store, version, prism} = this.options + await store.set({version, state: prism.get()}) + } +} + diff --git a/s/test.ts b/s/test.ts index 1478c81..22a49c3 100644 --- a/s/test.ts +++ b/s/test.ts @@ -2,11 +2,13 @@ import {science} from "@e280/science" import core from "./nova/core/test.js" import wait from "./nova/wait/test.js" +import prism from "./nova/prism/test.js" import tracker from "./nova/tracker/test.js" await science.run({ tracker, core, wait, + prism, }) From d2bd1a679cacbe2539c9c19976a9468b9a9fb7cc Mon Sep 17 00:00:00 2001 From: Chase Moskal Date: Sun, 3 May 2026 22:14:39 -0700 Subject: [PATCH 11/18] add: prism readme --- s/nova/README.md | 138 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) diff --git a/s/nova/README.md b/s/nova/README.md index a58d9d0..a87cc9b 100644 --- a/s/nova/README.md +++ b/s/nova/README.md @@ -16,6 +16,7 @@ 🚦 [**#signals,**](#signals) sweet little bundles of state ⌛ [**#wait,**](#wait) helpers for async operation states +🔮 [**#prism,**](#prism) bigger centralized state trees 🪄 [**#tracker,**](#tracker) agnostic reactivity integration hub ⚛️ [**#react,**](#react) optional bindings for react @@ -212,6 +213,143 @@ import {signal, derived, effect, batch} from "@e280/strata" +

+ + + +## 🍋 strata prism +> *persistent app-level state* + +- single-source-of-truth state tree +- no spooky-dookie proxy magic — just god's honest javascript +- immutable except for `mutate(fn)` calls +- use many lenses, efficient reactivity +- chrono provides undo/redo history +- persistence, localstorage, cross-tab sync + +### 🔮 prism and lenses +- **import prism** + ```ts + import {Prism} from "@e280/strata" + ``` +- **prism is a state tree** + ```ts + const prism = new Prism({ + snacks: { + peanuts: 8, + bag: ["popcorn", "butter"], + person: { + name: "chase", + incredi: true, + }, + }, + }) + ``` +- **create lenses, which are views into state subtrees** + ```ts + const snacks = prism.lens(state => state.snacks) + const person = snacks.lens(state => state.person) + ``` + - you can lens another lens +- **lenses provide snapshot access to state** + ```ts + // .state is a mutable snapshot with relaxed typings + snacks.state.peanuts // 8 + person.state.name // "chase" + // ⛔ casual mutations ignored + + // .frozen is an immutable snapshot with strict typings + snacks.frozen.peanuts // 8 + snacks.frozen.peanuts++ + // ⛔ casual mutations throw errors + ``` +- **only formal mutations can actually change state** + ```ts + snacks.mutate(state => state.peanuts++) + // ✅ formal mutations to change state + + snacks.state.peanuts // 9 + ``` +- **array mutations are unironically based, actually** + ```ts + snacks.mutate(state => state.bag.push("salt")) + ``` + +### 🔮 chrono for time travel +- **import stuff** + ```ts + import {Chrono, chronicle} from "@e280/strata" + ``` +- **create a chronicle in your state** + ```ts + const prism = new Prism({ + + // chronicle stores history + // 👇 + snacks: chronicle({ + peanuts: 8, + bag: ["popcorn", "butter"], + person: { + name: "chase", + incredi: true, + }, + }), + }) + ``` + - *big-brain moment:* the whole chronicle *itself* is stored in the state.. serializable.. think persistence — user can close their project, reopen, and their undo/redo history is still chillin' — *brat girl summer* +- **create a chrono-wrapped lens to interact with your chronicle** + ```ts + const snacks = new Chrono(64, prism.lens(state => state.snacks)) + // 👆 + // how many past snapshots to store + ``` +- **mutations will advance history,** and undo/redo works + ```ts + snacks.mutate(s => s.peanuts = 101) + + snacks.undo() + // back to 8 peanuts + + snacks.redo() + // forward to 101 peanuts + ``` +- **check how many undoable or redoable steps are available** + ```ts + snacks.undoable // 1 + snacks.redoable // 0 + ``` +- **you can make sub-lenses of a chrono,** all their mutations advance history too +- **plz pinky-swear right now,** that you won't create a chrono under a lens under another chrono 💀 + +### 🔮 persistence to localStorage +- **import prism** + ```ts + import {Vault, LocalStore} from "@e280/strata" + ``` +- **create a local storage store** + ```ts + const store = new LocalStore("myAppState") + ``` +- **make a vault for your prism** + ```ts + const vault = new Vault({ + prism, + store, + version: 1, // 👈 bump this when you break your state schema! + }) + ``` + - `store` type is compatible with [`@e280/kv`](https://github.com/e280/kv) +- **cross-tab sync (load on storage events)** + ```ts + store.onStorageEvent(vault.load) + ``` +- **initial load** + ```ts + await vault.load() + ``` + + +

From 223fd44e8baedce815c65f27a24a069d541c8327 Mon Sep 17 00:00:00 2001 From: Chase Moskal Date: Sun, 3 May 2026 22:14:52 -0700 Subject: [PATCH 12/18] update: changelog --- CHANGELOG.md | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ad3c98..e923b7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,8 +11,20 @@ ## v0.4 ### v0.4.0 -- 🟥 huge core rewrite -- 🟥 wait +- 🟥 **huge core rewrite** + - 🟥 everything is now sync, not async anymore. stripped of all debouncing and async shenanigans, now calling `tracker.write` (and thus setting signals, updating prism state, etc) immediately executes all downstream subscribers without any delay. this greatly improves our ability to detect and prevent scary catastropic circular-loop crashes. this also avoids async fatigue spreading through your codebases. the downside is that this could lead to worse performance. + - 🍏 new `batch` fn helps you optimize performance -- batched tracker writes are deduped and flushed at the end of the batch, meaning, effects are only called once. +- 🟥 **tracker** + - 🟥 renamed `tracker.notifyRead` to `tracker.read` + - 🟥 renamed `tracker.notifyWrite` to `tracker.write` +- 🟥 **signals/derived** + - 🟥 eliminated all the funky magic class+fn implementations for dead-simple minimal implementations... new signal module is 18 lines... + - 🟥 removed `$count.on` direct subscriptions -- just use effects + - 🟥 removed `$count.value` accessors -- just use hipster-fn syntax + - 🟥 removed `$count.get()` and `$count.set(v)` methods -- just use hipster-fn syntax + - 🟥 removed comparison logic, now all signal value setting always notifies the tracker, doesn't care if there was a real change + - 🟥 removed `lazy` completely removed -- obsoleted by superior new derived implementation that is lazy +- 🟥 **wait** - 🟥 renamed `WaitDone` to `WaitResult` to better match ok/err/result - 🟥 renamed `newWait` to `makeWait` because i like it more - 🟥 renamed `WaitSignal` to `WaitDerived` because that's what it is From ef31340ef4786cbda4791200d618a1c7fa93ca6f Mon Sep 17 00:00:00 2001 From: Chase Moskal Date: Sun, 3 May 2026 22:20:39 -0700 Subject: [PATCH 13/18] add: r map and set --- s/nova/core/index.ts | 2 ++ s/nova/core/r/map.ts | 64 ++++++++++++++++++++++++++++++++++++++++++++ s/nova/core/r/set.ts | 44 ++++++++++++++++++++++++++++++ 3 files changed, 110 insertions(+) create mode 100644 s/nova/core/r/map.ts create mode 100644 s/nova/core/r/set.ts diff --git a/s/nova/core/index.ts b/s/nova/core/index.ts index fc337cf..1a13ccd 100644 --- a/s/nova/core/index.ts +++ b/s/nova/core/index.ts @@ -1,4 +1,6 @@ +export * from "./r/map.js" +export * from "./r/set.js" export * from "./batch.js" export * from "./derived.js" export * from "./effect.js" diff --git a/s/nova/core/r/map.ts b/s/nova/core/r/map.ts new file mode 100644 index 0000000..d83af90 --- /dev/null +++ b/s/nova/core/r/map.ts @@ -0,0 +1,64 @@ + +import {GMap} from "@e280/stz" +import {tracker} from "../../tracker/tracker.js" + +export class RMap extends GMap { + get size() { + tracker.read(this) + return super.size + } + + ;[Symbol.iterator]() { + tracker.read(this) + return super[Symbol.iterator]() + } + + keys() { + tracker.read(this) + return super.keys() + } + + values() { + tracker.read(this) + return super.values() + } + + entries() { + tracker.read(this) + return super.entries() + } + + forEach(callbackFn: (value: V, key: K, map: Map) => void) { + tracker.read(this) + return super.forEach(callbackFn) + } + + has(key: K) { + tracker.read(this) + return super.has(key) + } + + get(key: K) { + tracker.read(this) + return super.get(key) + } + + set(key: K, value: V) { + const r = super.set(key, value) + tracker.write(this) + return r + } + + delete(key: K) { + const r = super.delete(key) + tracker.write(this) + return r + } + + clear() { + super.clear() + tracker.write(this) + return this + } +} + diff --git a/s/nova/core/r/set.ts b/s/nova/core/r/set.ts new file mode 100644 index 0000000..27e1b87 --- /dev/null +++ b/s/nova/core/r/set.ts @@ -0,0 +1,44 @@ + +import {GSet} from "@e280/stz" +import {tracker} from "../../tracker/tracker.js" + +export class RSet extends GSet { + get size() { + tracker.read(this) + return super.size + } + + ;[Symbol.iterator]() { + tracker.read(this) + return super[Symbol.iterator]() + } + + values() { + tracker.read(this) + return super.values() + } + + has(item: T) { + tracker.read(this) + return super.has(item) + } + + add(item: T) { + super.add(item) + tracker.write(this) + return this + } + + delete(item: T) { + const r = super.delete(item) + tracker.write(this) + return r + } + + clear() { + super.clear() + tracker.write(this) + return this + } +} + From d8f231c592520d725e3d4d858aed0edd04641303 Mon Sep 17 00:00:00 2001 From: Chase Moskal Date: Sun, 3 May 2026 23:14:07 -0700 Subject: [PATCH 14/18] improve: wait readme --- README.md | 104 ++++++++++++++---------------------- s/nova/wait/parts/select.ts | 18 ++++--- 2 files changed, 52 insertions(+), 70 deletions(-) diff --git a/README.md b/README.md index 77293fa..e3bd716 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ 🚦 [**#signals,**](#signals) sweet little bundles of state 🔮 [**#prism,**](#prism) bigger centralized state trees -⌛ [**#wait,**](#wait) representing async operations +⌛ [**#wait,**](#wait) async state helpers *(think loading spinners)* 🪄 [**#tracker,**](#tracker) agnostic reactivity integration hub ⚛️ [**#react,**](#react) optional bindings for react @@ -269,99 +269,75 @@ import {signal, effect, derived, lazy} from "@e280/strata" ## 🍋 strata wait -> *represent async operations* +> *tiny async state helpers* -- wait is designed to vibe with [stz#ok](https://github.com/e280/stz#ok) - ```ts - import {ok, err} from "@e280/stz" - ``` - -### ⌛ wait primitives -- imports - ```ts - import {newWait} from "@e280/strata" - ``` -- helpers to create a `Wait` - ```ts - // loading - newWait() - // {done: false} - - // done, ok - newWait(ok(123)) - // {done: true, ok: true, value: 123} - - // done, err - newWait(err("uh oh")) - // {done: true, ok: false, error: "uh oh"} - ``` +***wait*** is small. *pending, ok, err.* +it extends [stz's ok/err](https://github.com/e280/stz#ok). +it's like, for your ui, showing little loading spinners and branching when stuff is loading. -### ⌛ make a magic reactive wait signal -- imports +### ⌛ good things come to those who wait +- **import stuff** ```ts - import {nap} from "@e280/stz" - import {wait} from "@e280/strata" + import {ok, err, nap} from "@e280/stz" + import {wait, waitFormal} from "@e280/strata" ``` -- it's a derived signal (readonly) that tracks the result of an async fn or promise +- **wrap any async operation in a fancy wait** ```ts + // wrap any async operation in a fancy wait const $wait = wait(async() => { - await nap(100) // do some async stuff - return 123 // return your value + await nap(100) + if (Math.random() > 0.5) return 123 + else throw new Error("bad luck!") }) ``` -- read the current state from the signal + - btw you can pass a promise instead of an async fn +- **check if it's done** ```ts - $wait() - // {done: false} + console.log($wait().done) + // false -- sorry bro, its not ready yet ``` -- later, when it's ready +- **okay, we can actually await for the result** ```ts - await $wait.ready - // 123 - // undefined if there was an error + const result = await $wait.result - $wait() - // {done: true, ok: true, value: 123} + if (result.ok) + console.log(result.value) + // 123 + else + console.error(result.error) + // Error: bad luck! ``` -### ⌛ persnickety belt-and-suspenders mode -- imports - ```ts - import {waitResult} from "@e280/strata" - ``` -- do formal rigid error handling because you're super strict and serious +### ⌛ waitFormal is persnickety belt-and-suspenders mode +- **you can get super explicit about the types** ```ts - const $wait = waitResult(async() => { + const $wait = waitFormal(async() => { if (Math.random() > 0.5) return ok(123) if (Math.random() < 0.01) - return err("unlikely lol") + return err("unlucky") else return err("bad roll") }) ``` -- listen for the formal result - ```ts - await $wait.result - // {done: true, ok: true, value: 123} or - // {done: true, ok: false, error: "bad roll"} - ``` -- btw, wait and waitResult will actually accept a promise if you like + +### ⌛ wait, there's more +- maker ```ts - const $wait = wait(Promise.resolve(123)) + makeWait() // pending + makeWait(ok(123)) + makeWait(err("uh oh")) ``` - -### ⌛ wait helpers -- check the state +- status checkers ```ts isWaitPending($wait()) - isWaitDone($wait()) + isWaitDone($wait()) // ok or err isWaitOk($wait()) isWaitErr($wait()) ``` -- get the finished value or error +- value grabbers ```ts waitGetOk($wait()) // 123 | undefined waitNeedOk($wait()) // 123 (or throws an error) @@ -370,7 +346,7 @@ import {signal, effect, derived, lazy} from "@e280/strata" waitGetErr($wait()) // "bad roll" | undefined waitNeedErr($wait()) // "bad roll" (or throws an error) ``` -- select based on the state +- quick selector ```ts const text = waitSelect($wait(), { pending: () => "still loading...", diff --git a/s/nova/wait/parts/select.ts b/s/nova/wait/parts/select.ts index ac73ad9..6e9fa5f 100644 --- a/s/nova/wait/parts/select.ts +++ b/s/nova/wait/parts/select.ts @@ -4,18 +4,24 @@ import {isWaitOk, isWaitPending} from "./is.js" import {waitNeedErr, waitNeedOk} from "./get.js" export function waitSelect(wait: Wait, select: { - pending: () => Ret, - ok: (value: Value) => Ret - err: (error: E) => Ret + pending?: () => Ret + ok?: (value: Value) => Ret + err?: (error: E) => Ret }) { + const { + pending = () => {}, + ok = () => {}, + err = () => {}, + } = select + if (isWaitPending(wait)) - return select.pending() + return pending() else if (isWaitOk(wait)) - return select.ok(waitNeedOk(wait)) + return ok(waitNeedOk(wait)) else - return select.err(waitNeedErr(wait)) + return err(waitNeedErr(wait)) } From 4c36e8b69e88e6e249af3336fe191e51e9a2b4ec Mon Sep 17 00:00:00 2001 From: Chase Moskal Date: Sun, 3 May 2026 23:18:25 -0700 Subject: [PATCH 15/18] fix: new readme with new wait docs --- s/nova/README.md | 212 +++++++++++++++++++++-------------------------- 1 file changed, 94 insertions(+), 118 deletions(-) diff --git a/s/nova/README.md b/s/nova/README.md index a87cc9b..005bca4 100644 --- a/s/nova/README.md +++ b/s/nova/README.md @@ -15,8 +15,8 @@ 🧑‍💻 a project by https://e280.org/ 🚦 [**#signals,**](#signals) sweet little bundles of state -⌛ [**#wait,**](#wait) helpers for async operation states 🔮 [**#prism,**](#prism) bigger centralized state trees +⌛ [**#wait,**](#wait) async state helpers *(think loading spinners)* 🪄 [**#tracker,**](#tracker) agnostic reactivity integration hub ⚛️ [**#react,**](#react) optional bindings for react @@ -96,123 +96,6 @@ import {signal, derived, effect, batch} from "@e280/strata" -

- - - -## 🍋 strata wait -> *represent async operations* - -- wait is designed to vibe with [stz#ok](https://github.com/e280/stz#ok) - ```ts - import {ok, err} from "@e280/stz" - ``` - -### ⌛ make some wait -- imports - ```ts - import {makeWait} from "@e280/strata" - ``` -- helpers to create a `Wait` - ```ts - // loading - makeWait() - // {done: false} - - // done, ok - makeWait(ok(123)) - // {done: true, ok: true, value: 123} - - // done, err - makeWait(err("uh oh")) - // {done: true, ok: false, error: "uh oh"} - ``` - -### ⌛ make a magic reactive waiter -- imports - ```ts - import {nap} from "@e280/stz" - import {wait} from "@e280/strata" - ``` -- it's a derived signal (readonly) that tracks the result of an async fn or promise - ```ts - const $wait = wait(async() => { - await nap(100) // do some async stuff - return 123 // return your value - }) - ``` -- read the current state from the signal - ```ts - $wait() - // {done: false} - ``` -- later, when it's ready - ```ts - await $wait.ready - // 123 - // undefined if there was an error - - $wait() - // {done: true, ok: true, value: 123} - ``` - -### ⌛ waitFormal, persnickety belt-and-suspenders mode -- imports - ```ts - import {waitFormal} from "@e280/strata" - ``` -- do formal rigid error handling because you're super strict and serious - ```ts - const $wait = waitFormal(async() => { - if (Math.random() > 0.5) - return ok(123) - - if (Math.random() < 0.01) - return err("unlikely lol") - - else - return err("bad roll") - }) - ``` -- listen for the formal result - ```ts - await $wait.result - // {done: true, ok: true, value: 123} or - // {done: true, ok: false, error: "bad roll"} - ``` -- btw, wait and waitFormal will actually accept a promise if you like - ```ts - const $wait = wait(Promise.resolve(123)) - ``` - -### ⌛ wait helpers -- check the state - ```ts - isWaitPending($wait()) - isWaitDone($wait()) - isWaitOk($wait()) - isWaitErr($wait()) - ``` -- get the finished value or error - ```ts - waitGetOk($wait()) // 123 | undefined - waitNeedOk($wait()) // 123 (or throws an error) - ``` - ```ts - waitGetErr($wait()) // "bad roll" | undefined - waitNeedErr($wait()) // "bad roll" (or throws an error) - ``` -- select based on the state - ```ts - const text = waitSelect($wait(), { - pending: () => "still loading...", - ok: value => `ready: ${value}`, - err: error => `ack! ${error}`, - }) - ``` - - -

@@ -350,6 +233,99 @@ import {signal, derived, effect, batch} from "@e280/strata" +

+ + + +## 🍋 strata wait +> *tiny async state helpers* + +***wait*** is small. *pending, ok, err.* +it extends [stz's ok/err](https://github.com/e280/stz#ok). +it's like, for your ui, showing little loading spinners and branching when stuff is loading. + +### ⌛ good things come to those who wait +- **import stuff** + ```ts + import {ok, err, nap} from "@e280/stz" + import {wait, waitFormal} from "@e280/strata" + ``` +- **wrap any async operation in a fancy wait** + ```ts + // wrap any async operation in a fancy wait + const $wait = wait(async() => { + await nap(100) + if (Math.random() > 0.5) return 123 + else throw new Error("bad luck!") + }) + ``` + - btw you can pass a promise instead of an async fn +- **check if it's done** + ```ts + console.log($wait().done) + // false -- sorry bro, its not ready yet + ``` +- **okay, we can actually await for the result** + ```ts + const result = await $wait.result + + if (result.ok) + console.log(result.value) + // 123 + else + console.error(result.error) + // Error: bad luck! + ``` + +### ⌛ waitFormal is persnickety belt-and-suspenders mode +- **you can get super explicit about the types** + ```ts + const $wait = waitFormal(async() => { + if (Math.random() > 0.5) + return ok(123) + + if (Math.random() < 0.01) + return err("unlucky") + + else + return err("bad roll") + }) + ``` + +### ⌛ wait, there's more +- maker + ```ts + makeWait() // pending + makeWait(ok(123)) + makeWait(err("uh oh")) + ``` +- status checkers + ```ts + isWaitPending($wait()) + isWaitDone($wait()) // ok or err + isWaitOk($wait()) + isWaitErr($wait()) + ``` +- value grabbers + ```ts + waitGetOk($wait()) // 123 | undefined + waitNeedOk($wait()) // 123 (or throws an error) + ``` + ```ts + waitGetErr($wait()) // "bad roll" | undefined + waitNeedErr($wait()) // "bad roll" (or throws an error) + ``` +- quick selector + ```ts + const text = waitSelect($wait(), { + pending: () => "still loading...", + ok: value => `ready: ${value}`, + err: error => `ack! ${error}`, + }) + ``` + + +

From 7e4c4952128f603881823e4793922ca6ddedd7cd Mon Sep 17 00:00:00 2001 From: Chase Moskal Date: Sun, 3 May 2026 23:26:24 -0700 Subject: [PATCH 16/18] improve: readme --- s/nova/README.md | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/s/nova/README.md b/s/nova/README.md index 005bca4..1b9994c 100644 --- a/s/nova/README.md +++ b/s/nova/README.md @@ -62,7 +62,8 @@ import {signal, derived, effect, batch} from "@e280/strata" $product() // 🪄 20 ``` - **btw,** - deriveds are lazy and don't run the formula fn unless actually demanded (unless an effect is watching them). also, they have a `.dispose()` fn you can use to stop them. + deriveds are lazy, only run the fn when demanded. + also, they have a `.dispose()` fn if you need to stop them. ### 🚦 effects - **effects run when the relevant signals change** @@ -76,7 +77,8 @@ import {signal, derived, effect, batch} from "@e280/strata" // when $count is changed, the effect fn is run ``` - **btw,** - effects return a dispose fn you can use to stop them. also, you can optionally pass a second parameter fn that receives what the first fn returns, and it's not initially called. + you can also pass an optional second fn param, which receives what the first fn returns, and is not called initially. + also, effect returns a dispose fn if you need to stop it. ### 🚦 batch - **optimize multiple writes into one fat update** @@ -134,17 +136,21 @@ import {signal, derived, effect, batch} from "@e280/strata" const person = snacks.lens(state => state.person) ``` - you can lens another lens -- **lenses provide snapshot access to state** +- **`lens.state` is a cloned mutable snapshot with chill typings** ```ts - // .state is a mutable snapshot with relaxed typings snacks.state.peanuts // 8 person.state.name // "chase" - // ⛔ casual mutations ignored - // .frozen is an immutable snapshot with strict typings - snacks.frozen.peanuts // 8 + snacks.state.peanuts++ + // ⛔ attempted state mutation: silently ignored + ``` +- **`lens.frozen` provides a deep-frozen immutable snapshot with strict typings** + ```ts + snacks.frozen.peanuts // 8 (readonly) + person.frozen.name // "chase" (readonly) + snacks.frozen.peanuts++ - // ⛔ casual mutations throw errors + // ⛔ attempted frozen mutation: throw errors ``` - **only formal mutations can actually change state** ```ts From dfe475dea8abb52c626bcf6ab4eb611771e77cf9 Mon Sep 17 00:00:00 2001 From: Chase Moskal Date: Sun, 3 May 2026 23:31:35 -0700 Subject: [PATCH 17/18] adopt: nova as the real codebase --- README.md | 237 ++++++--------- s/{nova => }/bindings/index.ts | 0 s/{nova => }/bindings/react.ts | 0 s/{nova => }/core/batch.ts | 0 s/{nova => }/core/derived.ts | 0 s/{nova => }/core/effect.ts | 0 s/{nova => }/core/index.ts | 0 s/{nova => }/core/r/map.ts | 0 s/{nova => }/core/r/set.ts | 0 s/{nova => }/core/signal.ts | 0 s/{nova => }/core/test.ts | 0 s/{nova => }/core/types.ts | 0 s/{nova => }/core/utils/watch.ts | 0 s/index.ts | 3 +- s/nova/README.md | 451 ----------------------------- s/nova/index.ts | 5 - s/nova/prism/chrono/chronicle.ts | 11 - s/nova/prism/chrono/chrono.ts | 95 ------ s/nova/prism/chrono/types.ts | 12 - s/nova/prism/index.ts | 11 - s/nova/prism/lens.ts | 60 ---- s/nova/prism/prism.ts | 39 --- s/nova/prism/test.ts | 337 --------------------- s/nova/prism/types.ts | 28 -- s/nova/prism/utils/cache-cell.ts | 22 -- s/nova/prism/utils/immute.ts | 8 - s/nova/prism/utils/optic-symbol.ts | 3 - s/nova/prism/vault/local-store.ts | 31 -- s/nova/prism/vault/types.ts | 22 -- s/nova/prism/vault/vault.ts | 19 -- s/nova/tracker/index.ts | 3 - s/nova/tracker/test.ts | 145 ---------- s/nova/tracker/tracker.ts | 92 ------ s/nova/wait/index.ts | 8 - s/nova/wait/parts/get.ts | 26 -- s/nova/wait/parts/is.ts | 8 - s/nova/wait/parts/select.ts | 27 -- s/nova/wait/parts/type.ts | 18 -- s/nova/wait/parts/wait.ts | 39 --- s/nova/wait/test.ts | 35 --- s/prism/chrono/chrono.ts | 14 +- s/prism/lens.ts | 20 +- s/prism/prism.ts | 17 +- s/prism/test.ts | 142 +++++---- s/prism/types.ts | 4 +- s/prism/vault/vault.ts | 2 +- s/signals/derived/class.ts | 34 --- s/signals/derived/fn.ts | 35 --- s/signals/derived/test.ts | 122 -------- s/signals/effect/effect.ts | 11 - s/signals/effect/test.ts | 172 ----------- s/signals/effect/watch.ts | 32 -- s/signals/index.ts | 18 -- s/signals/lazy/class.ts | 74 ----- s/signals/lazy/fn.ts | 22 -- s/signals/lazy/test.ts | 75 ----- s/signals/r/map.ts | 63 ---- s/signals/r/set.ts | 44 --- s/signals/signal/class.ts | 77 ----- s/signals/signal/fn.ts | 31 -- s/signals/signal/test.ts | 112 ------- s/signals/test.ts | 15 - s/signals/types.ts | 11 - s/signals/utils/default-compare.ts | 5 - s/signals/utils/symbols.ts | 9 - s/test.ts | 8 +- s/tracker/bindings/react.ts | 53 ---- s/tracker/index.ts | 1 - s/tracker/test.ts | 149 ++++++++-- s/tracker/tracker.ts | 114 +++++--- s/wait/index.ts | 2 +- s/wait/parts/is.ts | 4 +- s/{nova => }/wait/parts/make.ts | 0 s/wait/parts/new.ts | 10 - s/wait/parts/select.ts | 18 +- s/wait/parts/type.ts | 16 +- s/wait/parts/wait.ts | 25 +- 77 files changed, 423 insertions(+), 2933 deletions(-) rename s/{nova => }/bindings/index.ts (100%) rename s/{nova => }/bindings/react.ts (100%) rename s/{nova => }/core/batch.ts (100%) rename s/{nova => }/core/derived.ts (100%) rename s/{nova => }/core/effect.ts (100%) rename s/{nova => }/core/index.ts (100%) rename s/{nova => }/core/r/map.ts (100%) rename s/{nova => }/core/r/set.ts (100%) rename s/{nova => }/core/signal.ts (100%) rename s/{nova => }/core/test.ts (100%) rename s/{nova => }/core/types.ts (100%) rename s/{nova => }/core/utils/watch.ts (100%) delete mode 100644 s/nova/README.md delete mode 100644 s/nova/index.ts delete mode 100644 s/nova/prism/chrono/chronicle.ts delete mode 100644 s/nova/prism/chrono/chrono.ts delete mode 100644 s/nova/prism/chrono/types.ts delete mode 100644 s/nova/prism/index.ts delete mode 100644 s/nova/prism/lens.ts delete mode 100644 s/nova/prism/prism.ts delete mode 100644 s/nova/prism/test.ts delete mode 100644 s/nova/prism/types.ts delete mode 100644 s/nova/prism/utils/cache-cell.ts delete mode 100644 s/nova/prism/utils/immute.ts delete mode 100644 s/nova/prism/utils/optic-symbol.ts delete mode 100644 s/nova/prism/vault/local-store.ts delete mode 100644 s/nova/prism/vault/types.ts delete mode 100644 s/nova/prism/vault/vault.ts delete mode 100644 s/nova/tracker/index.ts delete mode 100644 s/nova/tracker/test.ts delete mode 100644 s/nova/tracker/tracker.ts delete mode 100644 s/nova/wait/index.ts delete mode 100644 s/nova/wait/parts/get.ts delete mode 100644 s/nova/wait/parts/is.ts delete mode 100644 s/nova/wait/parts/select.ts delete mode 100644 s/nova/wait/parts/type.ts delete mode 100644 s/nova/wait/parts/wait.ts delete mode 100644 s/nova/wait/test.ts delete mode 100644 s/signals/derived/class.ts delete mode 100644 s/signals/derived/fn.ts delete mode 100644 s/signals/derived/test.ts delete mode 100644 s/signals/effect/effect.ts delete mode 100644 s/signals/effect/test.ts delete mode 100644 s/signals/effect/watch.ts delete mode 100644 s/signals/index.ts delete mode 100644 s/signals/lazy/class.ts delete mode 100644 s/signals/lazy/fn.ts delete mode 100644 s/signals/lazy/test.ts delete mode 100644 s/signals/r/map.ts delete mode 100644 s/signals/r/set.ts delete mode 100644 s/signals/signal/class.ts delete mode 100644 s/signals/signal/fn.ts delete mode 100644 s/signals/signal/test.ts delete mode 100644 s/signals/test.ts delete mode 100644 s/signals/types.ts delete mode 100644 s/signals/utils/default-compare.ts delete mode 100644 s/signals/utils/symbols.ts delete mode 100644 s/tracker/bindings/react.ts rename s/{nova => }/wait/parts/make.ts (100%) delete mode 100644 s/wait/parts/new.ts diff --git a/README.md b/README.md index e3bd716..1b9994c 100644 --- a/README.md +++ b/README.md @@ -30,101 +30,71 @@ > *reactive bundles of joy* ```ts -import {signal, effect, derived, lazy} from "@e280/strata" +import {signal, derived, effect, batch} from "@e280/strata" ``` -### 🚦 each signal holds a value -- **make signal** +### 🚦 signal +- **a signal holds a value** ```ts - const $count = signal(0) + const $count = signal(1) ``` - > *maybe you like the `$` prefix convention for signals?* -- **read signal** + > *we kinda like the `$` convention for signals* +- **read the signal** ```ts - $count() // 0 + $count() // 1 ``` -- **write signal** +- **write the signal** ```ts - $count(1) + $count(2) ``` -- 🤯 **await all downstream effects** - ```ts - await $count(2) - ``` - > *this is supposed to impress you* -### 🚦 pick your poison -- **signal hipster-fn syntax** - ```ts - $count() // read - await $count(2) // write - ``` -- **signal get/set syntax** +### 🚦 derived +- **combine signals like a formula** ```ts - $count.get() // read - await $count.set(2) // write + const $alpha = signal(1) + const $bravo = signal(10) + const $product = derived(() => $alpha() * $bravo()) ``` -- **signal .value accessor syntax** +- **it automatically updates** ```ts - $count.value // read - $count.value = 2 // write - ``` - value pattern is super nice for these vibes - ```ts - $count.value++ - $count.value += 1 + $product() // 10 + $alpha(2) + $product() // 🪄 20 ``` +- **btw,** + deriveds are lazy, only run the fn when demanded. + also, they have a `.dispose()` fn if you need to stop them. ### 🚦 effects - **effects run when the relevant signals change** ```ts effect(() => console.log($count())) - // 1 + // 2 // the system detects '$count' is relevant - $count.value++ - // 2 + $count(3) + // 3 // when $count is changed, the effect fn is run ``` +- **btw,** + you can also pass an optional second fn param, which receives what the first fn returns, and is not called initially. + also, effect returns a dispose fn if you need to stop it. -### 🚦 .on listeners -- **yes, you can do direct callbacks to listen for changes** - ```ts - const off = $count.on(count => console.log(`callback ${count}`)) - - $count.value++ - // "callback 3" - - off() - // stop listening - ``` - -### 🚦 derived signals -- **derived,** for combining signals, like a formula +### 🚦 batch +- **optimize multiple writes into one fat update** ```ts - const $a = signal(1) - const $b = signal(10) - const $product = derived(() => $a() * $b()) - - $product() // 10 - - // change a dependency, - // and the derived signal is automatically updated - await $a(2) - - $product() // 20 + // call downstream effects only once + batch(() => { + $count(4) + $count(5) + $count(6) + }) ``` -- **lazy,** for making special optimizations. - it's like derived, except it cannot trigger effects, - because it's so damned lazy, it only computes the value on read, and only when necessary. - > *i repeat: lazy signals cannot trigger effects!* -### 🚦 types and such -- **`Signaly`** — can be `Signal` or `Derived` or `Lazy` - - these are types for the core primitive classes -- **the classes are funky** - - Signal, Derived, and Lazy classes cannot be subclassed or extended, due to spooky magic we've done to make the instances callable as functions (hipster syntax). - - however, at least `$count instanceof Signal` works, so at least that's working. +### 🚦 types +- **`Signal`** — it's a signal fn +- **`Derived`** — it's a derived fn +- **`Valuable`** — could be `Signal` or `Derived` @@ -166,16 +136,21 @@ import {signal, effect, derived, lazy} from "@e280/strata" const person = snacks.lens(state => state.person) ``` - you can lens another lens -- **lenses provide snapshot access to state** +- **`lens.state` is a cloned mutable snapshot with chill typings** ```ts - // .state is a mutable snapshot with relaxed typings snacks.state.peanuts // 8 person.state.name // "chase" - // .frozen is an immutable snapshot with strict typings - snacks.frozen.peanuts // 8 + snacks.state.peanuts++ + // ⛔ attempted state mutation: silently ignored + ``` +- **`lens.frozen` provides a deep-frozen immutable snapshot with strict typings** + ```ts + snacks.frozen.peanuts // 8 (readonly) + person.frozen.name // "chase" (readonly) + snacks.frozen.peanuts++ - // ⛔ error: casual mutations forbidden + // ⛔ attempted frozen mutation: throw errors ``` - **only formal mutations can actually change state** ```ts @@ -186,7 +161,7 @@ import {signal, effect, derived, lazy} from "@e280/strata" ``` - **array mutations are unironically based, actually** ```ts - await snacks.mutate(state => state.bag.push("salt")) + snacks.mutate(state => state.bag.push("salt")) ``` ### 🔮 chrono for time travel @@ -368,71 +343,40 @@ it's like, for your ui, showing little loading spinners and branching when stuff import {tracker} from "@e280/strata/tracker" ``` -if you're some kinda framework author, making a new ui thing, or a new state concept -- then you can use the `tracker` to jack into the strata reactivity system, and suddenly your stuff will be fully strata-compatible, reactin' and triggerin' with the best of 'em. - -the tracker is agnostic and independent, and doesn't know about strata specifics like signals or trees -- and it would be perfectly reasonable for you to use strata solely to integrate with the tracker, thus making your stuff reactivity-compatible with other libraries that use the tracker, like [sly](https://github.com/e280/sly). - -note, the *items* that the tracker tracks can be any object, or symbol.. the tracker cares about the identity of the item, not the value (tracker holds them in a WeakMap to avoid creating a memory leak).. - -### 🪄 integrate your ui's reactivity -- we need to imagine you have some prerequisites - - `myRenderFn` -- your fn that might access some state stuff - - `myRerenderFn` -- your fn that is called when some state stuff changes - - it's okay if these are the same fn, but they don't have to be -- `tracker.observe` to check what is touched by a fn - ```ts - // 🪄 run myRenderFn and collect seen items - const {seen, result} = tracker.observe(myRenderFn) +this is the inner sanctum of strata. use the tracker to jack into the reactivity system, you can make anything fully strata-compatible and you'll be reactin' and triggerin' with the best of 'em. the tracker is also what you'll need if you're trying to create bindings for your own frontend framework to trigger your ui to rerender and stuff. - // a set of items that were accessed during myRenderFn - seen - - // the value returned by myRenderFn - result - ``` -- it's a good idea to debounce your rerender fn - ```ts - import {microbounce} from "@e280/stz" - const myDebouncedRerenderFn = microbounce(myRerenderFn) - ``` -- `tracker.subscribe` to respond to changes - ```ts - const stoppers: (() => void)[] = [] - - // loop over every seen item - for (const item of seen) { - - // 🪄 react to changes - const stop = tracker.subscribe(item, myDebouncedRerenderFn) +### 🪄 invent your own novel state concept +- let's invent a very simple thing, so you can see how simple the tracker really is. + ```ts + export class BoomerSignal { + constructor(private value: Value) {} - stoppers.push(stop) + get() { + tracker.read(this) // 🪄 inform tracker our thing was accessed + return this.value } + + set(value: Value) { + this.value = value + tracker.write(this) // 🪄 inform tracker our thing was changed + } + } + ``` +- boom, that's it! now we have a new reactive thing we can use, and it'll rerender our ui or whatever. + ```ts + const $count = new BoomerSignal(1) - const stopReactivity = () => stoppers.forEach(stop => stop()) - ``` - -### 🪄 integrate your own novel state concepts -- as an example, we'll invent the simplest possible signal - ```ts - export class SimpleSignal { - constructor(private value: Value) {} - - get() { - - // 🪄 tell the tracker this signal was accessed - tracker.notifyRead(this) - - return this.value - } + effect(() => console.log($count.get())) + // 1 - async set(value: Value) { - this.value = value + $count.set(2) + // 2 + ``` - // 🪄 tell the tracker this signal has changed - await tracker.notifyWrite(this) - } - } - ``` +### 🪄 integrate your ui framework for auto-rerendering +- use `tracker.observe` to check what is touched by a fn +- use `tracker.subscribe` to subscribe to the seen items that `observe` returns +- see the [source code](./tracker/tracker.ts) @@ -442,52 +386,57 @@ note, the *items* that the tracker tracks can be any object, or symbol.. the tra ## 🍋 react bindings +```ts +``` + ### ⚛️ setup your `strata.ts` module ```ts import * as react from "react" -import {react as strata} from "@e280/strata" +import {reactBindings} from "@e280/strata" export const { component, - useStrata, + useTracked, useOnce, useSignal, useDerived, -} = strata(react) +} = reactBindings(react) ``` ### ⚛️ `component` enables fully automatic reactive re-rendering ```ts +import {signal} from "@e280/strata" import {component} from "./strata.js" const $count = signal(0) export const MyCounter = component(() => { - const add = () => $count.value++ + const add = () => $count($count() + 1) return }) ``` -### ⚛️ `useStrata` for a manual hands-on approach (plays nicer with hmr) +### ⚛️ `useTracked` for a manual hands-on approach (plays nicer with hmr) ```ts -import {useStrata} from "./strata.js" +import {signal} from "@e280/strata" +import {useTracked} from "./strata.js" const $count = signal(0) export const MyCounter = () => { - const count = useStrata(() => $count()) - const add = () => $count.value++ + const count = useTracked(() => $count()) + const add = () => $count($count() + 1) return } ``` -### ⚛️ `useSignal` for local component state +### ⚛️ `useSignal` for local component state (and `useDerived`) ```ts import {useSignal} from "./strata.js" export const MyCounter = () => { const $count = useSignal(0) - const add = () => $count.value++ + const add = () => $count($count() + 1) return } ``` diff --git a/s/nova/bindings/index.ts b/s/bindings/index.ts similarity index 100% rename from s/nova/bindings/index.ts rename to s/bindings/index.ts diff --git a/s/nova/bindings/react.ts b/s/bindings/react.ts similarity index 100% rename from s/nova/bindings/react.ts rename to s/bindings/react.ts diff --git a/s/nova/core/batch.ts b/s/core/batch.ts similarity index 100% rename from s/nova/core/batch.ts rename to s/core/batch.ts diff --git a/s/nova/core/derived.ts b/s/core/derived.ts similarity index 100% rename from s/nova/core/derived.ts rename to s/core/derived.ts diff --git a/s/nova/core/effect.ts b/s/core/effect.ts similarity index 100% rename from s/nova/core/effect.ts rename to s/core/effect.ts diff --git a/s/nova/core/index.ts b/s/core/index.ts similarity index 100% rename from s/nova/core/index.ts rename to s/core/index.ts diff --git a/s/nova/core/r/map.ts b/s/core/r/map.ts similarity index 100% rename from s/nova/core/r/map.ts rename to s/core/r/map.ts diff --git a/s/nova/core/r/set.ts b/s/core/r/set.ts similarity index 100% rename from s/nova/core/r/set.ts rename to s/core/r/set.ts diff --git a/s/nova/core/signal.ts b/s/core/signal.ts similarity index 100% rename from s/nova/core/signal.ts rename to s/core/signal.ts diff --git a/s/nova/core/test.ts b/s/core/test.ts similarity index 100% rename from s/nova/core/test.ts rename to s/core/test.ts diff --git a/s/nova/core/types.ts b/s/core/types.ts similarity index 100% rename from s/nova/core/types.ts rename to s/core/types.ts diff --git a/s/nova/core/utils/watch.ts b/s/core/utils/watch.ts similarity index 100% rename from s/nova/core/utils/watch.ts rename to s/core/utils/watch.ts diff --git a/s/index.ts b/s/index.ts index 305e513..58a81b0 100644 --- a/s/index.ts +++ b/s/index.ts @@ -1,6 +1,7 @@ +export * from "./bindings/index.js" +export * from "./core/index.js" export * from "./prism/index.js" -export * from "./signals/index.js" export * from "./tracker/index.js" export * from "./wait/index.js" diff --git a/s/nova/README.md b/s/nova/README.md deleted file mode 100644 index 1b9994c..0000000 --- a/s/nova/README.md +++ /dev/null @@ -1,451 +0,0 @@ - -![](https://i.imgur.com/h7FohWa.jpeg) - - - -

- -# ⛏️ strata - -### get in loser, we're managing state -📦 `npm install @e280/strata` -✨ it's basically about automagically rerendering ui when data changes -🦝 powers auto-reactivity in our view library [@e280/sly](https://github.com/e280/sly) -🧙‍♂️ probably my tenth state management library, lol -🧑‍💻 a project by https://e280.org/ - -🚦 [**#signals,**](#signals) sweet little bundles of state -🔮 [**#prism,**](#prism) bigger centralized state trees -⌛ [**#wait,**](#wait) async state helpers *(think loading spinners)* -🪄 [**#tracker,**](#tracker) agnostic reactivity integration hub -⚛️ [**#react,**](#react) optional bindings for react - - - -

- - - -## 🍋 strata signals -> *reactive bundles of joy* - -```ts -import {signal, derived, effect, batch} from "@e280/strata" -``` - -### 🚦 signal -- **a signal holds a value** - ```ts - const $count = signal(1) - ``` - > *we kinda like the `$` convention for signals* -- **read the signal** - ```ts - $count() // 1 - ``` -- **write the signal** - ```ts - $count(2) - ``` - -### 🚦 derived -- **combine signals like a formula** - ```ts - const $alpha = signal(1) - const $bravo = signal(10) - const $product = derived(() => $alpha() * $bravo()) - ``` -- **it automatically updates** - ```ts - $product() // 10 - $alpha(2) - $product() // 🪄 20 - ``` -- **btw,** - deriveds are lazy, only run the fn when demanded. - also, they have a `.dispose()` fn if you need to stop them. - -### 🚦 effects -- **effects run when the relevant signals change** - ```ts - effect(() => console.log($count())) - // 2 - // the system detects '$count' is relevant - - $count(3) - // 3 - // when $count is changed, the effect fn is run - ``` -- **btw,** - you can also pass an optional second fn param, which receives what the first fn returns, and is not called initially. - also, effect returns a dispose fn if you need to stop it. - -### 🚦 batch -- **optimize multiple writes into one fat update** - ```ts - // call downstream effects only once - batch(() => { - $count(4) - $count(5) - $count(6) - }) - ``` - -### 🚦 types -- **`Signal`** — it's a signal fn -- **`Derived`** — it's a derived fn -- **`Valuable`** — could be `Signal` or `Derived` - - - -

- - - -## 🍋 strata prism -> *persistent app-level state* - -- single-source-of-truth state tree -- no spooky-dookie proxy magic — just god's honest javascript -- immutable except for `mutate(fn)` calls -- use many lenses, efficient reactivity -- chrono provides undo/redo history -- persistence, localstorage, cross-tab sync - -### 🔮 prism and lenses -- **import prism** - ```ts - import {Prism} from "@e280/strata" - ``` -- **prism is a state tree** - ```ts - const prism = new Prism({ - snacks: { - peanuts: 8, - bag: ["popcorn", "butter"], - person: { - name: "chase", - incredi: true, - }, - }, - }) - ``` -- **create lenses, which are views into state subtrees** - ```ts - const snacks = prism.lens(state => state.snacks) - const person = snacks.lens(state => state.person) - ``` - - you can lens another lens -- **`lens.state` is a cloned mutable snapshot with chill typings** - ```ts - snacks.state.peanuts // 8 - person.state.name // "chase" - - snacks.state.peanuts++ - // ⛔ attempted state mutation: silently ignored - ``` -- **`lens.frozen` provides a deep-frozen immutable snapshot with strict typings** - ```ts - snacks.frozen.peanuts // 8 (readonly) - person.frozen.name // "chase" (readonly) - - snacks.frozen.peanuts++ - // ⛔ attempted frozen mutation: throw errors - ``` -- **only formal mutations can actually change state** - ```ts - snacks.mutate(state => state.peanuts++) - // ✅ formal mutations to change state - - snacks.state.peanuts // 9 - ``` -- **array mutations are unironically based, actually** - ```ts - snacks.mutate(state => state.bag.push("salt")) - ``` - -### 🔮 chrono for time travel -- **import stuff** - ```ts - import {Chrono, chronicle} from "@e280/strata" - ``` -- **create a chronicle in your state** - ```ts - const prism = new Prism({ - - // chronicle stores history - // 👇 - snacks: chronicle({ - peanuts: 8, - bag: ["popcorn", "butter"], - person: { - name: "chase", - incredi: true, - }, - }), - }) - ``` - - *big-brain moment:* the whole chronicle *itself* is stored in the state.. serializable.. think persistence — user can close their project, reopen, and their undo/redo history is still chillin' — *brat girl summer* -- **create a chrono-wrapped lens to interact with your chronicle** - ```ts - const snacks = new Chrono(64, prism.lens(state => state.snacks)) - // 👆 - // how many past snapshots to store - ``` -- **mutations will advance history,** and undo/redo works - ```ts - snacks.mutate(s => s.peanuts = 101) - - snacks.undo() - // back to 8 peanuts - - snacks.redo() - // forward to 101 peanuts - ``` -- **check how many undoable or redoable steps are available** - ```ts - snacks.undoable // 1 - snacks.redoable // 0 - ``` -- **you can make sub-lenses of a chrono,** all their mutations advance history too -- **plz pinky-swear right now,** that you won't create a chrono under a lens under another chrono 💀 - -### 🔮 persistence to localStorage -- **import prism** - ```ts - import {Vault, LocalStore} from "@e280/strata" - ``` -- **create a local storage store** - ```ts - const store = new LocalStore("myAppState") - ``` -- **make a vault for your prism** - ```ts - const vault = new Vault({ - prism, - store, - version: 1, // 👈 bump this when you break your state schema! - }) - ``` - - `store` type is compatible with [`@e280/kv`](https://github.com/e280/kv) -- **cross-tab sync (load on storage events)** - ```ts - store.onStorageEvent(vault.load) - ``` -- **initial load** - ```ts - await vault.load() - ``` - - - -

- - - -## 🍋 strata wait -> *tiny async state helpers* - -***wait*** is small. *pending, ok, err.* -it extends [stz's ok/err](https://github.com/e280/stz#ok). -it's like, for your ui, showing little loading spinners and branching when stuff is loading. - -### ⌛ good things come to those who wait -- **import stuff** - ```ts - import {ok, err, nap} from "@e280/stz" - import {wait, waitFormal} from "@e280/strata" - ``` -- **wrap any async operation in a fancy wait** - ```ts - // wrap any async operation in a fancy wait - const $wait = wait(async() => { - await nap(100) - if (Math.random() > 0.5) return 123 - else throw new Error("bad luck!") - }) - ``` - - btw you can pass a promise instead of an async fn -- **check if it's done** - ```ts - console.log($wait().done) - // false -- sorry bro, its not ready yet - ``` -- **okay, we can actually await for the result** - ```ts - const result = await $wait.result - - if (result.ok) - console.log(result.value) - // 123 - else - console.error(result.error) - // Error: bad luck! - ``` - -### ⌛ waitFormal is persnickety belt-and-suspenders mode -- **you can get super explicit about the types** - ```ts - const $wait = waitFormal(async() => { - if (Math.random() > 0.5) - return ok(123) - - if (Math.random() < 0.01) - return err("unlucky") - - else - return err("bad roll") - }) - ``` - -### ⌛ wait, there's more -- maker - ```ts - makeWait() // pending - makeWait(ok(123)) - makeWait(err("uh oh")) - ``` -- status checkers - ```ts - isWaitPending($wait()) - isWaitDone($wait()) // ok or err - isWaitOk($wait()) - isWaitErr($wait()) - ``` -- value grabbers - ```ts - waitGetOk($wait()) // 123 | undefined - waitNeedOk($wait()) // 123 (or throws an error) - ``` - ```ts - waitGetErr($wait()) // "bad roll" | undefined - waitNeedErr($wait()) // "bad roll" (or throws an error) - ``` -- quick selector - ```ts - const text = waitSelect($wait(), { - pending: () => "still loading...", - ok: value => `ready: ${value}`, - err: error => `ack! ${error}`, - }) - ``` - - - -

- - - -## 🍋 strata tracker -> *reactivity integration hub* - -```ts -import {tracker} from "@e280/strata/tracker" -``` - -this is the inner sanctum of strata. use the tracker to jack into the reactivity system, you can make anything fully strata-compatible and you'll be reactin' and triggerin' with the best of 'em. the tracker is also what you'll need if you're trying to create bindings for your own frontend framework to trigger your ui to rerender and stuff. - -### 🪄 invent your own novel state concept -- let's invent a very simple thing, so you can see how simple the tracker really is. - ```ts - export class BoomerSignal { - constructor(private value: Value) {} - - get() { - tracker.read(this) // 🪄 inform tracker our thing was accessed - return this.value - } - - set(value: Value) { - this.value = value - tracker.write(this) // 🪄 inform tracker our thing was changed - } - } - ``` -- boom, that's it! now we have a new reactive thing we can use, and it'll rerender our ui or whatever. - ```ts - const $count = new BoomerSignal(1) - - effect(() => console.log($count.get())) - // 1 - - $count.set(2) - // 2 - ``` - -### 🪄 integrate your ui framework for auto-rerendering -- use `tracker.observe` to check what is touched by a fn -- use `tracker.subscribe` to subscribe to the seen items that `observe` returns -- see the [source code](./tracker/tracker.ts) - - - -

- - - -## 🍋 react bindings - -```ts -``` - -### ⚛️ setup your `strata.ts` module -```ts -import * as react from "react" -import {reactBindings} from "@e280/strata" - -export const { - component, - useTracked, - useOnce, - useSignal, - useDerived, -} = reactBindings(react) -``` - -### ⚛️ `component` enables fully automatic reactive re-rendering -```ts -import {signal} from "@e280/strata" -import {component} from "./strata.js" - -const $count = signal(0) - -export const MyCounter = component(() => { - const add = () => $count($count() + 1) - return -}) -``` - -### ⚛️ `useTracked` for a manual hands-on approach (plays nicer with hmr) -```ts -import {signal} from "@e280/strata" -import {useTracked} from "./strata.js" - -const $count = signal(0) - -export const MyCounter = () => { - const count = useTracked(() => $count()) - const add = () => $count($count() + 1) - return -} -``` - -### ⚛️ `useSignal` for local component state (and `useDerived`) -```ts -import {useSignal} from "./strata.js" - -export const MyCounter = () => { - const $count = useSignal(0) - const add = () => $count($count() + 1) - return -} -``` - - - -

- -## 🧑‍💻 strata is by e280 -free and open source by https://e280.org/ -join us if you're cool and good at dev - diff --git a/s/nova/index.ts b/s/nova/index.ts deleted file mode 100644 index cd17b03..0000000 --- a/s/nova/index.ts +++ /dev/null @@ -1,5 +0,0 @@ - -export * from "./bindings/index.js" -export * from "./core/index.js" -export * from "./tracker/index.js" - diff --git a/s/nova/prism/chrono/chronicle.ts b/s/nova/prism/chrono/chronicle.ts deleted file mode 100644 index 2926e1f..0000000 --- a/s/nova/prism/chrono/chronicle.ts +++ /dev/null @@ -1,11 +0,0 @@ - -import {Chronicle} from "./types.js" - -export function chronicle(state: State): Chronicle { - return { - past: [], - present: state, - future: [], - } -} - diff --git a/s/nova/prism/chrono/chrono.ts b/s/nova/prism/chrono/chrono.ts deleted file mode 100644 index 449c2ab..0000000 --- a/s/nova/prism/chrono/chrono.ts +++ /dev/null @@ -1,95 +0,0 @@ - -import {deep} from "@e280/stz" -import {Lens} from "../lens.js" -import {Chronicle} from "./types.js" -import {LensLike} from "../types.js" -import {_optic} from "../utils/optic-symbol.js" - -export class Chrono implements LensLike { - constructor( - public limit: number, - private basis: Lens>, - ) {} - - get chronicle() { - return this.basis.frozen - } - - get state() { - return this.basis.state.present - } - - get frozen() { - return this.basis.frozen.present - } - - get undoable() { - return this.chronicle.past.length - } - - get redoable() { - return this.chronicle.future.length - } - - #mut(chronicle: Chronicle, fn: (state: State) => R) { - const limit = Math.max(0, this.limit) - const snapshot = deep.clone(this.chronicle.present) as State - const result = fn(chronicle.present) - chronicle.past.push(snapshot) - chronicle.past = chronicle.past.slice(-limit) - chronicle.future = [] - return result - } - - /** progress forwards, depositing history into the past */ - mutate(fn: (state: State) => R): R { - return this.basis.mutate(chronicle => this.#mut(chronicle, fn)) - } - - /** step backwards into the past, by n steps */ - undo(n = 1) { - this.basis.mutate(chronicle => { - const snapshots = chronicle.past.slice(-n) - if (snapshots.length >= n) { - const oldPresent = chronicle.present - chronicle.present = snapshots.shift()! - chronicle.past = chronicle.past.slice(0, -n) - chronicle.future.unshift(oldPresent, ...snapshots) - } - }) - } - - /** step forwards into the future, by n steps */ - redo(n = 1) { - this.basis.mutate(chronicle => { - const snapshots = chronicle.future.slice(0, n) - if (snapshots.length >= n) { - const oldPresent = chronicle.present - chronicle.present = snapshots.shift()! - chronicle.past.push(oldPresent, ...snapshots) - chronicle.future = chronicle.future.slice(n) - } - }) - } - - /** wipe past and future snapshots */ - wipe() { - this.basis.mutate(chronicle => { - chronicle.past = [] - chronicle.future = [] - }) - } - - lens(selector: (state: State) => State2) { - const lens = new Lens({ - registerLens: this.basis[_optic].registerLens, - getState: () => selector(this.basis[_optic].getState().present), - mutate: fn => this.basis[_optic].mutate(chronicle => { - return this.#mut(chronicle, state => fn(selector(state))) - }), - }) - this.basis[_optic].registerLens(lens) - return lens - } -} - diff --git a/s/nova/prism/chrono/types.ts b/s/nova/prism/chrono/types.ts deleted file mode 100644 index d5e4e41..0000000 --- a/s/nova/prism/chrono/types.ts +++ /dev/null @@ -1,12 +0,0 @@ - -export type Chronicle = { - // [abc] d [efg] - // \ \ \ - // \ \ future - // \ present - // past - past: State[] - present: State - future: State[] -} - diff --git a/s/nova/prism/index.ts b/s/nova/prism/index.ts deleted file mode 100644 index dcbe3d9..0000000 --- a/s/nova/prism/index.ts +++ /dev/null @@ -1,11 +0,0 @@ - -export * from "./chrono/chronicle.js" -export * from "./chrono/chrono.js" -export * from "./chrono/types.js" -export * from "./vault/local-store.js" -export * from "./vault/vault.js" -export * from "./vault/types.js" -export * from "./lens.js" -export * from "./prism.js" -export * from "./types.js" - diff --git a/s/nova/prism/lens.ts b/s/nova/prism/lens.ts deleted file mode 100644 index 55334c8..0000000 --- a/s/nova/prism/lens.ts +++ /dev/null @@ -1,60 +0,0 @@ - -import {deep} from "@e280/stz" -import {immute} from "./utils/immute.js" -import {tracker} from "../tracker/tracker.js" -import {_optic} from "./utils/optic-symbol.js" -import {CacheCell} from "./utils/cache-cell.js" -import {Immutable, LensLike, Optic} from "./types.js" - -/** reactive view into a state prism, with formalized mutations */ -export class Lens implements LensLike { - ;[_optic]: Optic - #previous: State - #stateCache: CacheCell - #frozenCache: CacheCell> - - constructor(optic: Optic) { - this[_optic] = optic - this.#previous = deep.clone(optic.getState()) - this.#stateCache = new CacheCell(() => deep.clone(optic.getState())) - this.#frozenCache = new CacheCell(() => immute(optic.getState())) - } - - update() { - const state = this[_optic].getState() - const isChanged = !deep.equal(state, this.#previous) - if (isChanged) { - this.#stateCache.invalidate() - this.#frozenCache.invalidate() - this.#previous = deep.clone(state) - tracker.write(this) - } - } - - /** get a snapshot of the current state. it's typed as mutable, but you should not mutate it. */ - get state() { - tracker.read(this) - return this.#stateCache.get() - } - - /** get an immutable readonly snapshot of the current state. */ - get frozen() { - tracker.read(this) - return this.#frozenCache.get() - } - - mutate(fn: (state: State) => R) { - return this[_optic].mutate(fn) - } - - lens(selector: (state: State) => State2) { - const lens = new Lens({ - getState: () => selector(this[_optic].getState()), - mutate: fn => this[_optic].mutate(state => fn(selector(state))), - registerLens: this[_optic].registerLens, - }) - this[_optic].registerLens(lens) - return lens - } -} - diff --git a/s/nova/prism/prism.ts b/s/nova/prism/prism.ts deleted file mode 100644 index 1f603c0..0000000 --- a/s/nova/prism/prism.ts +++ /dev/null @@ -1,39 +0,0 @@ - -import {Lens} from "./lens.js" -import {tracker} from "../tracker/tracker.js" - -/** state mangagement source-of-truth */ -export class Prism { - #state: State - #lenses = new Set>() - - constructor(state: State) { - this.#state = state - } - - get() { - tracker.read(this) - return this.#state - } - - set(state: State) { - this.#state = state - for (const lens of this.#lenses) lens.update() - tracker.write(this) - } - - lens(selector: (state: State) => State2) { - const lens = new Lens({ - getState: () => selector(this.#state), - mutate: fn => { - const result = fn(selector(this.#state)) - this.set(this.#state) - return result - }, - registerLens: lens => this.#lenses.add(lens), - }) - this.#lenses.add(lens) - return lens - } -} - diff --git a/s/nova/prism/test.ts b/s/nova/prism/test.ts deleted file mode 100644 index 6be32c3..0000000 --- a/s/nova/prism/test.ts +++ /dev/null @@ -1,337 +0,0 @@ - -import {suite, test, expect} from "@e280/science" - -import {Prism} from "./prism.js" -import {effect} from "../core/effect.js" -import {Chrono} from "./chrono/chrono.js" -import {chronicle} from "./chrono/chronicle.js" -import { batch } from "../core/batch.js" - -export default suite({ - "prism": suite({ - "get/set state": test(async() => { - const prism = new Prism({count: 1}) - expect(prism.get().count).is(1) - prism.set({count: 2}) - expect(prism.get().count).is(2) - }), - - "get/set state can trigger effects": test(async() => { - const prism = new Prism({count: 1}) - let triggered = 0 - effect(() => { - void prism.get().count - triggered++ - }) - expect(triggered).is(1) - prism.set({count: 2}) - expect(triggered).is(2) - }), - }), - - "lens": suite({ - "get state": test(async() => { - const prism = new Prism({data: {count: 1}}) - const lens = prism.lens(s => s.data) - expect(lens.frozen.count).is(1) - }), - - "state is immutable": test(async() => { - const prism = new Prism({data: {count: 1}}) - const lens = prism.lens(s => s.data) - expect(() => (lens.frozen as any).count++).throws() - }), - - "proper mutation": test(async() => { - const prism = new Prism({data: {count: 1}}) - const lens = prism.lens(s => s.data) - lens.mutate(s => s.count++) - expect(lens.frozen.count).is(2) - lens.mutate(s => s.count++) - expect(lens.frozen.count).is(3) - }), - - "state after mutation is frozen": test(async() => { - const prism = new Prism({data: {count: 1}}) - const lens = prism.lens(s => s) - lens.mutate(s => s.data = {count: 2}) - expect(lens.frozen.data.count).is(2) - expect(() => (lens.frozen.data as any).count++).throws() - }), - - "effect reacts": test(async() => { - const prism = new Prism({data: {count: 1}}) - const lens = prism.lens(s => s.data) - let happenings = 0 - effect(() => { - void lens.frozen.count - happenings++ - }) - lens.mutate(s => s.count++) - expect(happenings).is(2) - }), - - "effects can be batched": test(async() => { - const prism = new Prism({data: {count: 1}}) - const lens = prism.lens(s => s.data) - let happenings = 0 - effect(() => { - void lens.frozen.count - happenings++ - }) - batch(() => { - lens.mutate(s => s.count++) - lens.mutate(s => s.count++) - }) - expect(happenings).is(2) - }), - - "array pushes are reactive": test(async() => { - const prism = new Prism({data: {array: ["lol"]}}) - const lens = prism.lens(s => s.data) - let happenings = 0 - effect(() => lens.state, () => happenings++) - lens.mutate(s => s.array.push("lmao")) - expect(happenings).is(1) - expect(lens.frozen.array.length).is(2) - }), - - "sync coherence": test(async() => { - const prism = new Prism({data: {count: 1}}) - const lens = prism.lens(s => s.data) - lens.mutate(s => s.count++) - expect(lens.frozen.count).is(2) - lens.mutate(s => s.count++) - expect(lens.frozen.count).is(3) - }), - - "nullable selector": test(async() => { - type S = {a?: {b: {count: number}}} - const prism = new Prism({a: {b: {count: 1}}}) - const lens = prism.lens(s => s.a?.b) - expect(lens.frozen?.count).is(1) - prism.set({a: undefined}) - expect(lens.frozen?.count).is(undefined) - }), - - "deep composition": test(async() => { - const prism = new Prism({a: {b: {count: 1}}}) - const lensA = prism.lens(s => s.a) - const lensB = lensA.lens(s => s.b) - expect(prism.get().a.b.count).is(1) - expect(lensA.frozen.b.count).is(1) - expect(lensB.frozen.count).is(1) - }), - - "deep mutations": test(async() => { - const prism = new Prism({a: {b: {count: 1}}}) - const lensA = prism.lens(s => s.a) - const lensB = lensA.lens(s => s.b) - lensB.mutate(s => s.count++) - expect(prism.get().a.b.count).is(2) - expect(lensA.frozen.b.count).is(2) - expect(lensB.frozen.count).is(2) - lensA.mutate(s => s.b = {count: 3}) - expect(prism.get().a.b.count).is(3) - expect(lensA.frozen.b.count).is(3) - expect(lensB.frozen.count).is(3) - }), - - "outside mutations ignored": test(async() => { - const prism = new Prism({a: {count: 1}, b: {count: 101}}) - const lensA = prism.lens(s => s.a) - const lensB = prism.lens(s => s.b) - let happeningsA = 0 - let happeningsB = 0 - effect(() => lensA.state, () => happeningsA++) - effect(() => lensB.state, () => happeningsB++) - lensA.mutate(s => s.count++) - expect(happeningsA).is(1) - expect(happeningsB).is(0) - }), - - "outside mutations ignored for effects": test(async() => { - const prism = new Prism({a: {count: 1}, b: {count: 101}}) - const lensA = prism.lens(s => s.a) - const lensB = prism.lens(s => s.b) - let happeningsA = 0 - let happeningsB = 0 - effect(() => { - void lensA.frozen.count - happeningsA++ - }) - effect(() => { - void lensB.frozen.count - happeningsB++ - }) - lensA.mutate(s => s.count++) - expect(happeningsA).is(2) - expect(happeningsB).is(1) - }), - }), - - "chrono": suite({ - "get present state": test(async() => { - const prism = new Prism({data: chronicle({count: 1})}) - const lens = prism.lens(s => s.data) - const chrono = new Chrono(64, lens) - expect(chrono.frozen.count).is(1) - }), - - "mutation": test(async() => { - const prism = new Prism({data: chronicle({count: 1})}) - const lens = prism.lens(s => s.data) - const chrono = new Chrono(64, lens) - chrono.mutate(s => s.count++) - expect(chrono.frozen.count).is(2) - chrono.mutate(s => s.count++) - expect(chrono.frozen.count).is(3) - }), - - "undoable/redoable": test(async() => { - const prism = new Prism({data: chronicle({count: 1})}) - const lens = prism.lens(s => s.data) - const chrono = new Chrono(64, lens) - expect(chrono.undoable).is(0) - expect(chrono.redoable).is(0) - chrono.mutate(s => s.count++) - expect(chrono.undoable).is(1) - chrono.mutate(s => s.count++) - expect(chrono.undoable).is(2) - chrono.undo() - expect(chrono.undoable).is(1) - expect(chrono.redoable).is(1) - chrono.undo() - expect(chrono.undoable).is(0) - expect(chrono.redoable).is(2) - chrono.redo() - expect(chrono.undoable).is(1) - expect(chrono.redoable).is(1) - }), - - "undo": test(async() => { - const prism = new Prism({data: chronicle({count: 1})}) - const lens = prism.lens(s => s.data) - const chrono = new Chrono(64, lens) - - chrono.mutate(s => s.count++) - expect(chrono.frozen.count).is(2) - - chrono.undo() - expect(chrono.frozen.count).is(1) - }), - - "sync undo": test(async() => { - const prism = new Prism({data: chronicle({count: 1})}) - const lens = prism.lens(s => s.data) - const chrono = new Chrono(64, lens) - - chrono.mutate(s => s.count++) - expect(chrono.frozen.count).is(2) - - chrono.undo() - expect(chrono.frozen.count).is(1) - }), - - "redo": test(async() => { - const prism = new Prism({data: chronicle({count: 1})}) - const lens = prism.lens(s => s.data) - const chrono = new Chrono(64, lens) - - chrono.mutate(s => s.count++) - expect(chrono.frozen.count).is(2) - - chrono.undo() - expect(chrono.frozen.count).is(1) - - chrono.redo() - expect(chrono.frozen.count).is(2) - }), - - "undo/redo is orderly": test(async() => { - const prism = new Prism({data: chronicle({count: 1})}) - const lens = prism.lens(s => s.data) - const chrono = new Chrono(64, lens) - - chrono.mutate(s => s.count++) - chrono.mutate(s => s.count++) - chrono.mutate(s => s.count++) - expect(chrono.frozen.count).is(4) - - chrono.undo() - expect(chrono.frozen.count).is(3) - - chrono.undo() - expect(chrono.frozen.count).is(2) - - chrono.redo() - expect(chrono.frozen.count).is(3) - - chrono.redo() - expect(chrono.frozen.count).is(4) - - chrono.undo() - expect(chrono.frozen.count).is(3) - - chrono.undo() - expect(chrono.frozen.count).is(2) - - chrono.undo() - expect(chrono.frozen.count).is(1) - }), - - "undo nothing does nothing": test(async() => { - const prism = new Prism({data: chronicle({count: 1})}) - const lens = prism.lens(s => s.data) - const chrono = new Chrono(64, lens) - chrono.undo() - expect(chrono.frozen.count).is(1) - }), - - "redo nothing does nothing": test(async() => { - const prism = new Prism({data: chronicle({count: 1})}) - const lens = prism.lens(s => s.data) - const chrono = new Chrono(64, lens) - chrono.redo() - expect(chrono.frozen.count).is(1) - }), - - "undo 2x": test(async() => { - const prism = new Prism({data: chronicle({count: 1})}) - const lens = prism.lens(s => s.data) - const chrono = new Chrono(64, lens) - chrono.mutate(s => s.count++) - chrono.mutate(s => s.count++) - chrono.mutate(s => s.count++) - expect(chrono.frozen.count).is(4) - chrono.undo(2) - expect(chrono.frozen.count).is(2) - }), - - "redo 2x": test(async() => { - const prism = new Prism({data: chronicle({count: 1})}) - const lens = prism.lens(s => s.data) - const chrono = new Chrono(64, lens) - chrono.mutate(s => s.count++) - chrono.mutate(s => s.count++) - chrono.mutate(s => s.count++) - expect(chrono.frozen.count).is(4) - chrono.undo(2) - expect(chrono.frozen.count).is(2) - chrono.redo(2) - expect(chrono.frozen.count).is(4) - }), - - "sublens mutations are undoable": test(async() => { - const prism = new Prism({data: chronicle({a: {count: 1}})}) - const chrono = new Chrono(64, prism.lens(s => s.data)) - const sublens = chrono.lens(s => s.a) - expect(sublens.frozen.count).is(1) - sublens.mutate(s => s.count++) - expect(sublens.frozen.count).is(2) - chrono.undo() - expect(sublens.frozen.count).is(1) - }), - }), -}) - diff --git a/s/nova/prism/types.ts b/s/nova/prism/types.ts deleted file mode 100644 index 0f00556..0000000 --- a/s/nova/prism/types.ts +++ /dev/null @@ -1,28 +0,0 @@ - -import {Lens} from "./lens.js" - -export type LensLike = { - readonly state: State - readonly frozen: Immutable - mutate(fn: (state: State) => R): R - lens(selector: (state: State) => S2): Lens -} - -export type Optic = { - getState: () => State - mutate: (fn: (state: State) => R) => R - registerLens: (lens: Lens) => void -} - -export type Immutable = - T extends (...args: any[]) => any ? T : - T extends readonly any[] ? ReadonlyArray> : - T extends object ? { readonly [K in keyof T]: Immutable } : - T - -export type Mutable = - T extends (...args: any[]) => any ? T : - T extends ReadonlyArray ? Mutable[] : - T extends object ? { -readonly [K in keyof T]: Mutable } : - T - diff --git a/s/nova/prism/utils/cache-cell.ts b/s/nova/prism/utils/cache-cell.ts deleted file mode 100644 index db06c86..0000000 --- a/s/nova/prism/utils/cache-cell.ts +++ /dev/null @@ -1,22 +0,0 @@ - -export class CacheCell { - #dirty = false - #value: R - - constructor(private calculate: () => R) { - this.#value = calculate() - } - - get() { - if (this.#dirty) { - this.#dirty = false - this.#value = this.calculate() - } - return this.#value - } - - invalidate() { - this.#dirty = true - } -} - diff --git a/s/nova/prism/utils/immute.ts b/s/nova/prism/utils/immute.ts deleted file mode 100644 index 4e11d5a..0000000 --- a/s/nova/prism/utils/immute.ts +++ /dev/null @@ -1,8 +0,0 @@ - -import {deep} from "@e280/stz" -import {Immutable} from "../types.js" - -export function immute(s: S) { - return deep.freeze(deep.clone(s)) as Immutable -} - diff --git a/s/nova/prism/utils/optic-symbol.ts b/s/nova/prism/utils/optic-symbol.ts deleted file mode 100644 index 3fd0437..0000000 --- a/s/nova/prism/utils/optic-symbol.ts +++ /dev/null @@ -1,3 +0,0 @@ - -export const _optic = Symbol("optic") - diff --git a/s/nova/prism/vault/local-store.ts b/s/nova/prism/vault/local-store.ts deleted file mode 100644 index 83238bb..0000000 --- a/s/nova/prism/vault/local-store.ts +++ /dev/null @@ -1,31 +0,0 @@ - -import {Cubby} from "./types.js" - -export class LocalStore implements Cubby { - constructor( - private key: string, - private storage: Storage = window.localStorage, - ) {} - - async get() { - const json = this.storage.getItem(this.key) - return json - ? JSON.parse(json) - : undefined - } - - async set(data: X) { - const json = JSON.stringify(data) - this.storage.setItem(this.key, json) - } - - onStorageEvent(fn: () => void) { - const listener = (event: StorageEvent) => { - if (event.storageArea === this.storage && event.key === this.key) - fn() - } - window.addEventListener("storage", listener) - return () => window.removeEventListener("storage", listener) - } -} - diff --git a/s/nova/prism/vault/types.ts b/s/nova/prism/vault/types.ts deleted file mode 100644 index 1de264a..0000000 --- a/s/nova/prism/vault/types.ts +++ /dev/null @@ -1,22 +0,0 @@ - -import {Prism} from "../prism.js" - -export type Versioned = { - state: State - version: number -} - -export type Cubby = { - get(): Promise - set(data: X | undefined): Promise -} - -/** @deprecated renamed to `Cubby` */ -export type EzStore = Cubby - -export type VaultOptions = { - version: number - prism: Prism - store: Cubby> -} - diff --git a/s/nova/prism/vault/vault.ts b/s/nova/prism/vault/vault.ts deleted file mode 100644 index c2b21c2..0000000 --- a/s/nova/prism/vault/vault.ts +++ /dev/null @@ -1,19 +0,0 @@ - -import {VaultOptions} from "./types.js" - -export class Vault { - constructor(private options: VaultOptions) {} - - load = async() => { - const {store, version, prism} = this.options - const pickle = await store.get() - if (pickle && pickle.version === version) - prism.set(pickle.state) - } - - save = async() => { - const {store, version, prism} = this.options - await store.set({version, state: prism.get()}) - } -} - diff --git a/s/nova/tracker/index.ts b/s/nova/tracker/index.ts deleted file mode 100644 index 3388e88..0000000 --- a/s/nova/tracker/index.ts +++ /dev/null @@ -1,3 +0,0 @@ - -export * from "./tracker.js" - diff --git a/s/nova/tracker/test.ts b/s/nova/tracker/test.ts deleted file mode 100644 index ad59c12..0000000 --- a/s/nova/tracker/test.ts +++ /dev/null @@ -1,145 +0,0 @@ - -import {Tracker} from "./tracker.js" -import {science, test, expect} from "@e280/science" - -export default science.suite({ - "subscriptions": test(async() => { - const tracker = new Tracker() - let alpha = 0 - let bravo = 0 - const item = {} - const alphaOff = tracker.subscribe(item, () => alpha++) - tracker.subscribe(item, () => bravo++) - - tracker.write(item) - expect(alpha).is(1) - expect(bravo).is(1) - - tracker.write(item) - expect(alpha).is(2) - expect(bravo).is(2) - - alphaOff() - tracker.write(item) - expect(alpha).is(2) - expect(bravo).is(3) - }), - - "observe": test(async() => { - const tracker = new Tracker() - const item = {} - - const {seen, value} = tracker.observe(() => { - tracker.read(item) - return 123 - }) - - expect(seen.has(item)).is(true) - expect(value).is(123) - }), - - "nested observe layers are isolated": test(async() => { - const tracker = new Tracker() - const outer = {} - const inner = {} - - const observed = tracker.observe(() => { - tracker.read(outer) - - const nested = tracker.observe(() => { - tracker.read(inner) - }) - - expect(nested.seen.has(inner)).is(true) - expect(nested.seen.has(outer)).is(false) - }) - - expect(observed.seen.has(outer)).is(true) - expect(observed.seen.has(inner)).is(false) - }), - - "batch dedupes subscriber calls": test(async() => { - const tracker = new Tracker() - let called = 0 - const item = {} - - tracker.subscribe(item, () => called++) - - tracker.batch(() => { - tracker.write(item) - tracker.write(item) - tracker.write(item) - }) - - expect(called).is(1) - }), - - "nested batch flushes only once": test(async() => { - const tracker = new Tracker() - let called = 0 - const item = {} - - tracker.subscribe(item, () => called++) - - tracker.batch(() => { - tracker.write(item) - - tracker.batch(() => { - tracker.write(item) - expect(called).is(0) - }) - - expect(called).is(0) - }) - - expect(called).is(1) - }), - - "batch flushes cascading writes in waves": test(async() => { - const tracker = new Tracker() - const a = {} - const b = {} - const calls: string[] = [] - - tracker.subscribe(a, () => { - calls.push("a") - tracker.write(b) - }) - - tracker.subscribe(b, () => { - calls.push("b") - }) - - tracker.batch(() => { - tracker.write(a) - }) - - expect(calls.join(",")).is("a,b") - }), - - "circularity is forbidden": test(async() => { - const tracker = new Tracker() - const item = {} - - const fn = () => tracker.write(item) - tracker.subscribe(item, fn) - - expect(() => tracker.write(item)).throws() - }), - - "same subscriber rescheduled during flush is forbidden": test(async() => { - const tracker = new Tracker() - const item = {} - let calls = 0 - - const fn = () => { - calls++ - tracker.write(item) - } - - tracker.subscribe(item, fn) - - expect(() => tracker.write(item)).throws() - expect(calls).is(1) - }), -}) diff --git a/s/nova/tracker/tracker.ts b/s/nova/tracker/tracker.ts deleted file mode 100644 index b3debdc..0000000 --- a/s/nova/tracker/tracker.ts +++ /dev/null @@ -1,92 +0,0 @@ - -import {GWeakMap} from "@e280/stz" - -export type Trackable = object | symbol - -/** - * reactivity integration hub - */ -export class Tracker { - #busy = new Set<() => void>() - #observationLayers: Set[] = [] - #subscriptions = new GWeakMap void>>() - - #batchDepth = 0 - #batchPending = new Set<() => void>() - - /** indicate to observers that this item was accessed */ - read(item: Item) { - const top = this.#observationLayers.at(-1) - top?.add(item) - } - - /** invoke all subscriptions for this item */ - write(item: Item) { - const fns = this.#subscriptions.get(item) - if (!fns) return - - for (const fn of fns) - this.#batchPending.add(fn) - - if (this.#batchDepth === 0) - this.#flush() - } - - /** collect items that were read during fn */ - observe(fn: () => Value) { - const seen = new Set() - this.#observationLayers.push(seen) - try { - const value = fn() - return {seen, value} - } - finally { - this.#observationLayers.pop() - } - } - - /** fn will be called when item changes */ - subscribe(item: Item, fn: () => void) { - const fns = this.#subscriptions.guarantee(item, () => new Set()) - fns.add(fn) - return () => { - fns.delete(fn) - if (fns.size === 0) - this.#subscriptions.delete(item) - } - } - - batch = (fn: () => R) => { - this.#batchDepth++ - try { - return fn() - } - finally { - this.#batchDepth-- - if (this.#batchDepth === 0) - this.#flush() - } - } - - #run(fn: () => void) { - if (this.#busy.has(fn)) - throw new Error("circularity forbidden") - this.#busy.add(fn) - try { fn() } - finally { this.#busy.delete(fn) } - } - - #flush() { - while (this.#batchPending.size > 0) { - const pending = [...this.#batchPending] - this.#batchPending.clear() - - for (const fn of pending) - this.#run(fn) - } - } -} - -/** standard global tracker for integrations */ -export const tracker: Tracker = (globalThis as any)[Symbol.for("e280.tracker.2")] ??= new Tracker() - diff --git a/s/nova/wait/index.ts b/s/nova/wait/index.ts deleted file mode 100644 index 538790d..0000000 --- a/s/nova/wait/index.ts +++ /dev/null @@ -1,8 +0,0 @@ - -export * from "./parts/get.js" -export * from "./parts/is.js" -export * from "./parts/make.js" -export * from "./parts/select.js" -export * from "./parts/wait.js" -export * from "./parts/type.js" - diff --git a/s/nova/wait/parts/get.ts b/s/nova/wait/parts/get.ts deleted file mode 100644 index 10caf8a..0000000 --- a/s/nova/wait/parts/get.ts +++ /dev/null @@ -1,26 +0,0 @@ - -import {getErr, getOk, needErr, needOk} from "@e280/stz" -import {Wait} from "./type.js" - -export function waitGetOk(wait: Wait) { - return wait.done - ? getOk(wait) - : undefined -} - -export function waitNeedOk(wait: Wait) { - if (!wait.done) throw new Error("wait not done") - return needOk(wait) -} - -export function waitGetErr(wait: Wait) { - return wait.done - ? getErr(wait) - : undefined -} - -export function waitNeedErr(wait: Wait) { - if (!wait.done) throw new Error("wait not done") - return needErr(wait) -} - diff --git a/s/nova/wait/parts/is.ts b/s/nova/wait/parts/is.ts deleted file mode 100644 index 31710eb..0000000 --- a/s/nova/wait/parts/is.ts +++ /dev/null @@ -1,8 +0,0 @@ - -import {Wait, WaitResult, WaitErr, WaitOk, WaitPending} from "./type.js" - -export const isWaitPending = (wait: Wait): wait is WaitPending => !wait.done -export const isWaitDone = (wait: Wait): wait is WaitResult => wait.done -export const isWaitOk = (wait: Wait): wait is WaitOk => wait.done && wait.ok -export const isWaitErr = (wait: Wait): wait is WaitErr => wait.done && !wait.ok - diff --git a/s/nova/wait/parts/select.ts b/s/nova/wait/parts/select.ts deleted file mode 100644 index 6e9fa5f..0000000 --- a/s/nova/wait/parts/select.ts +++ /dev/null @@ -1,27 +0,0 @@ - -import {Wait} from "./type.js" -import {isWaitOk, isWaitPending} from "./is.js" -import {waitNeedErr, waitNeedOk} from "./get.js" - -export function waitSelect(wait: Wait, select: { - pending?: () => Ret - ok?: (value: Value) => Ret - err?: (error: E) => Ret - }) { - - const { - pending = () => {}, - ok = () => {}, - err = () => {}, - } = select - - if (isWaitPending(wait)) - return pending() - - else if (isWaitOk(wait)) - return ok(waitNeedOk(wait)) - - else - return err(waitNeedErr(wait)) -} - diff --git a/s/nova/wait/parts/type.ts b/s/nova/wait/parts/type.ts deleted file mode 100644 index b1855b7..0000000 --- a/s/nova/wait/parts/type.ts +++ /dev/null @@ -1,18 +0,0 @@ - -import {Err, Ok, Result} from "@e280/stz" -import {Derived} from "../../core/types.js" - -export type WaitPending = {done: false} -export type WaitResult = {done: true} & Result -export type WaitOk = {done: true} & Ok -export type WaitErr = {done: true} & Err - -export type Wait = - | WaitPending - | WaitResult - -export type WaitDerived = Derived> & { - ready: Promise - result: Promise> -} - diff --git a/s/nova/wait/parts/wait.ts b/s/nova/wait/parts/wait.ts deleted file mode 100644 index 89f1b3a..0000000 --- a/s/nova/wait/parts/wait.ts +++ /dev/null @@ -1,39 +0,0 @@ - -import {attemptAsync, getOk, Result} from "@e280/stz" - -import {makeWait} from "./make.js" -import {signal} from "../../core/signal.js" -import {derived} from "../../core/derived.js" -import {Wait, WaitResult, WaitDerived} from "./type.js" - -export function wait( - input: Promise | (() => Promise), - ) { - return waitFormal(attemptAsync(input)) -} - -export function waitFormal( - input: Promise> | (() => Promise>), - ) { - return waitFormalPromise( - (typeof input === "function") - ? input() - : input - ) -} - -function waitFormalPromise(promise: Promise>) { - const $wait = signal>(makeWait()) - const $derived = derived(() => $wait()) as WaitDerived - - $derived.result = promise.then(result => { - const r: WaitResult = {done: true, ...result} - $wait(r) - return r - }) - - $derived.ready = $derived.result.then(getOk) - - return $derived -} - diff --git a/s/nova/wait/test.ts b/s/nova/wait/test.ts deleted file mode 100644 index 264da5d..0000000 --- a/s/nova/wait/test.ts +++ /dev/null @@ -1,35 +0,0 @@ - -import {nap, needOk} from "@e280/stz" -import {expect, suite, test} from "@e280/science" - -import {wait} from "./parts/wait.js" -import {waitNeedErr, waitNeedOk} from "./parts/get.js" -import {isWaitDone, isWaitErr, isWaitPending} from "./parts/is.js" - -export default suite({ - "wait fn, done": test(async() => { - const $wait = wait(async() => { - await nap() - return 123 - }) - expect(isWaitPending($wait())).is(true) - expect(await $wait.ready).is(123) - expect(needOk(await $wait.result)).is(123) - expect(isWaitDone($wait())).is(true) - expect(waitNeedOk($wait())).is(123) - }), - - "wait fn, failed": test(async() => { - const $wait = wait(async() => { - await nap() - if (!!true) throw new Error("uh oh") - return 123 - }) - expect(isWaitPending($wait())).is(true) - expect(await $wait.ready).is(undefined) - expect((await $wait.result).ok).is(false) - expect(isWaitErr($wait())).is(true) - expect(waitNeedErr($wait()).message).is("uh oh") - }), -}) - diff --git a/s/prism/chrono/chrono.ts b/s/prism/chrono/chrono.ts index e1fc313..449c2ab 100644 --- a/s/prism/chrono/chrono.ts +++ b/s/prism/chrono/chrono.ts @@ -42,13 +42,13 @@ export class Chrono implements LensLike { } /** progress forwards, depositing history into the past */ - async mutate(fn: (state: State) => R): Promise { + mutate(fn: (state: State) => R): R { return this.basis.mutate(chronicle => this.#mut(chronicle, fn)) } /** step backwards into the past, by n steps */ - async undo(n = 1) { - await this.basis.mutate(chronicle => { + undo(n = 1) { + this.basis.mutate(chronicle => { const snapshots = chronicle.past.slice(-n) if (snapshots.length >= n) { const oldPresent = chronicle.present @@ -60,8 +60,8 @@ export class Chrono implements LensLike { } /** step forwards into the future, by n steps */ - async redo(n = 1) { - await this.basis.mutate(chronicle => { + redo(n = 1) { + this.basis.mutate(chronicle => { const snapshots = chronicle.future.slice(0, n) if (snapshots.length >= n) { const oldPresent = chronicle.present @@ -73,8 +73,8 @@ export class Chrono implements LensLike { } /** wipe past and future snapshots */ - async wipe() { - await this.basis.mutate(chronicle => { + wipe() { + this.basis.mutate(chronicle => { chronicle.past = [] chronicle.future = [] }) diff --git a/s/prism/lens.ts b/s/prism/lens.ts index 9ec7c56..55334c8 100644 --- a/s/prism/lens.ts +++ b/s/prism/lens.ts @@ -1,5 +1,5 @@ -import {deep, microbounce, sub} from "@e280/stz" +import {deep} from "@e280/stz" import {immute} from "./utils/immute.js" import {tracker} from "../tracker/tracker.js" import {_optic} from "./utils/optic-symbol.js" @@ -8,17 +8,10 @@ import {Immutable, LensLike, Optic} from "./types.js" /** reactive view into a state prism, with formalized mutations */ export class Lens implements LensLike { - on = sub<[state: State]>() - onFrozen = sub<[state: Immutable]>() - ;[_optic]: Optic #previous: State #stateCache: CacheCell #frozenCache: CacheCell> - #onPublishDebounced = microbounce(() => { - this.on.publish(this.state) - this.onFrozen.publish(this.frozen) - }) constructor(optic: Optic) { this[_optic] = optic @@ -27,31 +20,30 @@ export class Lens implements LensLike { this.#frozenCache = new CacheCell(() => immute(optic.getState())) } - async update() { + update() { const state = this[_optic].getState() const isChanged = !deep.equal(state, this.#previous) if (isChanged) { this.#stateCache.invalidate() this.#frozenCache.invalidate() this.#previous = deep.clone(state) - this.#onPublishDebounced() - await tracker.notifyWrite(this) + tracker.write(this) } } /** get a snapshot of the current state. it's typed as mutable, but you should not mutate it. */ get state() { - tracker.notifyRead(this) + tracker.read(this) return this.#stateCache.get() } /** get an immutable readonly snapshot of the current state. */ get frozen() { - tracker.notifyRead(this) + tracker.read(this) return this.#frozenCache.get() } - async mutate(fn: (state: State) => R) { + mutate(fn: (state: State) => R) { return this[_optic].mutate(fn) } diff --git a/s/prism/prism.ts b/s/prism/prism.ts index a220d40..1f603c0 100644 --- a/s/prism/prism.ts +++ b/s/prism/prism.ts @@ -1,5 +1,4 @@ -import {microbounce, sub} from "@e280/stz" import {Lens} from "./lens.js" import {tracker} from "../tracker/tracker.js" @@ -8,31 +7,27 @@ export class Prism { #state: State #lenses = new Set>() - on = sub<[state: State]>() - #onPublishDebounced = microbounce(() => this.on.publish(this.#state)) - constructor(state: State) { this.#state = state } get() { - tracker.notifyRead(this) + tracker.read(this) return this.#state } - async set(state: State) { + set(state: State) { this.#state = state - await Promise.all([...this.#lenses].map(lens => lens.update())) - await this.#onPublishDebounced() - await tracker.notifyWrite(this) + for (const lens of this.#lenses) lens.update() + tracker.write(this) } lens(selector: (state: State) => State2) { const lens = new Lens({ getState: () => selector(this.#state), - mutate: async fn => { + mutate: fn => { const result = fn(selector(this.#state)) - await this.set(this.#state) + this.set(this.#state) return result }, registerLens: lens => this.#lenses.add(lens), diff --git a/s/prism/test.ts b/s/prism/test.ts index 184d76b..6be32c3 100644 --- a/s/prism/test.ts +++ b/s/prism/test.ts @@ -2,30 +2,30 @@ import {suite, test, expect} from "@e280/science" import {Prism} from "./prism.js" +import {effect} from "../core/effect.js" import {Chrono} from "./chrono/chrono.js" import {chronicle} from "./chrono/chronicle.js" -import {effect} from "../signals/effect/effect.js" +import { batch } from "../core/batch.js" export default suite({ "prism": suite({ "get/set state": test(async() => { const prism = new Prism({count: 1}) expect(prism.get().count).is(1) - await prism.set({count: 2}) + prism.set({count: 2}) expect(prism.get().count).is(2) }), "get/set state can trigger effects": test(async() => { const prism = new Prism({count: 1}) let triggered = 0 - const stop = effect(() => { + effect(() => { void prism.get().count triggered++ }) expect(triggered).is(1) - await prism.set({count: 2}) + prism.set({count: 2}) expect(triggered).is(2) - stop() }), }), @@ -45,16 +45,16 @@ export default suite({ "proper mutation": test(async() => { const prism = new Prism({data: {count: 1}}) const lens = prism.lens(s => s.data) - await lens.mutate(s => s.count++) + lens.mutate(s => s.count++) expect(lens.frozen.count).is(2) - await lens.mutate(s => s.count++) + lens.mutate(s => s.count++) expect(lens.frozen.count).is(3) }), "state after mutation is frozen": test(async() => { const prism = new Prism({data: {count: 1}}) const lens = prism.lens(s => s) - await lens.mutate(s => s.data = {count: 2}) + lens.mutate(s => s.data = {count: 2}) expect(lens.frozen.data.count).is(2) expect(() => (lens.frozen.data as any).count++).throws() }), @@ -63,48 +63,46 @@ export default suite({ const prism = new Prism({data: {count: 1}}) const lens = prism.lens(s => s.data) let happenings = 0 - const stop = effect(() => { + effect(() => { void lens.frozen.count happenings++ }) - await lens.mutate(s => s.count++) + lens.mutate(s => s.count++) expect(happenings).is(2) - stop() }), - "lens.on is debounced": test(async() => { + "effects can be batched": test(async() => { const prism = new Prism({data: {count: 1}}) const lens = prism.lens(s => s.data) let happenings = 0 - const stop = lens.on(() => void happenings++) - await Promise.all([ - lens.mutate(s => s.count++), - lens.mutate(s => s.count++), - ]) - expect(happenings).is(1) - stop() + effect(() => { + void lens.frozen.count + happenings++ + }) + batch(() => { + lens.mutate(s => s.count++) + lens.mutate(s => s.count++) + }) + expect(happenings).is(2) }), "array pushes are reactive": test(async() => { const prism = new Prism({data: {array: ["lol"]}}) const lens = prism.lens(s => s.data) let happenings = 0 - const stop = lens.on(() => void happenings++) - await lens.mutate(s => s.array.push("lmao")) + effect(() => lens.state, () => happenings++) + lens.mutate(s => s.array.push("lmao")) expect(happenings).is(1) expect(lens.frozen.array.length).is(2) - stop() }), "sync coherence": test(async() => { const prism = new Prism({data: {count: 1}}) const lens = prism.lens(s => s.data) - const p1 = lens.mutate(s => s.count++) + lens.mutate(s => s.count++) expect(lens.frozen.count).is(2) - const p2 = lens.mutate(s => s.count++) + lens.mutate(s => s.count++) expect(lens.frozen.count).is(3) - await p1 - await p2 }), "nullable selector": test(async() => { @@ -112,7 +110,7 @@ export default suite({ const prism = new Prism({a: {b: {count: 1}}}) const lens = prism.lens(s => s.a?.b) expect(lens.frozen?.count).is(1) - await prism.set({a: undefined}) + prism.set({a: undefined}) expect(lens.frozen?.count).is(undefined) }), @@ -129,11 +127,11 @@ export default suite({ const prism = new Prism({a: {b: {count: 1}}}) const lensA = prism.lens(s => s.a) const lensB = lensA.lens(s => s.b) - await lensB.mutate(s => s.count++) + lensB.mutate(s => s.count++) expect(prism.get().a.b.count).is(2) expect(lensA.frozen.b.count).is(2) expect(lensB.frozen.count).is(2) - await lensA.mutate(s => s.b = {count: 3}) + lensA.mutate(s => s.b = {count: 3}) expect(prism.get().a.b.count).is(3) expect(lensA.frozen.b.count).is(3) expect(lensB.frozen.count).is(3) @@ -145,13 +143,11 @@ export default suite({ const lensB = prism.lens(s => s.b) let happeningsA = 0 let happeningsB = 0 - const stopA = lensA.on(() => void happeningsA++) - const stopB = lensB.on(() => void happeningsA++) - await lensA.mutate(s => s.count++) + effect(() => lensA.state, () => happeningsA++) + effect(() => lensB.state, () => happeningsB++) + lensA.mutate(s => s.count++) expect(happeningsA).is(1) expect(happeningsB).is(0) - stopA() - stopB() }), "outside mutations ignored for effects": test(async() => { @@ -160,19 +156,17 @@ export default suite({ const lensB = prism.lens(s => s.b) let happeningsA = 0 let happeningsB = 0 - const stopA = effect(() => { + effect(() => { void lensA.frozen.count happeningsA++ }) - const stopB = effect(() => { + effect(() => { void lensB.frozen.count happeningsB++ }) - await lensA.mutate(s => s.count++) + lensA.mutate(s => s.count++) expect(happeningsA).is(2) expect(happeningsB).is(1) - stopA() - stopB() }), }), @@ -188,9 +182,9 @@ export default suite({ const prism = new Prism({data: chronicle({count: 1})}) const lens = prism.lens(s => s.data) const chrono = new Chrono(64, lens) - await chrono.mutate(s => s.count++) + chrono.mutate(s => s.count++) expect(chrono.frozen.count).is(2) - await chrono.mutate(s => s.count++) + chrono.mutate(s => s.count++) expect(chrono.frozen.count).is(3) }), @@ -200,17 +194,17 @@ export default suite({ const chrono = new Chrono(64, lens) expect(chrono.undoable).is(0) expect(chrono.redoable).is(0) - await chrono.mutate(s => s.count++) + chrono.mutate(s => s.count++) expect(chrono.undoable).is(1) - await chrono.mutate(s => s.count++) + chrono.mutate(s => s.count++) expect(chrono.undoable).is(2) - await chrono.undo() + chrono.undo() expect(chrono.undoable).is(1) expect(chrono.redoable).is(1) - await chrono.undo() + chrono.undo() expect(chrono.undoable).is(0) expect(chrono.redoable).is(2) - await chrono.redo() + chrono.redo() expect(chrono.undoable).is(1) expect(chrono.redoable).is(1) }), @@ -220,10 +214,10 @@ export default suite({ const lens = prism.lens(s => s.data) const chrono = new Chrono(64, lens) - await chrono.mutate(s => s.count++) + chrono.mutate(s => s.count++) expect(chrono.frozen.count).is(2) - await chrono.undo() + chrono.undo() expect(chrono.frozen.count).is(1) }), @@ -244,13 +238,13 @@ export default suite({ const lens = prism.lens(s => s.data) const chrono = new Chrono(64, lens) - await chrono.mutate(s => s.count++) + chrono.mutate(s => s.count++) expect(chrono.frozen.count).is(2) - await chrono.undo() + chrono.undo() expect(chrono.frozen.count).is(1) - await chrono.redo() + chrono.redo() expect(chrono.frozen.count).is(2) }), @@ -259,30 +253,30 @@ export default suite({ const lens = prism.lens(s => s.data) const chrono = new Chrono(64, lens) - await chrono.mutate(s => s.count++) - await chrono.mutate(s => s.count++) - await chrono.mutate(s => s.count++) + chrono.mutate(s => s.count++) + chrono.mutate(s => s.count++) + chrono.mutate(s => s.count++) expect(chrono.frozen.count).is(4) - await chrono.undo() + chrono.undo() expect(chrono.frozen.count).is(3) - await chrono.undo() + chrono.undo() expect(chrono.frozen.count).is(2) - await chrono.redo() + chrono.redo() expect(chrono.frozen.count).is(3) - await chrono.redo() + chrono.redo() expect(chrono.frozen.count).is(4) - await chrono.undo() + chrono.undo() expect(chrono.frozen.count).is(3) - await chrono.undo() + chrono.undo() expect(chrono.frozen.count).is(2) - await chrono.undo() + chrono.undo() expect(chrono.frozen.count).is(1) }), @@ -290,7 +284,7 @@ export default suite({ const prism = new Prism({data: chronicle({count: 1})}) const lens = prism.lens(s => s.data) const chrono = new Chrono(64, lens) - await chrono.undo() + chrono.undo() expect(chrono.frozen.count).is(1) }), @@ -298,7 +292,7 @@ export default suite({ const prism = new Prism({data: chronicle({count: 1})}) const lens = prism.lens(s => s.data) const chrono = new Chrono(64, lens) - await chrono.redo() + chrono.redo() expect(chrono.frozen.count).is(1) }), @@ -306,11 +300,11 @@ export default suite({ const prism = new Prism({data: chronicle({count: 1})}) const lens = prism.lens(s => s.data) const chrono = new Chrono(64, lens) - await chrono.mutate(s => s.count++) - await chrono.mutate(s => s.count++) - await chrono.mutate(s => s.count++) + chrono.mutate(s => s.count++) + chrono.mutate(s => s.count++) + chrono.mutate(s => s.count++) expect(chrono.frozen.count).is(4) - await chrono.undo(2) + chrono.undo(2) expect(chrono.frozen.count).is(2) }), @@ -318,13 +312,13 @@ export default suite({ const prism = new Prism({data: chronicle({count: 1})}) const lens = prism.lens(s => s.data) const chrono = new Chrono(64, lens) - await chrono.mutate(s => s.count++) - await chrono.mutate(s => s.count++) - await chrono.mutate(s => s.count++) + chrono.mutate(s => s.count++) + chrono.mutate(s => s.count++) + chrono.mutate(s => s.count++) expect(chrono.frozen.count).is(4) - await chrono.undo(2) + chrono.undo(2) expect(chrono.frozen.count).is(2) - await chrono.redo(2) + chrono.redo(2) expect(chrono.frozen.count).is(4) }), @@ -333,9 +327,9 @@ export default suite({ const chrono = new Chrono(64, prism.lens(s => s.data)) const sublens = chrono.lens(s => s.a) expect(sublens.frozen.count).is(1) - await sublens.mutate(s => s.count++) + sublens.mutate(s => s.count++) expect(sublens.frozen.count).is(2) - await chrono.undo() + chrono.undo() expect(sublens.frozen.count).is(1) }), }), diff --git a/s/prism/types.ts b/s/prism/types.ts index 2d6a31a..0f00556 100644 --- a/s/prism/types.ts +++ b/s/prism/types.ts @@ -4,13 +4,13 @@ import {Lens} from "./lens.js" export type LensLike = { readonly state: State readonly frozen: Immutable - mutate(fn: (state: State) => R): Promise + mutate(fn: (state: State) => R): R lens(selector: (state: State) => S2): Lens } export type Optic = { getState: () => State - mutate: (fn: (state: State) => R) => Promise + mutate: (fn: (state: State) => R) => R registerLens: (lens: Lens) => void } diff --git a/s/prism/vault/vault.ts b/s/prism/vault/vault.ts index 845994a..c2b21c2 100644 --- a/s/prism/vault/vault.ts +++ b/s/prism/vault/vault.ts @@ -8,7 +8,7 @@ export class Vault { const {store, version, prism} = this.options const pickle = await store.get() if (pickle && pickle.version === version) - await prism.set(pickle.state) + prism.set(pickle.state) } save = async() => { diff --git a/s/signals/derived/class.ts b/s/signals/derived/class.ts deleted file mode 100644 index 9c59975..0000000 --- a/s/signals/derived/class.ts +++ /dev/null @@ -1,34 +0,0 @@ - -import {Sub} from "@e280/stz" -import {derived} from "./fn.js" -import {SignalOptions} from "../types.js" -import {tracker} from "../../tracker/tracker.js" - -export interface Derived { - (): Value -} - -export class Derived { - sneak!: Value - on!: Sub<[Value]> - dispose!: () => void - - constructor(formula: () => Value, options?: Partial) { - if (new.target !== Derived) throw new Error("Derived cannot be subclassed") - return derived(formula, options) - } - - get value() { - return this.get() - } - - get() { - tracker.notifyRead(this) - return this.sneak - } - - toString() { - return `(derived "${String(this.get())}")` - } -} - diff --git a/s/signals/derived/fn.ts b/s/signals/derived/fn.ts deleted file mode 100644 index b829be3..0000000 --- a/s/signals/derived/fn.ts +++ /dev/null @@ -1,35 +0,0 @@ - -import {sub} from "@e280/stz" -import {Derived} from "./class.js" -import {watch} from "../effect/watch.js" -import {SignalOptions} from "../types.js" -import {tracker} from "../../tracker/tracker.js" -import {defaultCompare} from "../utils/default-compare.js" - -export function derived(formula: () => Value, options?: Partial) { - function fn(): Value { - return (fn as Derived).get() - } - - const compare = options?.compare ?? defaultCompare - - Object.setPrototypeOf(fn, Derived.prototype) - fn.on = sub<[Value]>() - - const {result, dispose} = watch(formula, async(value) => { - const isChanged = !compare(fn.sneak, value) - if (isChanged) { - fn.sneak = value - await Promise.all([ - tracker.notifyWrite(fn), - fn.on.pub(value), - ]) - } - }) - - fn.sneak = result - fn.dispose = dispose - - return fn as Derived -} - diff --git a/s/signals/derived/test.ts b/s/signals/derived/test.ts deleted file mode 100644 index d746427..0000000 --- a/s/signals/derived/test.ts +++ /dev/null @@ -1,122 +0,0 @@ - -import {Science, test, expect, spy} from "@e280/science" -import {derived} from "./fn.js" -import {signal} from "../signal/fn.js" -import {effect} from "../effect/effect.js" - -export default Science.suite({ - "basic": test(async() => { - const a = signal(1) - const b = signal(10) - const product = derived(() => a.get() * b.get()) - expect(product.get()).is(10) - - await a.set(2) - expect(product.get()).is(20) - }), - - "effect reacts to derived changes": test(async() => { - const a = signal(1) - const b = signal(10) - const product = derived(() => a.value * b.value) - - let mutations = 0 - effect(() => { - void product.get() - mutations++ - }) - expect(product.value).is(10) - expect(mutations).is(1) - - await a.set(2) - expect(product.value).is(20) - expect(mutations).is(2) - - await a.set(3) - expect(product.value).is(30) - expect(mutations).is(3) - }), - - "effect doesn't overreact to derived": test(async() => { - const a = signal(1) - const b = signal(10) - const product = derived(() => a.value * b.value) - - const derivedSpy = spy(() => {}) - product.on(derivedSpy) - - let mutations = 0 - effect(() => { - a.get() - product.get() - mutations++ - }) - expect(product.value).is(10) - expect(mutations).is(1) - expect(derivedSpy.spy.calls.length).is(0) - - await a.set(2) - expect(product.value).is(20) - expect(mutations).is(2) - expect(derivedSpy.spy.calls.length).is(1) - }), - - "derived handles changing deps": test(async() => { - const toggle = signal(true) - const a = signal(1) - const b = signal(2) - const comp = derived(() => toggle() ? a() : b()) - expect(comp()).is(1) - await toggle(false) - expect(comp()).is(2) - await b(3) - expect(comp()).is(3) - }), - - "derived.on": test(async() => { - const a = signal(1) - const b = signal(10) - const product = derived(() => a.value * b.value) - expect(product.value).is(10) - - const mole = spy((_v: number) => {}) - product.on(mole) - expect(mole.spy.calls.length).is(0) - - await a.set(2) - expect(product.value).is(20) - expect(mole.spy.calls.length).is(1) - expect(mole.spy.calls[0].args[0]).is(20) - }), - - "derived.on not called if result doesn't change": test(async() => { - const a = signal(1) - const b = signal(10) - const product = derived(() => a.value * b.value) - expect(product.value).is(10) - - const mole = spy((_v: number) => {}) - product.on(mole) - expect(mole.spy.calls.length).is(0) - - await a.set(2) - expect(product.value).is(20) - expect(mole.spy.calls.length).is(1) - expect(mole.spy.calls[0].args[0]).is(20) - - await a.set(2) - expect(product.value).is(20) - expect(mole.spy.calls.length).is(1) - }), - - "hipster syntax": test(async() => { - const a = signal(1) - const b = signal(10) - const product = derived(() => a() * b()) - expect(product()).is(10) - - await a(2) - expect(product()).is(20) - }), -}) - diff --git a/s/signals/effect/effect.ts b/s/signals/effect/effect.ts deleted file mode 100644 index b3f25d0..0000000 --- a/s/signals/effect/effect.ts +++ /dev/null @@ -1,11 +0,0 @@ - -import {watch} from "./watch.js" - -export function effect( - collector: () => Value, - responder?: (value: Value) => void, - ) { - - return watch(collector, responder).dispose -} - diff --git a/s/signals/effect/test.ts b/s/signals/effect/test.ts deleted file mode 100644 index e3cee34..0000000 --- a/s/signals/effect/test.ts +++ /dev/null @@ -1,172 +0,0 @@ - -import {Science, test, expect} from "@e280/science" -import {watch} from "./watch.js" -import {effect} from "./effect.js" -import {signal} from "../signal/fn.js" - -export default Science.suite({ - "watch": Science.suite({ - "responder gets value": test(async() => { - const count = signal(1) - let collected = 0 - watch( - () => count(), - x => { collected = x } - ) - expect(collected).is(0) - await count(2) - expect(collected).is(2) - }), - - "responder not called until change": test(async() => { - const count = signal(1) - let calls = 0 - watch( - () => count(), - () => { calls++ } - ) - expect(calls).is(0) - await count(2) - expect(calls).is(1) - }), - - "watch updates dynamic dependencies": test(async() => { - const toggle = signal(true) - const a = signal(1) - const b = signal(10) - - let collected = 0 - - watch( - () => toggle() ? a() : b(), - x => { collected = x } - ) - - await a(2) - expect(collected).is(2) - - await toggle(false) - expect(collected).is(10) - - collected = 0 - await a(3) - expect(collected).is(0) - - await b(11) - expect(collected).is(11) - }) - }), - - "tracks signal changes": test(async() => { - const count = signal(1) - let doubled = 0 - - effect(() => doubled = count.value * 2) - expect(doubled).is(2) - - await count.set(3) - expect(doubled).is(6) - }), - - "correct signal effect order": test(async() => { - let order: string[] = [] - const count = signal(0) - - effect(() => { - if (count.value) - order.push("effect") - }) - - order.push("before") - await count.set(1) - order.push("after") - - expect(order.length).is(3) - expect(order[0]).is("before") - expect(order[1]).is("effect") - expect(order[2]).is("after") - }), - - "simple effect called the correct number of times": test(async() => { - const count = signal(0) - let runs = 0 - effect(() => { count(); runs++ }) - expect(runs).is(1) - await count(1) - expect(runs).is(2) - await count(2) - expect(runs).is(3) - }), - - "is only called when signal actually changes": test(async() => { - const count = signal(1) - let runs = 0 - effect(() => { - count.get() - runs++ - }) - expect(runs).is(1) - await count.set(999) - expect(runs).is(2) - await count.set(999) - expect(runs).is(2) - }), - - "debounced": test(async() => { - const count = signal(1) - let runs = 0 - effect(() => { - count.get() - runs++ - }) - expect(runs).is(1) - count.value++ - count.value++ - await count.set(count.get() + 1) - expect(runs).is(2) - }), - - "can be disposed": test(async() => { - const count = signal(1) - let doubled = 0 - - const dispose = effect(() => doubled = count.value * 2) - expect(doubled).is(2) - - await count.set(3) - expect(doubled).is(6) - - dispose() - await count.set(4) - expect(doubled).is(6) // old value - }), - - "signal set promise waits for effects": test(async() => { - const count = signal(1) - let doubled = 0 - - effect(() => doubled = count.value * 2) - expect(doubled).is(2) - - await count.set(3) - expect(doubled).is(6) - }), - - "only runs on change": test(async() => { - const sig = signal("a") - let runs = 0 - - effect(() => { - sig.value - runs++ - }) - expect(runs).is(1) - - await sig.set("a") - expect(runs).is(1) - - await sig.set("b") - expect(runs).is(2) - }), -}) - diff --git a/s/signals/effect/watch.ts b/s/signals/effect/watch.ts deleted file mode 100644 index 0784bed..0000000 --- a/s/signals/effect/watch.ts +++ /dev/null @@ -1,32 +0,0 @@ - -import {microbounce} from "@e280/stz" -import {tracker} from "../../tracker/tracker.js" - -export function watch( - collector: () => Value, - responder?: (value: Value) => void, - ) { - - let disposers: (() => void)[] = [] - - const dispose = () => { - for (const d of disposers) d() - disposers = [] - } - - const run = () => { - const {seen, result} = tracker.observe(collector) - for (const saw of seen) - disposers.push(tracker.subscribe(saw, reset)) - return result - } - - const reset = microbounce(() => { - dispose() - if (responder) responder(run()) - else run() - }) - - return {result: run(), dispose} -} - diff --git a/s/signals/index.ts b/s/signals/index.ts deleted file mode 100644 index 358e170..0000000 --- a/s/signals/index.ts +++ /dev/null @@ -1,18 +0,0 @@ - -export * from "./derived/fn.js" -export * from "./derived/class.js" - -export * from "./effect/effect.js" -export * from "./effect/watch.js" - -export * from "./lazy/fn.js" -export * from "./lazy/class.js" - -export * from "./r/map.js" -export * from "./r/set.js" - -export * from "./signal/fn.js" -export * from "./signal/class.js" - -export * from "./types.js" - diff --git a/s/signals/lazy/class.ts b/s/signals/lazy/class.ts deleted file mode 100644 index 48cde59..0000000 --- a/s/signals/lazy/class.ts +++ /dev/null @@ -1,74 +0,0 @@ - -import {lazy} from "./fn.js" -import {SignalOptions} from "../types.js" -import {tracker} from "../../tracker/tracker.js" -import {_collect, _compare, _dirty, _disposers, _effect, _formula} from "../utils/symbols.js" - -export interface Lazy { - (): Value -} - -export class Lazy { - sneak!: Value - ;[_formula]!: () => Value - ;[_dirty]!: boolean - ;[_disposers]!: (() => void)[] - ;[_effect]!: (() => void) | undefined - ;[_compare]!: (a: any, b: any) => boolean - - constructor(formula: () => Value, options?: Partial) { - if (new.target !== Lazy) throw new Error("Lazy cannot be subclassed") - return lazy(formula, options) - } - - get value() { - return this.get() - } - - [_collect]() { - for (const d of this[_disposers]) d() - this[_disposers] = [] - - const {seen, result} = tracker.observe(this[_formula]) - - const markDirty = async() => { this[_dirty] = true } - for (const saw of seen) - this[_disposers].push(tracker.subscribe(saw, markDirty)) - - this[_effect] = () => { - for (const d of this[_disposers]) d() - this[_disposers] = [] - } - - return result - } - - get() { - if (!this[_effect]) { - this.sneak = this[_collect]() - this[_dirty] = false - } - else if (this[_dirty]) { - this[_dirty] = false - const value = this[_collect]() - const isChanged = !this[_compare](this.sneak, value) - if (isChanged) { - this.sneak = value - tracker.notifyWrite(this) - } - } - - tracker.notifyRead(this) - return this.sneak - } - - dispose() { - if (this[_effect]) - this[_effect]() - } - - toString() { - return `($lazy "${String(this.get())}")` - } -} - diff --git a/s/signals/lazy/fn.ts b/s/signals/lazy/fn.ts deleted file mode 100644 index 00b6cce..0000000 --- a/s/signals/lazy/fn.ts +++ /dev/null @@ -1,22 +0,0 @@ - -import {Lazy} from "./class.js" -import {SignalOptions} from "../types.js" -import {defaultCompare} from "../utils/default-compare.js" -import {_compare, _dirty, _disposers, _effect, _formula} from "../utils/symbols.js" - -export function lazy(formula: () => Value, options?: Partial) { - function fn(): Value { - return (fn as Lazy).get() - } - - Object.setPrototypeOf(fn, Lazy.prototype) - fn.sneak = undefined - fn[_formula] = formula - fn[_dirty] = false - fn[_effect] = undefined - fn[_disposers] = [] as any - fn[_compare] = options?.compare ?? defaultCompare - - return fn as Lazy -} - diff --git a/s/signals/lazy/test.ts b/s/signals/lazy/test.ts deleted file mode 100644 index 6ee4b1b..0000000 --- a/s/signals/lazy/test.ts +++ /dev/null @@ -1,75 +0,0 @@ - -import {Science, test, expect} from "@e280/science" -import {lazy} from "./fn.js" -import {signal} from "../signal/fn.js" - -export default Science.suite({ - "lazy values": test(async() => { - const a = signal(2) - const b = signal(3) - const sum = lazy(() => a.value + b.value) - expect(sum.value).is(5) - - await a.set(5) - expect(sum.value).is(8) - - await b.set(7) - expect(sum.value).is(12) - }), - - "lazy is lazy": test(async() => { - const a = signal(1) - let runs = 0 - - const comp = lazy(() => { - runs++ - return a.value * 10 - }) - - expect(runs).is(0) - expect(comp.value).is(10) - expect(runs).is(1) - - await a.set(2) - expect(runs).is(1) - expect(comp.value).is(20) - expect(runs).is(2) - }), - - "lazy handles changing deps": test(async() => { - const toggle = signal(true) - const a = signal(1) - const b = signal(2) - const comp = lazy(() => toggle() ? a() : b()) - expect(comp()).is(1) - await toggle(false) - expect(comp()).is(2) - await b(3) - expect(comp()).is(3) - }), - - "lazy syntax": test(async() => { - const a = signal(2) - const b = signal(3) - const sum = lazy(() => a.value + b.value) - expect(sum.value).is(5) - - await a.set(5) - expect(sum.value).is(8) - expect(sum.get()).is(8) - }), - - "lazy hipster syntax": test(async() => { - const a = signal(2) - const b = signal(3) - const sum = lazy(() => a() + b()) - expect(sum()).is(5) - - await a(5) - expect(sum()).is(8) - - await b(7) - expect(sum()).is(12) - }), -}) - diff --git a/s/signals/r/map.ts b/s/signals/r/map.ts deleted file mode 100644 index 6e11e01..0000000 --- a/s/signals/r/map.ts +++ /dev/null @@ -1,63 +0,0 @@ - -import {GMap} from "@e280/stz" -import {tracker} from "../../tracker/tracker.js" - -export class RMap extends GMap { - get size() { - tracker.notifyRead(this) - return super.size - } - - ;[Symbol.iterator]() { - tracker.notifyRead(this) - return super[Symbol.iterator]() - } - - keys() { - tracker.notifyRead(this) - return super.keys() - } - - values() { - tracker.notifyRead(this) - return super.values() - } - - entries() { - tracker.notifyRead(this) - return super.entries() - } - - forEach(callbackFn: (value: V, key: K, map: Map) => void) { - tracker.notifyRead(this) - return super.forEach(callbackFn) - } - - has(key: K) { - tracker.notifyRead(this) - return super.has(key) - } - - get(key: K) { - tracker.notifyRead(this) - return super.get(key) - } - - set(key: K, value: V) { - const r = super.set(key, value) - tracker.notifyWrite(this) - return r - } - - delete(key: K) { - const r = super.delete(key) - tracker.notifyWrite(this) - return r - } - - clear() { - super.clear() - tracker.notifyWrite(this) - } -} - diff --git a/s/signals/r/set.ts b/s/signals/r/set.ts deleted file mode 100644 index 64793a2..0000000 --- a/s/signals/r/set.ts +++ /dev/null @@ -1,44 +0,0 @@ - -import {GSet} from "@e280/stz" -import {tracker} from "../../tracker/tracker.js" - -export class RSet extends GSet { - get size() { - tracker.notifyRead(this) - return super.size - } - - ;[Symbol.iterator]() { - tracker.notifyRead(this) - return super[Symbol.iterator]() - } - - values() { - tracker.notifyRead(this) - return super.values() - } - - has(item: T) { - tracker.notifyRead(this) - return super.has(item) - } - - add(item: T) { - super.add(item) - tracker.notifyWrite(this) - return this - } - - delete(item: T) { - const r = super.delete(item) - tracker.notifyWrite(this) - return r - } - - clear() { - super.clear() - tracker.notifyWrite(this) - return this - } -} - diff --git a/s/signals/signal/class.ts b/s/signals/signal/class.ts deleted file mode 100644 index dc09472..0000000 --- a/s/signals/signal/class.ts +++ /dev/null @@ -1,77 +0,0 @@ - -import {Sub} from "@e280/stz" -import {signal} from "./fn.js" -import {SignalOptions} from "../types.js" -import {tracker} from "../../tracker/tracker.js" -import {_compare, _lock} from "../utils/symbols.js" - -export interface Signal { - (): Value - (value: Value): Promise -} - -export class Signal { - sneak!: Value - on!: Sub<[Value]> - - ;[_lock]!: boolean - ;[_compare]!: (a: any, b: any) => boolean - - constructor(value: Value, options?: Partial) { - if (new.target !== Signal) throw new Error("Signal cannot be subclassed") - return signal(value, options) - } - - get value() { - return this.get() - } - - set value(value: Value) { - void this.set(value) - } - - get() { - tracker.notifyRead(this) - return this.sneak - } - - async set(value: Value, forcePublish = false) { - const previous = this.sneak - this.sneak = value - if (forcePublish || !this[_compare](previous, value)) - await this.publish() - return value - } - - async publish(): Promise { - // only wizards are allowed beyond this point. - // - the implementation is subtle - // - it looks wrong, but it's right - // - tarnished alchemists, take heed: lock engages only for sync activity of the async fns (think of the value setter!) - - if (this[_lock]) - throw new Error("forbid circularity") - - const value = this.sneak - let promise: Promise = Promise.resolve() - - try { - this[_lock] = true - promise = Promise.all([ - tracker.notifyWrite(this), - this.on.pub(value), - ]) - } - finally { - this[_lock] = false - } - - await promise - return value - } - - toString() { - return `($signal "${String(this.get())}")` - } -} - diff --git a/s/signals/signal/fn.ts b/s/signals/signal/fn.ts deleted file mode 100644 index 075907d..0000000 --- a/s/signals/signal/fn.ts +++ /dev/null @@ -1,31 +0,0 @@ - -import {sub} from "@e280/stz" -import {Signal} from "./class.js" -import {lazy} from "../lazy/fn.js" -import {SignalOptions} from "../types.js" -import {derived} from "../derived/fn.js" -import {_compare, _lock} from "../utils/symbols.js" -import {defaultCompare} from "../utils/default-compare.js" - -export function signal(value: Value, options?: Partial) { - function fn(): Value - function fn(value: Value): Promise - function fn(_value?: Value): Value | Promise { - const self = fn as Signal - return (arguments.length === 0) - ? self.get() - : self.set(arguments[0]) - } - - Object.setPrototypeOf(fn, Signal.prototype) - fn.sneak = value - fn.on = sub<[Value]>() - fn[_lock] = false - fn[_compare] = options?.compare ?? defaultCompare - - return fn as Signal -} - -signal.derived = derived -signal.lazy = lazy - diff --git a/s/signals/signal/test.ts b/s/signals/signal/test.ts deleted file mode 100644 index e22c24f..0000000 --- a/s/signals/signal/test.ts +++ /dev/null @@ -1,112 +0,0 @@ - -import {Science, test, expect} from "@e280/science" -import {signal} from "./fn.js" -import {Signal} from "./class.js" - -export default Science.suite({ - "get() and set()": test(async() => { - const count = signal(0) - expect(count.get()).is(0) - await count.set(1) - expect(count.get()).is(1) - count.set(2) - expect(count.get()).is(2) - }), - - ".value": test(async() => { - const count = signal(0) - expect(count.value).is(0) - count.value++ - expect(count.value).is(1) - count.value = 5 - expect(count.value).is(5) - }), - - "count() and count(1)": test(async() => { - const count = signal(0) - expect(count()).is(0) - await count(1) - expect(count()).is(1) - count(2) - expect(count()).is(2) - }), - - "set with forcePublish returns value": test(async() => { - const count = signal(0) - expect(count.value).is(0) - expect(await count.set(1)).is(1) - expect(await count.set(2, true)).is(2) - }), - - "syntax interop": test(async() => { - const count = signal(0) - - count(1) - expect(count()).is(1) - expect(count.get()).is(1) - expect(count.value).is(1) - - count.set(2) - expect(count()).is(2) - expect(count.get()).is(2) - expect(count.value).is(2) - - count.value = 3 - expect(count()).is(3) - expect(count.get()).is(3) - expect(count.value).is(3) - }), - - "class magic": { - "signal instanceof Signal": test(async() => { - const count = signal(0) - expect(count instanceof Signal).ok() - }), - - "new Signal": test(async() => { - const count = new Signal(0) - expect(count instanceof Signal).ok() - expect(count()).is(0) - await count(1) - expect(count.value).is(1) - }), - - "subclassing Signal is forbidden": test(async() => { - expect(() => new class extends Signal {}(0)).throws() - }), - }, - - "on": { - "on is not debounced": test(async() => { - const count = signal(1) - let runs = 0 - count.on(() => void runs++) - await count.set(2) - await count.set(3) - expect(runs).is(2) - }), - - "on only fires on change": test(async() => { - const count = signal(1) - let runs = 0 - count.on(() => void runs++) - await count.set(2) - await count.set(2) - expect(runs).is(1) - }), - - "on circularity forbidden": test(async() => { - const count = signal(1) - let runs = 0 - count.on(async() => { - await count.set(99) - runs++ - }) - expect(async() => { - await count.set(2) - }).throwsAsync() - expect(runs).is(0) - }), - }, -}) - diff --git a/s/signals/test.ts b/s/signals/test.ts deleted file mode 100644 index 281372e..0000000 --- a/s/signals/test.ts +++ /dev/null @@ -1,15 +0,0 @@ - -import {Science} from "@e280/science" - -import signal from "./signal/test.js" -import derived from "./derived/test.js" -import lazy from "./lazy/test.js" -import effect from "./effect/test.js" - -export default Science.suite({ - signal, - derived, - lazy, - effect, -}) - diff --git a/s/signals/types.ts b/s/signals/types.ts deleted file mode 100644 index 8ba04ac..0000000 --- a/s/signals/types.ts +++ /dev/null @@ -1,11 +0,0 @@ - -import {Lazy} from "./lazy/class.js" -import {Signal} from "./signal/class.js" -import {Derived} from "./derived/class.js" - -export type Signaly = Signal | Derived | Lazy - -export type SignalOptions = { - compare: (a: any, b: any) => boolean -} - diff --git a/s/signals/utils/default-compare.ts b/s/signals/utils/default-compare.ts deleted file mode 100644 index 4a61764..0000000 --- a/s/signals/utils/default-compare.ts +++ /dev/null @@ -1,5 +0,0 @@ - -export function defaultCompare(a: any, b: any) { - return Object.is(a, b) -} - diff --git a/s/signals/utils/symbols.ts b/s/signals/utils/symbols.ts deleted file mode 100644 index 79e80e8..0000000 --- a/s/signals/utils/symbols.ts +++ /dev/null @@ -1,9 +0,0 @@ - -export const _lock = Symbol() -export const _dirty = Symbol() -export const _effect = Symbol() -export const _collect = Symbol() -export const _compare = Symbol() -export const _formula = Symbol() -export const _disposers = Symbol() - diff --git a/s/test.ts b/s/test.ts index 22a49c3..47cb228 100644 --- a/s/test.ts +++ b/s/test.ts @@ -1,9 +1,9 @@ import {science} from "@e280/science" -import core from "./nova/core/test.js" -import wait from "./nova/wait/test.js" -import prism from "./nova/prism/test.js" -import tracker from "./nova/tracker/test.js" +import core from "./core/test.js" +import wait from "./wait/test.js" +import prism from "./prism/test.js" +import tracker from "./tracker/test.js" await science.run({ tracker, diff --git a/s/tracker/bindings/react.ts b/s/tracker/bindings/react.ts deleted file mode 100644 index 0eb3f74..0000000 --- a/s/tracker/bindings/react.ts +++ /dev/null @@ -1,53 +0,0 @@ - -import {tracker} from "../tracker.js" -import {signal} from "../../signals/signal/fn.js" -import {derived} from "../../signals/derived/fn.js" -import {SignalOptions} from "../../signals/types.js" - -export function react(react: { - useEffect: (fn: () => void | (() => void), deps?: unknown[]) => void - useState: (x: X | (() => X)) => [ - value: X, - set: (value: X | ((x: X) => X)) => void - ] - }) { - - const useStrata = (fn: () => X) => { - const [, setTick] = react.useState(0) - const {seen, result} = tracker.observe(fn) - - react.useEffect(() => { - const rerender = async() => setTick(tick => tick + 1) - const stoppers = [...seen].map(item => tracker.subscribe(item, rerender)) - return () => stoppers.forEach(stop => stop()) - }) - - return result - } - - const component =

(render: (props: P) => R) => { - const c = (props: P) => useStrata(() => render(props)) - c.displayName = (render as any).displayName ?? render.name ?? "Component" - return c - } - - const useOnce = (fn: () => X) => { - const [value] = react.useState(fn) - return value - } - - const useSignal = (value: X, options?: Partial) => { - const $signal = useOnce(() => signal(value, options)) - void useStrata(() => $signal()) - return $signal - } - - const useDerived = (formula: () => X, options?: Partial) => { - const $derived = useOnce(() => derived(formula, options)) - void useStrata(() => $derived()) - return $derived - } - - return {component, useStrata, useOnce, useSignal, useDerived} -} - diff --git a/s/tracker/index.ts b/s/tracker/index.ts index c985571..3388e88 100644 --- a/s/tracker/index.ts +++ b/s/tracker/index.ts @@ -1,4 +1,3 @@ export * from "./tracker.js" -export * from "./bindings/react.js" diff --git a/s/tracker/test.ts b/s/tracker/test.ts index 855333c..ad59c12 100644 --- a/s/tracker/test.ts +++ b/s/tracker/test.ts @@ -1,40 +1,145 @@ -import {Science, test, expect} from "@e280/science" import {Tracker} from "./tracker.js" +import {science, test, expect} from "@e280/science" -export default Science.suite({ - "change waits for downstream effects to settle": test(async() => { +export default science.suite({ + "subscriptions": test(async() => { const tracker = new Tracker() - let order: string[] = [] + let alpha = 0 + let bravo = 0 + const item = {} + const alphaOff = tracker.subscribe(item, () => alpha++) + tracker.subscribe(item, () => bravo++) + + tracker.write(item) + expect(alpha).is(1) + expect(bravo).is(1) + tracker.write(item) + expect(alpha).is(2) + expect(bravo).is(2) + + alphaOff() + tracker.write(item) + expect(alpha).is(2) + expect(bravo).is(3) + }), + + "observe": test(async() => { + const tracker = new Tracker() const item = {} - tracker.subscribe(item, async () => { - await Promise.resolve() - order.push("effect") + + const {seen, value} = tracker.observe(() => { + tracker.read(item) + return 123 }) - order.push("before") - await tracker.notifyWrite(item) - order.push("after") + expect(seen.has(item)).is(true) + expect(value).is(123) + }), + + "nested observe layers are isolated": test(async() => { + const tracker = new Tracker() + const outer = {} + const inner = {} - expect(order.length).is(3) - expect(order[0]).is("before") - expect(order[1]).is("effect") - expect(order[2]).is("after") + const observed = tracker.observe(() => { + tracker.read(outer) + + const nested = tracker.observe(() => { + tracker.read(inner) + }) + + expect(nested.seen.has(inner)).is(true) + expect(nested.seen.has(outer)).is(false) + }) + + expect(observed.seen.has(outer)).is(true) + expect(observed.seen.has(inner)).is(false) }), - "circularity forbidden": test(async() => { + "batch dedupes subscriber calls": test(async() => { const tracker = new Tracker() + let called = 0 const item = {} - // effect re-publishes the same change, creating a cycle - tracker.subscribe(item, async() => { - await tracker.notifyWrite(item) + tracker.subscribe(item, () => called++) + + tracker.batch(() => { + tracker.write(item) + tracker.write(item) + tracker.write(item) }) - expect(async() => { - await tracker.notifyWrite(item) - }).throwsAsync() + expect(called).is(1) }), -}) + "nested batch flushes only once": test(async() => { + const tracker = new Tracker() + let called = 0 + const item = {} + + tracker.subscribe(item, () => called++) + + tracker.batch(() => { + tracker.write(item) + + tracker.batch(() => { + tracker.write(item) + expect(called).is(0) + }) + + expect(called).is(0) + }) + + expect(called).is(1) + }), + + "batch flushes cascading writes in waves": test(async() => { + const tracker = new Tracker() + const a = {} + const b = {} + const calls: string[] = [] + + tracker.subscribe(a, () => { + calls.push("a") + tracker.write(b) + }) + + tracker.subscribe(b, () => { + calls.push("b") + }) + + tracker.batch(() => { + tracker.write(a) + }) + + expect(calls.join(",")).is("a,b") + }), + + "circularity is forbidden": test(async() => { + const tracker = new Tracker() + const item = {} + + const fn = () => tracker.write(item) + tracker.subscribe(item, fn) + + expect(() => tracker.write(item)).throws() + }), + + "same subscriber rescheduled during flush is forbidden": test(async() => { + const tracker = new Tracker() + const item = {} + let calls = 0 + + const fn = () => { + calls++ + tracker.write(item) + } + + tracker.subscribe(item, fn) + + expect(() => tracker.write(item)).throws() + expect(calls).is(1) + }), +}) diff --git a/s/tracker/tracker.ts b/s/tracker/tracker.ts index 8909907..b3debdc 100644 --- a/s/tracker/tracker.ts +++ b/s/tracker/tracker.ts @@ -1,62 +1,92 @@ -import {sub, Sub} from "@e280/stz" +import {GWeakMap} from "@e280/stz" -export type TrackableItem = object | symbol +export type Trackable = object | symbol /** * reactivity integration hub */ -export class Tracker { - #seeables: Set[] = [] - #changeables = new WeakMap() - #changeStack: Set>[] = [] - #busy = new Set() - - /** indicate item was accessed */ - notifyRead(item: Item) { - this.#seeables.at(-1)?.add(item) +export class Tracker { + #busy = new Set<() => void>() + #observationLayers: Set[] = [] + #subscriptions = new GWeakMap void>>() + + #batchDepth = 0 + #batchPending = new Set<() => void>() + + /** indicate to observers that this item was accessed */ + read(item: Item) { + const top = this.#observationLayers.at(-1) + top?.add(item) } - /** indicate item was changed */ - async notifyWrite(item: Item) { - if (this.#busy.has(item)) - throw new Error("circularity forbidden") - const prom = this.#guaranteeChangeable(item).pub() - this.#changeStack.at(-1)?.add(prom) - return prom + /** invoke all subscriptions for this item */ + write(item: Item) { + const fns = this.#subscriptions.get(item) + if (!fns) return + + for (const fn of fns) + this.#batchPending.add(fn) + + if (this.#batchDepth === 0) + this.#flush() + } + + /** collect items that were read during fn */ + observe(fn: () => Value) { + const seen = new Set() + this.#observationLayers.push(seen) + try { + const value = fn() + return {seen, value} + } + finally { + this.#observationLayers.pop() + } + } + + /** fn will be called when item changes */ + subscribe(item: Item, fn: () => void) { + const fns = this.#subscriptions.guarantee(item, () => new Set()) + fns.add(fn) + return () => { + fns.delete(fn) + if (fns.size === 0) + this.#subscriptions.delete(item) + } } - /** collect which items were seen during fn */ - observe(fn: () => R) { - this.#seeables.push(new Set()) - const result = fn() - const seen = this.#seeables.pop()! - return {seen, result} + batch = (fn: () => R) => { + this.#batchDepth++ + try { + return fn() + } + finally { + this.#batchDepth-- + if (this.#batchDepth === 0) + this.#flush() + } } - /** respond to changes by calling fn */ - subscribe(item: Item, fn: () => Promise) { - return this.#guaranteeChangeable(item)(async() => { - const collected = new Set>() - this.#changeStack.push(collected) - this.#busy.add(item) - collected.add(fn()) - this.#busy.delete(item) - await Promise.all(collected) - this.#changeStack.pop() - }) + #run(fn: () => void) { + if (this.#busy.has(fn)) + throw new Error("circularity forbidden") + this.#busy.add(fn) + try { fn() } + finally { this.#busy.delete(fn) } } - #guaranteeChangeable(item: Item) { - let on = this.#changeables.get(item) - if (!on) { - on = sub() - this.#changeables.set(item, on) + #flush() { + while (this.#batchPending.size > 0) { + const pending = [...this.#batchPending] + this.#batchPending.clear() + + for (const fn of pending) + this.#run(fn) } - return on } } /** standard global tracker for integrations */ -export const tracker: Tracker = (globalThis as any)[Symbol.for("e280.tracker")] ??= new Tracker() +export const tracker: Tracker = (globalThis as any)[Symbol.for("e280.tracker.2")] ??= new Tracker() diff --git a/s/wait/index.ts b/s/wait/index.ts index a1dc2df..538790d 100644 --- a/s/wait/index.ts +++ b/s/wait/index.ts @@ -1,7 +1,7 @@ export * from "./parts/get.js" export * from "./parts/is.js" -export * from "./parts/new.js" +export * from "./parts/make.js" export * from "./parts/select.js" export * from "./parts/wait.js" export * from "./parts/type.js" diff --git a/s/wait/parts/is.ts b/s/wait/parts/is.ts index 64a629c..31710eb 100644 --- a/s/wait/parts/is.ts +++ b/s/wait/parts/is.ts @@ -1,8 +1,8 @@ -import {Wait, WaitDone, WaitErr, WaitOk, WaitPending} from "./type.js" +import {Wait, WaitResult, WaitErr, WaitOk, WaitPending} from "./type.js" export const isWaitPending = (wait: Wait): wait is WaitPending => !wait.done -export const isWaitDone = (wait: Wait): wait is WaitDone => wait.done +export const isWaitDone = (wait: Wait): wait is WaitResult => wait.done export const isWaitOk = (wait: Wait): wait is WaitOk => wait.done && wait.ok export const isWaitErr = (wait: Wait): wait is WaitErr => wait.done && !wait.ok diff --git a/s/nova/wait/parts/make.ts b/s/wait/parts/make.ts similarity index 100% rename from s/nova/wait/parts/make.ts rename to s/wait/parts/make.ts diff --git a/s/wait/parts/new.ts b/s/wait/parts/new.ts deleted file mode 100644 index b4d4231..0000000 --- a/s/wait/parts/new.ts +++ /dev/null @@ -1,10 +0,0 @@ - -import {Result} from "@e280/stz" -import {Wait} from "./type.js" - -export function newWait(result?: Result): Wait { - return result - ? {done: true, ...result} - : {done: false} -} - diff --git a/s/wait/parts/select.ts b/s/wait/parts/select.ts index ac73ad9..6e9fa5f 100644 --- a/s/wait/parts/select.ts +++ b/s/wait/parts/select.ts @@ -4,18 +4,24 @@ import {isWaitOk, isWaitPending} from "./is.js" import {waitNeedErr, waitNeedOk} from "./get.js" export function waitSelect(wait: Wait, select: { - pending: () => Ret, - ok: (value: Value) => Ret - err: (error: E) => Ret + pending?: () => Ret + ok?: (value: Value) => Ret + err?: (error: E) => Ret }) { + const { + pending = () => {}, + ok = () => {}, + err = () => {}, + } = select + if (isWaitPending(wait)) - return select.pending() + return pending() else if (isWaitOk(wait)) - return select.ok(waitNeedOk(wait)) + return ok(waitNeedOk(wait)) else - return select.err(waitNeedErr(wait)) + return err(waitNeedErr(wait)) } diff --git a/s/wait/parts/type.ts b/s/wait/parts/type.ts index f10d323..b1855b7 100644 --- a/s/wait/parts/type.ts +++ b/s/wait/parts/type.ts @@ -1,20 +1,18 @@ import {Err, Ok, Result} from "@e280/stz" -import {Derived} from "../../signals/index.js" +import {Derived} from "../../core/types.js" export type WaitPending = {done: false} -export type WaitDone = {done: true} & Result +export type WaitResult = {done: true} & Result export type WaitOk = {done: true} & Ok export type WaitErr = {done: true} & Err export type Wait = | WaitPending - | WaitDone + | WaitResult -export type WaitSignal = - & { - ready: Promise - result: Promise> - } - & Derived> +export type WaitDerived = Derived> & { + ready: Promise + result: Promise> +} diff --git a/s/wait/parts/wait.ts b/s/wait/parts/wait.ts index cf92b05..89f1b3a 100644 --- a/s/wait/parts/wait.ts +++ b/s/wait/parts/wait.ts @@ -1,33 +1,34 @@ -import {derived, signal} from "@e280/strata" import {attemptAsync, getOk, Result} from "@e280/stz" -import {newWait} from "./new.js" -import {Wait, WaitDone, WaitSignal} from "./type.js" +import {makeWait} from "./make.js" +import {signal} from "../../core/signal.js" +import {derived} from "../../core/derived.js" +import {Wait, WaitResult, WaitDerived} from "./type.js" export function wait( input: Promise | (() => Promise), ) { - return waitResult(attemptAsync(input)) + return waitFormal(attemptAsync(input)) } -export function waitResult( +export function waitFormal( input: Promise> | (() => Promise>), ) { - return waitResultPromise( + return waitFormalPromise( (typeof input === "function") ? input() : input ) } -function waitResultPromise(promise: Promise>) { - const $wait = signal>(newWait()) - const $derived = derived(() => $wait()) as WaitSignal +function waitFormalPromise(promise: Promise>) { + const $wait = signal>(makeWait()) + const $derived = derived(() => $wait()) as WaitDerived - $derived.result = promise.then(async result => { - const r: WaitDone = {done: true, ...result} - await $wait(r) + $derived.result = promise.then(result => { + const r: WaitResult = {done: true, ...result} + $wait(r) return r }) From c43ba6598d8c28e0d71265907b8f7bd0de50ced0 Mon Sep 17 00:00:00 2001 From: Chase Moskal Date: Sun, 3 May 2026 23:48:09 -0700 Subject: [PATCH 18/18] set: npm tag to 'next' --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index b35f734..209024a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -23,5 +23,5 @@ jobs: - run: npm ci - run: npm run build -s - run: npm test - - run: npm publish --ignore-scripts --tag latest + - run: npm publish --ignore-scripts --tag next