diff --git a/src/array.ts b/src/array.ts index 89091d1..c1dfd15 100644 --- a/src/array.ts +++ b/src/array.ts @@ -273,7 +273,7 @@ export const mapFlat = ( nf?: NF ) => { const coll = collector>(proxy); - return proxy.map( + return proxy.mapNoPrevious( [arr], (cells) => coll(proxy.mapNoPrevious(cells, (..._cells) => _cells)), name, diff --git a/src/cellify.test.ts b/src/cellify.test.ts index 3d638f5..65d099b 100644 --- a/src/cellify.test.ts +++ b/src/cellify.test.ts @@ -12,7 +12,7 @@ import { Sheet } from "./sheet"; type IsEqual = [T] extends [U] ? ([U] extends [T] ? true : false) : false; -test("", () => { +test("IsEqual type", () => { type T = { a: string[] }[]; type C = Cellified; type U = Uncellified; @@ -83,3 +83,19 @@ test("cellify failOnCell", async () => { const v = { a: [1, 2, 3], b: { c: { foo: proxy.new(1, "1"), bar: 1 } } }; expect(() => cellify(proxy, v, "cv", true)).toThrowError("value is cell"); }); + +test("cellify failOnError", async () => { + const sheet = new Sheet(); + const proxy = new SheetProxy(sheet); + const v = proxy.new(1); + // @ts-expect-error intentional + const m = v.map((v) => v.toLowerCase()); + // The standard uncellify call throws. + await expect(() => uncellify(m)).rejects.toThrow( + "toLowerCase is not a function" + ); + // But we retrieve the error with errorsAsValues. + await expect(uncellify(m, { errorsAsValues: true })).resolves.toBeInstanceOf( + Error + ); +}); diff --git a/src/cellify.ts b/src/cellify.ts index 83eded4..67671fd 100644 --- a/src/cellify.ts +++ b/src/cellify.ts @@ -1,4 +1,11 @@ -import { type AnyCell, Cell, type MapCell, type ValueCell } from "./cell"; +import { + type AnyCell, + Cell, + type CellResult, + type MapCell, + type Pending, + type ValueCell +} from "./cell"; import { collector } from "./gc"; import type { SheetProxy } from "./proxy"; @@ -22,14 +29,15 @@ export type Uncellified = T extends AnyCell : U : T; -// @todo is type only if true -// exclude classes +// isObject returns true if the value is a regular JavaScript Object, +// but not null neither a custom Class instance. export const isObject = ( v: unknown ): v is Record => typeof v === "object" && v !== null && v.constructor?.name === "Object"; const errIsCell = new Error("value is cell"); + /** * cellify converts any value to a Cellified value where each array or record * becomes a Cell in canonical form. @@ -63,24 +71,39 @@ export const cellify = ( ) as Cellified; }; +export type UncellifyOptions = { + getter?: (c: AnyCell) => Pending | CellResult; + errorsAsValues?: boolean; +}; + /** * uncellify is used in tests to flatten a value tree that contains multiple cells. * @param v any value * @returns value without cells */ export const uncellify = async ( - v: T | AnyCell + v: T | AnyCell, + options: UncellifyOptions = {} ): Promise> => { - const value = v instanceof Cell ? await v.consolidatedValue : v; - if (value instanceof Error) throw value; + const getter = options?.getter + ? options.getter + : (cell: AnyCell) => cell.consolidatedValue; + const value = v instanceof Cell ? await getter(v) : v; + if (value instanceof Error) { + if (options?.errorsAsValues) return value as Uncellified; + throw value; + } if (Array.isArray(value)) - return Promise.all(value.map((_element) => uncellify(_element))) as Promise< - Uncellified - >; + return Promise.all( + value.map((_element) => uncellify(_element, options)) + ) as Promise>; if (isObject(value)) return Object.fromEntries( await Promise.all( - Object.entries(value).map(async ([k, vv]) => [k, await uncellify(vv)]) + Object.entries(value).map(async ([k, vv]) => [ + k, + await uncellify(vv, options) + ]) ) ); // Classes, null or base types (string, number, ...) diff --git a/src/debouncer.ts b/src/debouncer.ts new file mode 100644 index 0000000..a35553c --- /dev/null +++ b/src/debouncer.ts @@ -0,0 +1,30 @@ +import type { ValueCell } from "./cell"; + +export type Debouncer = (cb: (v: T) => void | Promise, v: T) => void; + +/** + * debouncer creates a debounce function that will execute a callback after a _delay_. + * + * Create with `const debounce = debouncer()` + * and use as `debounce(cb, v, delay)`. + * @param cb callback + * @param v value passed to callback + * @param delay optional delay in ms, default: 750 + */ +export const debouncer = ( + delay = 750, + working: ValueCell | undefined = undefined +): Debouncer => { + // console.log({ setting: delay }); + let timer: ReturnType; + return (cb: (v: T) => void | Promise, v: T) => { + // console.log({ called: delay }); + if (working !== undefined) working.set(true); + clearTimeout(timer); + timer = setTimeout(async () => { + // console.log({ deb: delay }); + await cb(v); + if (working !== undefined) working.set(false); + }, delay); + }; +}; diff --git a/src/index.ts b/src/index.ts index f0d515b..9409b49 100644 --- a/src/index.ts +++ b/src/index.ts @@ -52,11 +52,14 @@ export { } from "./cellify"; export { clock, clockWork, type Clock } from "./clock"; export { copy } from "./copy"; +export { debouncer, type Debouncer } from "./debouncer"; export { Debugger } from "./debug"; +export { initialValue } from "./initial"; export { jsonStringify } from "./json"; export { nextSubscriber } from "./next"; export { asyncReduce, + flattenObject, mapObject, reduceObject, type CellObject diff --git a/src/initial.test.ts b/src/initial.test.ts new file mode 100644 index 0000000..27992f3 --- /dev/null +++ b/src/initial.test.ts @@ -0,0 +1,16 @@ +import { expect, test } from "vitest"; + +import { initialValue } from "./initial"; +import { isEqual } from "./isEqual.test"; +import { delayed, sleep } from "./promise"; +import { Sheet } from "./sheet"; + +test("initialValue", async () => { + const proxy = new Sheet(isEqual).newProxy(); + const a = proxy.new(1); + const b = proxy.new(delayed(2, 50)); + const c = initialValue(proxy, a, b); + expect(c.consolidatedValue).toBe(1); + await sleep(60); + expect(c.consolidatedValue).toBe(2); +}); diff --git a/src/initial.ts b/src/initial.ts new file mode 100644 index 0000000..457be71 --- /dev/null +++ b/src/initial.ts @@ -0,0 +1,19 @@ +import type { AnyCell, MapCell } from "./cell"; +import type { SheetProxy } from "./proxy"; + +export const initialValue = ( + proxy: SheetProxy, + v0: T | AnyCell, + v: AnyCell, + name = "initial" +): MapCell => { + const cell = proxy.new(v0, name); + v.subscribe((v) => { + // We do not propagate errors yet. + if (v instanceof Error) return; + cell.set(v); + }); + // We fake being a MapCell to prevent setting the cell + // outside of this function. + return cell as unknown as MapCell; +}; diff --git a/src/json.ts b/src/json.ts index de88b88..e03e147 100644 --- a/src/json.ts +++ b/src/json.ts @@ -57,7 +57,7 @@ export const jsonStringify = ( case "symbol": break; case "bigint": - out += v < 1_000_000n ? Number(v) : `"${v.toString()}"`; + out += v < 1_000_000_000n ? Number(v) : `"${v.toString()}"`; break; default: out += JSON.stringify(v); diff --git a/src/object.test.ts b/src/object.test.ts index 6050607..09bc4d6 100644 --- a/src/object.test.ts +++ b/src/object.test.ts @@ -4,13 +4,13 @@ import { expect, test } from "vitest"; import { cellify, uncellify } from "./cellify"; import { Debugger } from "./debug"; import { isEqual } from "./isEqual.test"; -import { asyncReduce, mapObject, reduceObject } from "./object"; +import { asyncReduce, flattenObject, mapObject, reduceObject } from "./object"; import { delayed } from "./promise"; import { SheetProxy } from "./proxy"; import { Sheet } from "./sheet"; test("mapObject", async () => { - const sheet = new Sheet(); + const sheet = new Sheet(isEqual); const proxy = new SheetProxy(sheet); const obj = cellify(proxy, { a: 1, b: "foo", c: "bar" }); @@ -60,7 +60,7 @@ test("asyncReduce", async () => { test( "reduceObject", async () => { - const sheet = new Sheet(); + const sheet = new Sheet(isEqual); const debug = new Debugger(sheet); const proxy = new SheetProxy(sheet); @@ -89,3 +89,18 @@ test( }, { timeout: 1000 } ); + +test("flattenObject", async () => { + const sheet = new Sheet(isEqual); + const debug = new Debugger(sheet); + const proxy = new SheetProxy(sheet); + + const obj = cellify(proxy, { a: 1, b: 2, c: 3 }); + const f = flattenObject(proxy, obj); + await expect(f.consolidatedValue).resolves.toEqual({ a: 1, b: 2, c: 3 }); + expect(sheet.stats).toEqual({ count: 6, size: 6 }); // 3+1 obj +1 flatten +1 pointer + + await (await obj.consolidatedValue).a.set(4); + await expect(f.get()).resolves.toEqual({ a: 4, b: 2, c: 3 }); + expect(sheet.stats).toEqual({ count: 6, size: 6 }); // unchanged +}); diff --git a/src/object.ts b/src/object.ts index 3d9df55..cb2f8e8 100644 --- a/src/object.ts +++ b/src/object.ts @@ -11,7 +11,6 @@ export type CellObject = AnyCell>>; export const mapObject = ( proxy: SheetProxy, obj: CellObject, - // @todo return type fn: ( key: string, value: T, @@ -22,7 +21,7 @@ export const mapObject = ( ): MapCell>, NF> => proxy.map( [obj], - (cells, prev) => { + async (cells, prev) => { const set = new Set(Object.keys(prev || {})); const res = Object.fromEntries( Object.entries(cells).map(([k, v]) => { @@ -83,7 +82,7 @@ export const reduceObject = ( const coll = collector>(proxy); return proxy.mapNoPrevious( [obj], - (cells) => { + async (cells) => { const keys = Object.keys(cells); const values = Object.values(cells); // console.log({ reduce: keys, name, count: proxy._sheet.stats.count }); @@ -104,3 +103,26 @@ export const reduceObject = ( nf ); }; + +export const flattenObject = ( + proxy: SheetProxy, + obj: CellObject, + name = "flatten", + nf?: NF +) => { + const coll = collector, NF>>(proxy); + return proxy.mapNoPrevious( + [obj], + async (cells) => { + const keys = Object.keys(cells); + const values = Object.values(cells); + return coll( + proxy.mapNoPrevious(values, (..._cells) => + Object.fromEntries(_cells.map((v, i) => [keys[i], v])) + ) + ); + }, + name, + nf + ); +}; diff --git a/src/proxy.test.ts b/src/proxy.test.ts index 7523e49..3357f4b 100644 --- a/src/proxy.test.ts +++ b/src/proxy.test.ts @@ -29,23 +29,26 @@ test("native proxy", () => { expect(trigger).toBeTruthy(); }); -test("SheetProxy", () => { - const store = new Sheet(); - const value = store.new(2); - const proxy = new SheetProxy(store); +test("SheetProxy", async () => { + const sheet = new Sheet(); + const value = sheet.new(2); + const proxy = new SheetProxy(sheet); const double = proxy.map([value], (x) => 2 * x); const add = proxy.map([double], (x) => x + 1); proxy.destroy(); + // Force GC collection + sheet.collection(); + await proxy.working.wait(); value.set(3); // will not update detached/deleted cells expect(double.value).toBe(4); expect(add.value).toBe(5); }); test("Sheet multiple async updates", async () => { - const store = new Sheet(); - const value = store.new(2); + const sheet = new Sheet(); + const value = sheet.new(2); const double = value.map(async (x) => delayed(2 * x, 50)); - const add = store.map([value, double], (value, double) => + const add = sheet.map([value, double], (value, double) => delayed(value + double + 1, 30) ); // console.log("value", value.id, "double", double.id, "add", add.id); @@ -54,7 +57,7 @@ test("Sheet multiple async updates", async () => { value.set(3); expect(await add.consolidatedValue).toBe(10); value.set(4); - await store.wait(); + await sheet.wait(); expect(await add.consolidatedValue).toBe(13); }); @@ -98,9 +101,13 @@ test("proxy deletion", async () => { const c = sub.map([a, b], async (a, b) => a + b); expect(sheet.stats).toEqual({ count: 3, size: 3 }); sub.destroy(); + await proxy.working.wait(); expect(sheet.stats).toEqual({ count: 3, size: 2 }); await expect(b.get()).resolves.toBe(2); proxy.destroy(); + // Force collection. + sheet.collection(); + // Collection is not happening yet. expect(sheet.stats).toEqual({ count: 3, size: 0 }); }); @@ -113,5 +120,7 @@ test("proxy deletion with loop", async () => { const c = sub.map([a, b], async (a, b) => delayed(a + b, 5)); const d = proxy.map([c], async (v) => delayed(v * 2, 15)); expect(sheet.stats).toEqual({ count: 4, size: 4 }); - expect(() => sub.destroy()).toThrow("Cell has references"); + // Since we now collect the whole subgraph, there is no error. + sub.destroy(); + // expect(() => sub.destroy()).toThrow("Cell has references"); }); diff --git a/src/proxy.ts b/src/proxy.ts index 64ff258..f220d8c 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -167,6 +167,31 @@ export class SheetProxy { name?: string, noFail?: NF ): MapCell; + map( + dependencies: [ + AnyCell, + AnyCell, + AnyCell, + AnyCell, + AnyCell, + AnyCell, + AnyCell, + AnyCell + ], + computeFn: ( + arg1: D1, + arg2: D2, + arg3: D3, + arg4: D4, + arg5: D5, + arg6: D6, + arg7: D7, + arg8: D8, + prev?: V + ) => V | Promise> | AnyCell, + name?: string, + noFail?: NF + ): MapCell; /** * map a list to cells to a new cells using the compute function. @@ -250,13 +275,9 @@ export class SheetProxy { /** * destroy the Proxy and free memory. - * @todo check for memory leaks */ destroy() { - // if (!this?._sheet) { - // throw new Error(`missing: ${this?._name}`); - // } - this._sheet.delete(...this._list); + this._sheet.collect(...this._list); this._list = []; } } diff --git a/src/sheet.ts b/src/sheet.ts index 39fa50b..e7f6906 100644 --- a/src/sheet.ts +++ b/src/sheet.ts @@ -567,12 +567,7 @@ export class Sheet { this._internalNotify(_result.done); // Collect garbage - if (this._gc.size) { - const l = Array.from(this._gc); - this.debug(l, "gc", { deleting: l }); - this._gc = new Set(); - this.delete(...l); - } + this.collection(); // End of the update release(); @@ -592,6 +587,15 @@ export class Sheet { ); } + collection() { + if (this._gc.size) { + const l = Array.from(this._gc); + this.debug(l, "gc", { deleting: l }); + this._gc = new Set(); + this.delete(...l); + } + } + private registerCancelAndDone( updatable: number[], computations: (V | Canceled | Error)[], @@ -957,7 +961,7 @@ export class Sheet { this.debug(ids, "collect", { collecting: ids, cells: input }); for (const id of ids) { const deps = this.g.partialTopologicalSort(id); - this.debug(undefined, "collect", { deps }); + // this.debug(undefined, "collect", { deps }); for (const dep of deps) this._gc.add(dep); } }