Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

new utilities: flattenObject, debouncer, initialValue, uncellify options #34

Merged
merged 24 commits into from
May 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/array.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,7 @@ export const mapFlat = <T, NF extends boolean = false>(
nf?: NF
) => {
const coll = collector<MapCell<T[], NF>>(proxy);
return proxy.map(
return proxy.mapNoPrevious(
[arr],
(cells) => coll(proxy.mapNoPrevious(cells, (..._cells) => _cells)),
name,
Expand Down
18 changes: 17 additions & 1 deletion src/cellify.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { Sheet } from "./sheet";

type IsEqual<T, U> = [T] extends [U] ? ([U] extends [T] ? true : false) : false;

test("", () => {
test("IsEqual type", () => {
type T = { a: string[] }[];
type C = Cellified<T>;
type U = Uncellified<C>;
Expand Down Expand Up @@ -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
);
});
43 changes: 33 additions & 10 deletions src/cellify.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -22,14 +29,15 @@ export type Uncellified<T> = T extends AnyCell<infer U>
: 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 = <K extends string | number | symbol>(
v: unknown
): v is Record<K, unknown> =>
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.
Expand Down Expand Up @@ -63,24 +71,39 @@ export const cellify = <T>(
) as Cellified<T>;
};

export type UncellifyOptions = {
getter?: <T>(c: AnyCell<T>) => Pending<T, boolean> | CellResult<T, boolean>;
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 <T>(
v: T | AnyCell<T>
v: T | AnyCell<T>,
options: UncellifyOptions = {}
): Promise<Uncellified<T>> => {
const value = v instanceof Cell ? await v.consolidatedValue : v;
if (value instanceof Error) throw value;
const getter = options?.getter
? options.getter
: (cell: AnyCell<unknown>) => cell.consolidatedValue;
const value = v instanceof Cell ? await getter(v) : v;
if (value instanceof Error) {
if (options?.errorsAsValues) return value as Uncellified<T>;
throw value;
}
if (Array.isArray(value))
return Promise.all(value.map((_element) => uncellify(_element))) as Promise<
Uncellified<T>
>;
return Promise.all(
value.map((_element) => uncellify(_element, options))
) as Promise<Uncellified<T>>;
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, ...)
Expand Down
25 changes: 25 additions & 0 deletions src/debouncer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { expect, test } from "vitest";

import { debouncer } from "./debouncer";
import { isEqual } from "./isEqual.test";
import { sleep } from "./promise";
import { Sheet } from "./sheet";

test("debouncer", async () => {
const proxy = new Sheet(isEqual).newProxy();
const waiting = proxy.new(false);
const deb = debouncer(20, waiting);
const v = proxy.new(0);
expect(waiting.consolidatedValue).toBe(false);

for (let i = 1; i <= 10; i++) {
deb((i) => v.set(i), i);
await sleep(5);
expect(v.consolidatedValue).toBe(0);
expect(waiting.consolidatedValue).toBe(true);
}

await sleep(30);
expect(v.consolidatedValue).toBe(10);
expect(waiting.consolidatedValue).toBe(false);
});
30 changes: 30 additions & 0 deletions src/debouncer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type { ValueCell } from "./cell";

export type Debouncer = <T>(cb: (v: T) => void | Promise<void>, 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<boolean> | undefined = undefined
): Debouncer => {
// console.log({ setting: delay });
let timer: ReturnType<typeof setTimeout>;
return <T>(cb: (v: T) => void | Promise<void>, 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);
};
};
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions src/initial.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
19 changes: 19 additions & 0 deletions src/initial.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { AnyCell, MapCell } from "./cell";
import type { SheetProxy } from "./proxy";

export const initialValue = <T>(
proxy: SheetProxy,
v0: T | AnyCell<T>,
v: AnyCell<T>,
name = "initial"
): MapCell<T, true> => {
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<T, true>;
};
2 changes: 1 addition & 1 deletion src/json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export const jsonStringify = <T>(
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);
Expand Down
21 changes: 18 additions & 3 deletions src/object.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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
});
28 changes: 25 additions & 3 deletions src/object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ export type CellObject<T> = AnyCell<Record<string, AnyCell<T>>>;
export const mapObject = <T, U, NF extends boolean = false>(
proxy: SheetProxy,
obj: CellObject<T>,
// @todo return type
fn: (
key: string,
value: T,
Expand All @@ -22,7 +21,7 @@ export const mapObject = <T, U, NF extends boolean = false>(
): MapCell<Record<string, AnyCell<U>>, 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]) => {
Expand Down Expand Up @@ -83,7 +82,7 @@ export const reduceObject = <T, R, NF extends boolean = false>(
const coll = collector<MapCell<R, NF>>(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 });
Expand All @@ -104,3 +103,26 @@ export const reduceObject = <T, R, NF extends boolean = false>(
nf
);
};

export const flattenObject = <T, NF extends boolean = false>(
proxy: SheetProxy,
obj: CellObject<T>,
name = "flatten",
nf?: NF
) => {
const coll = collector<MapCell<Record<string, T>, 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
);
};
27 changes: 18 additions & 9 deletions src/proxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
});

Expand Down Expand Up @@ -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 });
});

Expand All @@ -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");
});
Loading
Loading