diff --git a/.eslintrc.json b/.eslintrc.json index 07861ff0..c3cd3f35 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -4,6 +4,14 @@ "plugins": ["@typescript-eslint"], "ignorePatterns": ["dist"], "extends": ["prettier", "plugin:@typescript-eslint/recommended"], + "parserOptions": { + "ecmaVersion": 2023, + "sourceType": "module" + }, + "env": { + "es2022": true, + "node": true + }, "rules": { "@typescript-eslint/ban-types": "off", "@typescript-eslint/no-unsafe-member-access": "off", diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 00000000..cfbc5d35 --- /dev/null +++ b/.tool-versions @@ -0,0 +1,2 @@ +nodejs 23.11.0 +yarn 1.22.22 diff --git a/.yarn/install-state.gz b/.yarn/install-state.gz index 60a96042..464251b9 100644 Binary files a/.yarn/install-state.gz and b/.yarn/install-state.gz differ diff --git a/.zed/debug.json b/.zed/debug.json new file mode 100644 index 00000000..4be1a903 --- /dev/null +++ b/.zed/debug.json @@ -0,0 +1,5 @@ +// Project-local debug tasks +// +// For more documentation on how to configure debug tasks, +// see: https://zed.dev/docs/debugger +[] diff --git a/package.json b/package.json index 9a8b84c6..0f9bfdab 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,6 @@ "eslint-plugin-import": "^2.27.5", "eslint-plugin-jsx-a11y": "^6.7.1", "turbo": "^1.9.4", - "typescript": "^5.0.4" + "typescript": "^5.5.0" } } diff --git a/packages/arbor-ost/.eslintrc.json b/packages/arbor-ost/.eslintrc.json new file mode 100644 index 00000000..0c3dcc99 --- /dev/null +++ b/packages/arbor-ost/.eslintrc.json @@ -0,0 +1,7 @@ +{ + "extends": ["../../.eslintrc.json"], + "ignorePatterns": ["perf"], + "parserOptions": { + "project": ["./tsconfig.eslint.json"] + } +} diff --git a/packages/arbor-ost/.prettierignore b/packages/arbor-ost/.prettierignore new file mode 100644 index 00000000..ed9fc32c --- /dev/null +++ b/packages/arbor-ost/.prettierignore @@ -0,0 +1,2 @@ +dist/ +**/dist/ diff --git a/packages/arbor-ost/package.json b/packages/arbor-ost/package.json new file mode 100644 index 00000000..e843f8c9 --- /dev/null +++ b/packages/arbor-ost/package.json @@ -0,0 +1,64 @@ +{ + "name": "@arborjs/ost", + "sideEffects": false, + "version": "0.0.1-alpha.1", + "description": "An observable state tree data structure used to power @arborjs/store.", + "keywords": [ + "arbor", + "react", + "state", + "store", + "typescript", + "tree" + ], + "repository": { + "url": "https://github.com/drborges/arbor", + "type": "git" + }, + "bugs": { + "url": "https://github.com/drborges/arbor/issues", + "email": "drborges.cic@gmail.com" + }, + "author": { + "email": "drborges.cic@gmail.com", + "name": "Diego Borges", + "url": "https://github.com/drborges" + }, + "main": "./dist/index.cjs.js", + "module": "./dist/index.esm.js", + "types": "./dist/index.d.ts", + "type": "module", + "exports": { + ".": { + "require": "./dist/index.cjs.js", + "import": "./dist/index.esm.js", + "default": "./dist/index.esm.js" + } + }, + "license": "MIT", + "prettier": "@arborjs/config-prettier", + "files": [ + "./dist", + "./README", + "../../LICENSE" + ], + "publishConfig": { + "access": "public" + }, + "devDependencies": { + "esbuild": "^0.17.19", + "eslint": "^8.40.0", + "prettier": "^2.8.8", + "typescript": "^5.0.4", + "vitest": "^1.6.0" + }, + "scripts": { + "prettier": "prettier -w . ", + "clean": "rm -rf dist", + "lint": "yarn eslint . --ext .js,.jsx,.ts,.tsx", + "test": "vitest run", + "dev": "vitest -w", + "build": "yarn clean && NODE_ENV=production node ../../tools/build.js && yarn tsc", + "build:dev": "yarn clean && NODE_ENV=development node ../../tools/build.js && yarn tsc" + } +} diff --git a/packages/arbor-ost/src/decorators/detached.ts b/packages/arbor-ost/src/decorators/detached.ts new file mode 100644 index 00000000..23a6040d --- /dev/null +++ b/packages/arbor-ost/src/decorators/detached.ts @@ -0,0 +1,7 @@ +export const ArborDetached = Symbol.for("ArborDetached") + +export function detached(target: unknown, prop: unknown) { + target[ArborDetached] = target[ArborDetached] || {} + const detachedProps = target[ArborDetached] + detachedProps[prop] = true +} diff --git a/packages/arbor-ost/src/decorators/node.ts b/packages/arbor-ost/src/decorators/node.ts new file mode 100644 index 00000000..8a9dfb3d --- /dev/null +++ b/packages/arbor-ost/src/decorators/node.ts @@ -0,0 +1,17 @@ +export const ArborProxiable = Symbol.for("ArborProxiable") + +/** + * Make application classes eligible for being used as nodes in the Arbor state tree. + * + * @example + * + * ```ts + * @node + * class Todo { + * text: string + * } + * ``` + */ +export function node(target: T, _context: unknown = null) { + target.prototype[ArborProxiable] = true +} diff --git a/packages/arbor-ost/src/errors.ts b/packages/arbor-ost/src/errors.ts new file mode 100644 index 00000000..e9dab758 --- /dev/null +++ b/packages/arbor-ost/src/errors.ts @@ -0,0 +1,9 @@ +export class ArborError extends Error {} + +export class DetachedPathError extends ArborError { + constructor(humanizedPath: string) { + super( + `Path ${humanizedPath} is detached from the tree. Cannot perform mutation.` + ) + } +} diff --git a/packages/arbor-ost/src/handlers/$array/index.ts b/packages/arbor-ost/src/handlers/$array/index.ts new file mode 100644 index 00000000..a0445e9f --- /dev/null +++ b/packages/arbor-ost/src/handlers/$array/index.ts @@ -0,0 +1,32 @@ +import { OST } from "../../ost" +import { $object } from "./../$object" +import { PopVisitor } from "./visitors/pop" +import { PushVisitor } from "./visitors/push" +import { ShiftVisitor } from "./visitors/shift" +import { SpliceVisitor } from "./visitors/splice" +import { UnshiftVisitor } from "./visitors/unshift" +import { ReverseVisitor } from "./visitors/reverse" +import { CopyWithinVisitor } from "./visitors/copyWithin" +import { FillVisitor } from "./visitors/fill" +import { Visitors } from "../visitors" + +const visitors = new Visitors( + new PushVisitor(), + new PopVisitor(), + new ShiftVisitor(), + new UnshiftVisitor(), + new ReverseVisitor(), + new SpliceVisitor(), + new CopyWithinVisitor(), + new FillVisitor() +) + +export class $array extends $object { + constructor(ost: OST) { + super(ost, visitors) + } + + static accepts(value: unknown): boolean { + return Array.isArray(value) + } +} diff --git a/packages/arbor-ost/src/handlers/$array/visitors/copyWithin.ts b/packages/arbor-ost/src/handlers/$array/visitors/copyWithin.ts new file mode 100644 index 00000000..69c9185b --- /dev/null +++ b/packages/arbor-ost/src/handlers/$array/visitors/copyWithin.ts @@ -0,0 +1,18 @@ +import { Visitor } from "../../visitor" + +export class CopyWithinVisitor extends Visitor { + prop = "copyWithin" + + visit({ ost, target, $node }) { + return (...args: unknown[]) => { + return ost.mutate($node, () => { + target.copyWithin(...args) + + return { + args: args, + operation: "copyWithin", + } + }) + } + } +} diff --git a/packages/arbor-ost/src/handlers/$array/visitors/fill.ts b/packages/arbor-ost/src/handlers/$array/visitors/fill.ts new file mode 100644 index 00000000..284f8881 --- /dev/null +++ b/packages/arbor-ost/src/handlers/$array/visitors/fill.ts @@ -0,0 +1,18 @@ +import { Visitor } from "../../visitor" + +export class FillVisitor extends Visitor { + prop = "fill" + + visit({ ost, target, $node }) { + return (...args: unknown[]) => { + return ost.mutate($node, () => { + target.fill(...args) + + return { + args: args, + operation: "fill", + } + }) + } + } +} diff --git a/packages/arbor-ost/src/handlers/$array/visitors/pop.ts b/packages/arbor-ost/src/handlers/$array/visitors/pop.ts new file mode 100644 index 00000000..e9baf042 --- /dev/null +++ b/packages/arbor-ost/src/handlers/$array/visitors/pop.ts @@ -0,0 +1,22 @@ +import { Visitor } from "../../visitor" + +export class PopVisitor extends Visitor { + prop = "pop" + + visit({ ost, target, $node }) { + return () => { + let popped: unknown + + ost.mutate($node, () => { + popped = target.pop() + + return { + args: [], + operation: "pop", + } + }) + + return popped + } + } +} diff --git a/packages/arbor-ost/src/handlers/$array/visitors/push.ts b/packages/arbor-ost/src/handlers/$array/visitors/push.ts new file mode 100644 index 00000000..2304219d --- /dev/null +++ b/packages/arbor-ost/src/handlers/$array/visitors/push.ts @@ -0,0 +1,23 @@ +import { Visitor } from "../../visitor" +import { Value } from "../../../types" + +export class PushVisitor extends Visitor { + prop = "push" + + visit({ ost, target, $node }) { + return (...items: Value[]) => { + let length: number + + ost.mutate($node, () => { + length = target.push(...items) + + return { + args: [items], + operation: "push", + } + }) + + return length + } + } +} diff --git a/packages/arbor-ost/src/handlers/$array/visitors/reverse.ts b/packages/arbor-ost/src/handlers/$array/visitors/reverse.ts new file mode 100644 index 00000000..8341038f --- /dev/null +++ b/packages/arbor-ost/src/handlers/$array/visitors/reverse.ts @@ -0,0 +1,18 @@ +import { Visitor } from "../../visitor" + +export class ReverseVisitor extends Visitor { + prop = "reverse" + + visit({ ost, target, $node }) { + return () => { + return ost.mutate($node, () => { + target.reverse() + + return { + args: [], + operation: "reverse", + } + }) + } + } +} diff --git a/packages/arbor-ost/src/handlers/$array/visitors/shift.ts b/packages/arbor-ost/src/handlers/$array/visitors/shift.ts new file mode 100644 index 00000000..a6b457fc --- /dev/null +++ b/packages/arbor-ost/src/handlers/$array/visitors/shift.ts @@ -0,0 +1,22 @@ +import { Visitor } from "../../visitor" + +export class ShiftVisitor extends Visitor { + prop = "shift" + + visit({ ost, target, $node }) { + return () => { + let removed: unknown + + ost.mutate($node, () => { + removed = target.shift() + + return { + args: [], + operation: "shift", + } + }) + + return removed + } + } +} diff --git a/packages/arbor-ost/src/handlers/$array/visitors/splice.ts b/packages/arbor-ost/src/handlers/$array/visitors/splice.ts new file mode 100644 index 00000000..d7de344f --- /dev/null +++ b/packages/arbor-ost/src/handlers/$array/visitors/splice.ts @@ -0,0 +1,26 @@ +import { Visitor } from "../../visitor" + +export class SpliceVisitor extends Visitor { + prop = "splice" + + visit({ ost, target, $node }) { + return ( + start: number, + deleteCount: number, + ...items: unknown[] + ): unknown[] => { + let deleted: unknown[] + + ost.mutate($node, () => { + deleted = target.splice(start, deleteCount, ...items) + + return { + args: [start, deleteCount, items], + operation: "splice", + } + }) + + return deleted + } + } +} diff --git a/packages/arbor-ost/src/handlers/$array/visitors/unshift.ts b/packages/arbor-ost/src/handlers/$array/visitors/unshift.ts new file mode 100644 index 00000000..cc6b7dce --- /dev/null +++ b/packages/arbor-ost/src/handlers/$array/visitors/unshift.ts @@ -0,0 +1,22 @@ +import { Visitor } from "../../visitor" + +export class UnshiftVisitor extends Visitor { + prop = "unshift" + + visit({ ost, target, $node }) { + return (...args: unknown[]) => { + let unshifted: unknown[] + + ost.mutate($node, () => { + unshifted = target.unshift(...args) + + return { + args: [args], + operation: "unshift", + } + }) + + return unshifted + } + } +} diff --git a/packages/arbor-ost/src/handlers/$map/index.ts b/packages/arbor-ost/src/handlers/$map/index.ts new file mode 100644 index 00000000..446922c5 --- /dev/null +++ b/packages/arbor-ost/src/handlers/$map/index.ts @@ -0,0 +1,40 @@ +import { OST } from "../../ost" +import { $object } from "./../$object" +import { Visitors } from "../visitors" +import { GetVisitor } from "./visitors/get" +import { SetVisitor } from "./visitors/set" +import { SizeVisitor } from "./visitors/size" +import { DeleteVisitor } from "./visitors/delete" +import { ClearVisitor } from "./visitors/clear" +import { EntriesVisitor } from "./visitors/entries" +import { ForEachVisitor } from "./visitors/forEach" +import { HasVisitor } from "./visitors/has" +import { KeysVisitor } from "./visitors/keys" +import { ValuesVisitor } from "./visitors/values" +import { IteratorVisitor } from "./visitors/iterator" +import { ChildrenVisitor } from "./visitors/$children" + +const visitors = new Visitors( + new ChildrenVisitor(), + new GetVisitor(), + new SetVisitor(), + new SizeVisitor(), + new DeleteVisitor(), + new ClearVisitor(), + new EntriesVisitor(), + new ForEachVisitor(), + new HasVisitor(), + new KeysVisitor(), + new ValuesVisitor(), + new IteratorVisitor() +) + +export class $map extends $object { + constructor(ost: OST) { + super(ost, visitors) + } + + static accepts(value: unknown): boolean { + return value instanceof Map + } +} diff --git a/packages/arbor-ost/src/handlers/$map/visitors/$children.ts b/packages/arbor-ost/src/handlers/$map/visitors/$children.ts new file mode 100644 index 00000000..12e44e88 --- /dev/null +++ b/packages/arbor-ost/src/handlers/$map/visitors/$children.ts @@ -0,0 +1,5 @@ +import { ValuesVisitor } from "./values" + +export class ChildrenVisitor extends ValuesVisitor { + prop = "$children" +} diff --git a/packages/arbor-ost/src/handlers/$map/visitors/clear.ts b/packages/arbor-ost/src/handlers/$map/visitors/clear.ts new file mode 100644 index 00000000..dea11539 --- /dev/null +++ b/packages/arbor-ost/src/handlers/$map/visitors/clear.ts @@ -0,0 +1,24 @@ +import { Visitor } from "../../visitor" + +export class ClearVisitor extends Visitor { + prop = "clear" + + visit({ ost, target, $node }) { + return () => { + if (target.size === 0) { + return false + } + + ost.mutate($node, () => { + target.clear() + + return { + args: [], + operation: "clear", + } + }) + + return true + } + } +} diff --git a/packages/arbor-ost/src/handlers/$map/visitors/delete.ts b/packages/arbor-ost/src/handlers/$map/visitors/delete.ts new file mode 100644 index 00000000..0fea2c4a --- /dev/null +++ b/packages/arbor-ost/src/handlers/$map/visitors/delete.ts @@ -0,0 +1,24 @@ +import { Visitor } from "../../visitor" + +export class DeleteVisitor extends Visitor { + prop = "delete" + + visit({ ost, target, $node }) { + return (key: unknown) => { + if (!target.has(key)) { + return false + } + + ost.mutate($node, () => { + target.delete(key) + + return { + args: [key], + operation: "delete", + } + }) + + return true + } + } +} diff --git a/packages/arbor-ost/src/handlers/$map/visitors/entries.ts b/packages/arbor-ost/src/handlers/$map/visitors/entries.ts new file mode 100644 index 00000000..fbc1f665 --- /dev/null +++ b/packages/arbor-ost/src/handlers/$map/visitors/entries.ts @@ -0,0 +1,13 @@ +import { Visitor } from "../../visitor" + +export class EntriesVisitor extends Visitor { + prop = "entries" + + visit({ target, $node }) { + return function* () { + for (const [key] of target.entries()) { + yield [key, $node.get(key)] + } + } + } +} diff --git a/packages/arbor-ost/src/handlers/$map/visitors/forEach.ts b/packages/arbor-ost/src/handlers/$map/visitors/forEach.ts new file mode 100644 index 00000000..4d1d47dc --- /dev/null +++ b/packages/arbor-ost/src/handlers/$map/visitors/forEach.ts @@ -0,0 +1,16 @@ +import { Visitor } from "../../visitor" + +export class ForEachVisitor extends Visitor { + prop = "forEach" + + visit({ target, $node }) { + return function ( + cb: (value: unknown, key: unknown, map: unknown) => void, + thisArg?: unknown + ) { + for (const [key] of target.entries()) { + cb($node.get(key), key, thisArg || $node) + } + } + } +} diff --git a/packages/arbor-ost/src/handlers/$map/visitors/get.ts b/packages/arbor-ost/src/handlers/$map/visitors/get.ts new file mode 100644 index 00000000..d50e7f14 --- /dev/null +++ b/packages/arbor-ost/src/handlers/$map/visitors/get.ts @@ -0,0 +1,18 @@ +import { Visitor } from "../../visitor" +import { isProxiable } from "../../$object/visitors/proxiable" + +export class GetVisitor extends Visitor { + prop = "get" + + visit({ target, ost, $node }) { + return (key: unknown) => { + const childValue = target.get(key) + + if (!isProxiable(childValue)) { + return childValue + } + + return ost.nodeOf(childValue) || $node.$createChild(childValue) + } + } +} diff --git a/packages/arbor-ost/src/handlers/$map/visitors/has.ts b/packages/arbor-ost/src/handlers/$map/visitors/has.ts new file mode 100644 index 00000000..b449fc50 --- /dev/null +++ b/packages/arbor-ost/src/handlers/$map/visitors/has.ts @@ -0,0 +1,9 @@ +import { Visitor } from "../../visitor" + +export class HasVisitor extends Visitor { + prop = "has" + + visit({ target }) { + return target.has.bind(target) + } +} diff --git a/packages/arbor-ost/src/handlers/$map/visitors/iterator.ts b/packages/arbor-ost/src/handlers/$map/visitors/iterator.ts new file mode 100644 index 00000000..b0e5f88c --- /dev/null +++ b/packages/arbor-ost/src/handlers/$map/visitors/iterator.ts @@ -0,0 +1,13 @@ +import { Visitor } from "../../visitor" + +export class IteratorVisitor extends Visitor { + prop = Symbol.iterator + + visit({ target, $node }) { + return function* () { + for (const [key] of target.entries()) { + yield [key, $node.get(key)] + } + } + } +} diff --git a/packages/arbor-ost/src/handlers/$map/visitors/keys.ts b/packages/arbor-ost/src/handlers/$map/visitors/keys.ts new file mode 100644 index 00000000..a86ba1ee --- /dev/null +++ b/packages/arbor-ost/src/handlers/$map/visitors/keys.ts @@ -0,0 +1,9 @@ +import { Visitor } from "../../visitor" + +export class KeysVisitor extends Visitor { + prop = "keys" + + visit({ target }) { + return target.keys.bind(target) + } +} diff --git a/packages/arbor-ost/src/handlers/$map/visitors/set.ts b/packages/arbor-ost/src/handlers/$map/visitors/set.ts new file mode 100644 index 00000000..0240b6ec --- /dev/null +++ b/packages/arbor-ost/src/handlers/$map/visitors/set.ts @@ -0,0 +1,19 @@ +import { Visitor } from "../../visitor" +import { Value } from "../../../types" + +export class SetVisitor extends Visitor { + prop = "set" + + visit({ ost, target, $node }) { + return (key: unknown, value: Value) => { + return ost.mutate($node, () => { + target.set(key, value) + + return { + args: [key, value], + operation: "set", + } + }) + } + } +} diff --git a/packages/arbor-ost/src/handlers/$map/visitors/size.ts b/packages/arbor-ost/src/handlers/$map/visitors/size.ts new file mode 100644 index 00000000..5d6d393c --- /dev/null +++ b/packages/arbor-ost/src/handlers/$map/visitors/size.ts @@ -0,0 +1,9 @@ +import { Visitor } from "../../visitor" + +export class SizeVisitor extends Visitor { + prop = "size" + + visit({ target }) { + return target.size + } +} diff --git a/packages/arbor-ost/src/handlers/$map/visitors/values.ts b/packages/arbor-ost/src/handlers/$map/visitors/values.ts new file mode 100644 index 00000000..6db58394 --- /dev/null +++ b/packages/arbor-ost/src/handlers/$map/visitors/values.ts @@ -0,0 +1,13 @@ +import { Visitor } from "../../visitor" + +export class ValuesVisitor extends Visitor { + prop = "values" + + visit({ target, $node }) { + return function* () { + for (const key of target.keys()) { + yield $node.get(key) + } + } + } +} diff --git a/packages/arbor-ost/src/handlers/$object/index.ts b/packages/arbor-ost/src/handlers/$object/index.ts new file mode 100644 index 00000000..07c7c92a --- /dev/null +++ b/packages/arbor-ost/src/handlers/$object/index.ts @@ -0,0 +1,67 @@ +import { OST } from "../../ost" +import { Node, Prop, Value } from "../../types" +import { Visitors } from "../visitors" + +import { isDetachedProperty } from "./visitors/detached" + +const defaultVisitors = new Visitors() + +export class $object implements ProxyHandler { + #visitors: Visitors + + constructor(readonly $ost: OST, visitors = defaultVisitors) { + this.#visitors = visitors + } + + static accepts(_value: unknown) { + return true + } + + get(target: V, prop: Prop, $node: Node) { + const childValue = Reflect.get(target, prop, target) as Value + + return this.#visitors.visit({ + ost: this.$ost, + target, + prop, + $node, + childValue, + }) + } + + set(target: V, prop: Prop, newValue: unknown, $node: Node): boolean { + if (isDetachedProperty(target, prop)) { + return Reflect.set(target, prop, newValue, $node) + } + + this.$ost.mutate($node, () => { + Reflect.set(target, prop, newValue, $node) + + return { + args: [prop, newValue], + operation: "set", + } + }) + + return true + } + + deleteProperty(target: V, prop: Prop): boolean { + if (isDetachedProperty(target, prop)) { + return Reflect.deleteProperty(target, prop) + } + + const $node = this.$ost.nodeOf(target) + + this.$ost.mutate($node, () => { + Reflect.deleteProperty(target, prop) + + return { + args: [prop], + operation: "delete", + } + }) + + return true + } +} diff --git a/packages/arbor-ost/src/handlers/$object/visitors/$children.ts b/packages/arbor-ost/src/handlers/$object/visitors/$children.ts new file mode 100644 index 00000000..a625bba5 --- /dev/null +++ b/packages/arbor-ost/src/handlers/$object/visitors/$children.ts @@ -0,0 +1,14 @@ +import { Visitor } from "../../visitor" + +export class ChildrenVisitor extends Visitor { + prop = "$children" + + visit({ ost, target }) { + return function* () { + for (const value of Object.values(target)) { + const childNode = ost.nodeOf(value) + if (childNode) yield childNode + } + } + } +} diff --git a/packages/arbor-ost/src/handlers/$object/visitors/$createChild.ts b/packages/arbor-ost/src/handlers/$object/visitors/$createChild.ts new file mode 100644 index 00000000..57f8259d --- /dev/null +++ b/packages/arbor-ost/src/handlers/$object/visitors/$createChild.ts @@ -0,0 +1,12 @@ +import { Visitor } from "../../visitor" +import { Value } from "../../../types" + +export class CreateChildVisitor extends Visitor { + prop = "$createChild" + + visit({ ost, $node }) { + return (value: Value) => { + return ost.createNode(value, $node.$path.child()) + } + } +} diff --git a/packages/arbor-ost/src/handlers/$object/visitors/$parent.ts b/packages/arbor-ost/src/handlers/$object/visitors/$parent.ts new file mode 100644 index 00000000..16be639b --- /dev/null +++ b/packages/arbor-ost/src/handlers/$object/visitors/$parent.ts @@ -0,0 +1,9 @@ +import { Visitor } from "../../visitor" + +export class ParentVisitor extends Visitor { + prop = "$parent" + + visit({ ost, target }) { + return ost.parentOf(target) + } +} diff --git a/packages/arbor-ost/src/handlers/$object/visitors/$path.ts b/packages/arbor-ost/src/handlers/$object/visitors/$path.ts new file mode 100644 index 00000000..65979d6e --- /dev/null +++ b/packages/arbor-ost/src/handlers/$object/visitors/$path.ts @@ -0,0 +1,9 @@ +import { Visitor } from "../../visitor" + +export class PathVisitor extends Visitor { + prop = "$path" + + visit({ ost, target }) { + return ost.pathOf(target) + } +} diff --git a/packages/arbor-ost/src/handlers/$object/visitors/$seed.ts b/packages/arbor-ost/src/handlers/$object/visitors/$seed.ts new file mode 100644 index 00000000..745c6e69 --- /dev/null +++ b/packages/arbor-ost/src/handlers/$object/visitors/$seed.ts @@ -0,0 +1,9 @@ +import { Visitor } from "../../visitor" + +export class SeedVisitor extends Visitor { + prop = "$seed" + + visit({ ost, target }) { + return ost.seedOf(target) + } +} diff --git a/packages/arbor-ost/src/handlers/$object/visitors/$subscriptions.ts b/packages/arbor-ost/src/handlers/$object/visitors/$subscriptions.ts new file mode 100644 index 00000000..42ca28c7 --- /dev/null +++ b/packages/arbor-ost/src/handlers/$object/visitors/$subscriptions.ts @@ -0,0 +1,9 @@ +import { Visitor } from "../../visitor" + +export class SubscriptionsVisitor extends Visitor { + prop = "$subscriptions" + + visit({ ost, target }) { + return ost.subscriptionsOf(target) + } +} diff --git a/packages/arbor-ost/src/handlers/$object/visitors/$value.ts b/packages/arbor-ost/src/handlers/$object/visitors/$value.ts new file mode 100644 index 00000000..4961131b --- /dev/null +++ b/packages/arbor-ost/src/handlers/$object/visitors/$value.ts @@ -0,0 +1,9 @@ +import { Visitor } from "../../visitor" + +export class ValueVisitor extends Visitor { + prop = "$value" + + visit({ target }) { + return target + } +} diff --git a/packages/arbor-ost/src/handlers/$object/visitors/detached.ts b/packages/arbor-ost/src/handlers/$object/visitors/detached.ts new file mode 100644 index 00000000..bc35ec73 --- /dev/null +++ b/packages/arbor-ost/src/handlers/$object/visitors/detached.ts @@ -0,0 +1,17 @@ +import { Prop } from "../../../types" +import { Visitor } from "../../visitor" +import { ArborDetached } from "../../../decorators/detached" + +export function isDetachedProperty(target: unknown, prop: Prop) { + return target?.[ArborDetached]?.[prop] +} + +export class DetachedVisitor extends Visitor { + accepts({ target, prop }) { + return isDetachedProperty(target, prop) + } + + visit({ childValue }) { + return childValue + } +} diff --git a/packages/arbor-ost/src/handlers/$object/visitors/getter.ts b/packages/arbor-ost/src/handlers/$object/visitors/getter.ts new file mode 100644 index 00000000..b2eb2bd6 --- /dev/null +++ b/packages/arbor-ost/src/handlers/$object/visitors/getter.ts @@ -0,0 +1,26 @@ +import { Visitor } from "../../visitor" +import { Prop } from "../../../types" + +function isGetter(target: object, prop: Prop) { + if (!target) { + return false + } + + const descriptor = Object.getOwnPropertyDescriptor(target, prop) + + if (descriptor && descriptor.get !== undefined) { + return true + } + + return isGetter(Object.getPrototypeOf(target), prop) +} + +export class GetterVisitor extends Visitor { + accepts({ target, prop }) { + return isGetter(target, prop) + } + + visit({ target, prop, $node }) { + return Reflect.get(target, prop, $node) + } +} diff --git a/packages/arbor-ost/src/handlers/$object/visitors/proxiable.ts b/packages/arbor-ost/src/handlers/$object/visitors/proxiable.ts new file mode 100644 index 00000000..4234b67a --- /dev/null +++ b/packages/arbor-ost/src/handlers/$object/visitors/proxiable.ts @@ -0,0 +1,26 @@ +import { Visitor } from "../../visitor" +import { ArborProxiable } from "../../../decorators/node" + +export function isProxiable(value: unknown): value is object { + if (value == null) return false + + return ( + value.constructor === Object || + value.constructor === Array || + value.constructor === Map || + value.constructor === Set || + value.constructor === WeakMap || + value.constructor === WeakSet || + value[ArborProxiable] + ) +} + +export class ProxiableVisitor extends Visitor { + accepts({ childValue }) { + return isProxiable(childValue) + } + + visit({ ost, $node, childValue }) { + return ost.nodeOf(childValue) || $node.$createChild(childValue) + } +} diff --git a/packages/arbor-ost/src/handlers/$object/visitors/toStringTag.ts b/packages/arbor-ost/src/handlers/$object/visitors/toStringTag.ts new file mode 100644 index 00000000..ddbce812 --- /dev/null +++ b/packages/arbor-ost/src/handlers/$object/visitors/toStringTag.ts @@ -0,0 +1,10 @@ +import { Visitor } from "../../visitor" + +export class ToStringTagVisitor extends Visitor { + prop = Symbol.toStringTag + + visit({ ost, target, $node }) { + const detachedIndicator = ost.isDetached(target) ? "*" : "" + return `ArborNode<${target.constructor.name}(${detachedIndicator}${$node.$seed.value})>` + } +} diff --git a/packages/arbor-ost/src/handlers/$set/index.ts b/packages/arbor-ost/src/handlers/$set/index.ts new file mode 100644 index 00000000..bd9cf1b3 --- /dev/null +++ b/packages/arbor-ost/src/handlers/$set/index.ts @@ -0,0 +1,52 @@ +import { OST } from "../../ost" +import { $object } from "../$object" +import { Visitors } from "../visitors" +import { AddVisitor } from "./visitors/add" +import { ValuesVisitor } from "./visitors/values" +import { SizeVisitor } from "./visitors/size" +import { HasVisitor } from "./visitors/has" +import { DeleteVisitor } from "./visitors/delete" +import { ClearVisitor } from "./visitors/clear" +import { EntriesVisitor } from "./visitors/entries" +import { ForEachVisitor } from "./visitors/forEach" +import { KeysVisitor } from "./visitors/keys" +import { IteratorVisitor } from "./visitors/iterator" +import { DifferenceVisitor } from "./visitors/difference" +import { IntersectionVisitor } from "./visitors/intersection" +import { UnionVisitor } from "./visitors/union" +import { SymmetricDifferenceVisitor } from "./visitors/symmetricDifference" +import { IsDisjointFromVisitor } from "./visitors/isDisjointFrom" +import { IsSubsetOfVisitor } from "./visitors/isSubsetOf" +import { IsSupersetOfVisitor } from "./visitors/isSupersetOf" +import { ChildrenVisitor } from "./visitors/$children" + +const visitors = new Visitors( + new ChildrenVisitor(), + new AddVisitor(), + new HasVisitor(), + new SizeVisitor(), + new ValuesVisitor(), + new DeleteVisitor(), + new ClearVisitor(), + new EntriesVisitor(), + new ForEachVisitor(), + new KeysVisitor(), + new IteratorVisitor(), + new DifferenceVisitor(), + new IntersectionVisitor(), + new UnionVisitor(), + new SymmetricDifferenceVisitor(), + new IsDisjointFromVisitor(), + new IsSubsetOfVisitor(), + new IsSupersetOfVisitor() +) + +export class $set extends $object { + constructor(ost: OST) { + super(ost, visitors) + } + + static accepts(value: unknown): boolean { + return value instanceof Set + } +} diff --git a/packages/arbor-ost/src/handlers/$set/visitors/$children.ts b/packages/arbor-ost/src/handlers/$set/visitors/$children.ts new file mode 100644 index 00000000..12e44e88 --- /dev/null +++ b/packages/arbor-ost/src/handlers/$set/visitors/$children.ts @@ -0,0 +1,5 @@ +import { ValuesVisitor } from "./values" + +export class ChildrenVisitor extends ValuesVisitor { + prop = "$children" +} diff --git a/packages/arbor-ost/src/handlers/$set/visitors/add.ts b/packages/arbor-ost/src/handlers/$set/visitors/add.ts new file mode 100644 index 00000000..605c632e --- /dev/null +++ b/packages/arbor-ost/src/handlers/$set/visitors/add.ts @@ -0,0 +1,18 @@ +import { Visitor } from "../../visitor" + +export class AddVisitor extends Visitor { + prop = "add" + + visit({ ost, target, $node }) { + return (value: unknown) => { + return ost.mutate($node, () => { + target.add(value) + + return { + args: [value], + operation: "add", + } + }) + } + } +} diff --git a/packages/arbor-ost/src/handlers/$set/visitors/clear.ts b/packages/arbor-ost/src/handlers/$set/visitors/clear.ts new file mode 100644 index 00000000..0ddde75a --- /dev/null +++ b/packages/arbor-ost/src/handlers/$set/visitors/clear.ts @@ -0,0 +1,22 @@ +import { Visitor } from "../../visitor" + +export class ClearVisitor extends Visitor { + prop = "clear" + + visit({ ost, target, $node }) { + return () => { + if (target.size === 0) { + return + } + + ost.mutate($node, () => { + target.clear() + + return { + args: [], + operation: "clear", + } + }) + } + } +} diff --git a/packages/arbor-ost/src/handlers/$set/visitors/delete.ts b/packages/arbor-ost/src/handlers/$set/visitors/delete.ts new file mode 100644 index 00000000..65a64f37 --- /dev/null +++ b/packages/arbor-ost/src/handlers/$set/visitors/delete.ts @@ -0,0 +1,27 @@ +import { Visitor } from "../../visitor" + +export class DeleteVisitor extends Visitor { + prop = "delete" + + visit({ ost, target, $node }) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (valueOrNode: any) => { + const value = valueOrNode?.$value || valueOrNode + + if (!target.has(value)) { + return false + } + + ost.mutate($node, () => { + target.delete(value) + + return { + args: [valueOrNode], + operation: "delete", + } + }) + + return true + } + } +} diff --git a/packages/arbor-ost/src/handlers/$set/visitors/difference.ts b/packages/arbor-ost/src/handlers/$set/visitors/difference.ts new file mode 100644 index 00000000..f1767ce9 --- /dev/null +++ b/packages/arbor-ost/src/handlers/$set/visitors/difference.ts @@ -0,0 +1,26 @@ +import { Visitor } from "../../visitor" +import { isProxiable } from "../../$object/visitors/proxiable" + +export class DifferenceVisitor extends Visitor { + prop = "difference" + + visit({ target, ost, $node }) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (other: any) => { + // Extract underlying Set if other is a proxy + const otherSet = other?.$value || other + const result = target.difference(otherSet) + const wrappedResult = new Set() + + for (const value of result) { + if (isProxiable(value)) { + wrappedResult.add(ost.nodeOf(value) || $node.$createChild(value)) + } else { + wrappedResult.add(value) + } + } + + return wrappedResult + } + } +} diff --git a/packages/arbor-ost/src/handlers/$set/visitors/entries.ts b/packages/arbor-ost/src/handlers/$set/visitors/entries.ts new file mode 100644 index 00000000..d1f9770f --- /dev/null +++ b/packages/arbor-ost/src/handlers/$set/visitors/entries.ts @@ -0,0 +1,13 @@ +import { Visitor } from "../../visitor" + +export class EntriesVisitor extends Visitor { + prop = "entries" + + visit({ $node }) { + return function* () { + for (const child of $node[Symbol.iterator]()) { + yield [child, child] + } + } + } +} diff --git a/packages/arbor-ost/src/handlers/$set/visitors/forEach.ts b/packages/arbor-ost/src/handlers/$set/visitors/forEach.ts new file mode 100644 index 00000000..43786e84 --- /dev/null +++ b/packages/arbor-ost/src/handlers/$set/visitors/forEach.ts @@ -0,0 +1,20 @@ +import { Visitor } from "../../visitor" +import { isProxiable } from "../../$object/visitors/proxiable" + +export class ForEachVisitor extends Visitor { + prop = "forEach" + + visit({ target, ost, $node }) { + return function ( + cb: (value: unknown, value2: unknown, set: unknown) => void, + thisArg?: unknown + ) { + for (const value of target.values()) { + const nodeValue = isProxiable(value) + ? ost.nodeOf(value) || $node.$createChild(value) + : value + cb.call(thisArg, nodeValue, nodeValue, $node) + } + } + } +} diff --git a/packages/arbor-ost/src/handlers/$set/visitors/has.ts b/packages/arbor-ost/src/handlers/$set/visitors/has.ts new file mode 100644 index 00000000..0ab743cd --- /dev/null +++ b/packages/arbor-ost/src/handlers/$set/visitors/has.ts @@ -0,0 +1,13 @@ +import { Visitor } from "../../visitor" + +export class HasVisitor extends Visitor { + prop = "has" + + visit({ target }) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (valueOrNode: any) => { + const value = valueOrNode?.$value || valueOrNode + return target.has(value) + } + } +} diff --git a/packages/arbor-ost/src/handlers/$set/visitors/intersection.ts b/packages/arbor-ost/src/handlers/$set/visitors/intersection.ts new file mode 100644 index 00000000..bc58f2f2 --- /dev/null +++ b/packages/arbor-ost/src/handlers/$set/visitors/intersection.ts @@ -0,0 +1,26 @@ +import { Visitor } from "../../visitor" +import { isProxiable } from "../../$object/visitors/proxiable" + +export class IntersectionVisitor extends Visitor { + prop = "intersection" + + visit({ target, ost, $node }) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (other: any) => { + // Extract underlying Set if other is a proxy + const otherSet = other?.$value || other + const result = target.intersection(otherSet) + const wrappedResult = new Set() + + for (const value of result) { + if (isProxiable(value)) { + wrappedResult.add(ost.nodeOf(value) || $node.$createChild(value)) + } else { + wrappedResult.add(value) + } + } + + return wrappedResult + } + } +} diff --git a/packages/arbor-ost/src/handlers/$set/visitors/isDisjointFrom.ts b/packages/arbor-ost/src/handlers/$set/visitors/isDisjointFrom.ts new file mode 100644 index 00000000..0c550444 --- /dev/null +++ b/packages/arbor-ost/src/handlers/$set/visitors/isDisjointFrom.ts @@ -0,0 +1,13 @@ +import { Visitor } from "../../visitor" + +export class IsDisjointFromVisitor extends Visitor { + prop = "isDisjointFrom" + + visit({ target }) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (other: any) => { + const otherSet = other?.$value || other + return target.isDisjointFrom(otherSet) + } + } +} diff --git a/packages/arbor-ost/src/handlers/$set/visitors/isSubsetOf.ts b/packages/arbor-ost/src/handlers/$set/visitors/isSubsetOf.ts new file mode 100644 index 00000000..74284e54 --- /dev/null +++ b/packages/arbor-ost/src/handlers/$set/visitors/isSubsetOf.ts @@ -0,0 +1,14 @@ +import { Visitor } from "../../visitor" + +export class IsSubsetOfVisitor extends Visitor { + prop = "isSubsetOf" + + visit({ target }) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (other: any) => { + // Extract underlying Set if other is a proxy + const otherSet = other?.$value || other + return target.isSubsetOf(otherSet) + } + } +} diff --git a/packages/arbor-ost/src/handlers/$set/visitors/isSupersetOf.ts b/packages/arbor-ost/src/handlers/$set/visitors/isSupersetOf.ts new file mode 100644 index 00000000..bed2851d --- /dev/null +++ b/packages/arbor-ost/src/handlers/$set/visitors/isSupersetOf.ts @@ -0,0 +1,14 @@ +import { Visitor } from "../../visitor" + +export class IsSupersetOfVisitor extends Visitor { + prop = "isSupersetOf" + + visit({ target }) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (other: any) => { + // Extract underlying Set if other is a proxy + const otherSet = other?.$value || other + return target.isSupersetOf(otherSet) + } + } +} diff --git a/packages/arbor-ost/src/handlers/$set/visitors/iterator.ts b/packages/arbor-ost/src/handlers/$set/visitors/iterator.ts new file mode 100644 index 00000000..49b588c8 --- /dev/null +++ b/packages/arbor-ost/src/handlers/$set/visitors/iterator.ts @@ -0,0 +1,17 @@ +import { Visitor } from "../../visitor" +import { isProxiable } from "../../$object/visitors/proxiable" + +export class IteratorVisitor extends Visitor { + prop = Symbol.iterator + + visit({ target, ost, $node }) { + return function* () { + for (const value of target.values()) { + const nodeValue = isProxiable(value) + ? ost.nodeOf(value) || $node.$createChild(value) + : value + yield nodeValue + } + } + } +} diff --git a/packages/arbor-ost/src/handlers/$set/visitors/keys.ts b/packages/arbor-ost/src/handlers/$set/visitors/keys.ts new file mode 100644 index 00000000..1f8605f2 --- /dev/null +++ b/packages/arbor-ost/src/handlers/$set/visitors/keys.ts @@ -0,0 +1,7 @@ +import { ValuesVisitor } from "./values" + +// As per MDN's docs, Set#keys is simply an alias for Set#values. +// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Set/keys +export class KeysVisitor extends ValuesVisitor { + prop = "keys" +} diff --git a/packages/arbor-ost/src/handlers/$set/visitors/size.ts b/packages/arbor-ost/src/handlers/$set/visitors/size.ts new file mode 100644 index 00000000..5d6d393c --- /dev/null +++ b/packages/arbor-ost/src/handlers/$set/visitors/size.ts @@ -0,0 +1,9 @@ +import { Visitor } from "../../visitor" + +export class SizeVisitor extends Visitor { + prop = "size" + + visit({ target }) { + return target.size + } +} diff --git a/packages/arbor-ost/src/handlers/$set/visitors/symmetricDifference.ts b/packages/arbor-ost/src/handlers/$set/visitors/symmetricDifference.ts new file mode 100644 index 00000000..e95b6d7c --- /dev/null +++ b/packages/arbor-ost/src/handlers/$set/visitors/symmetricDifference.ts @@ -0,0 +1,26 @@ +import { Visitor } from "../../visitor" +import { isProxiable } from "../../$object/visitors/proxiable" + +export class SymmetricDifferenceVisitor extends Visitor { + prop = "symmetricDifference" + + visit({ target, ost, $node }) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (other: any) => { + // Extract underlying Set if other is a proxy + const otherSet = other?.$value || other + const result = target.symmetricDifference(otherSet) + const wrappedResult = new Set() + + for (const value of result) { + if (isProxiable(value)) { + wrappedResult.add(ost.nodeOf(value) || $node.$createChild(value)) + } else { + wrappedResult.add(value) + } + } + + return wrappedResult + } + } +} diff --git a/packages/arbor-ost/src/handlers/$set/visitors/union.ts b/packages/arbor-ost/src/handlers/$set/visitors/union.ts new file mode 100644 index 00000000..576a3ebd --- /dev/null +++ b/packages/arbor-ost/src/handlers/$set/visitors/union.ts @@ -0,0 +1,26 @@ +import { Visitor } from "../../visitor" +import { isProxiable } from "../../$object/visitors/proxiable" + +export class UnionVisitor extends Visitor { + prop = "union" + + visit({ target, ost, $node }) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (other: any) => { + // Extract underlying Set if other is a proxy + const otherSet = other?.$value || other + const result = target.union(otherSet) + const wrappedResult = new Set() + + for (const value of result) { + if (isProxiable(value)) { + wrappedResult.add(ost.nodeOf(value) || $node.$createChild(value)) + } else { + wrappedResult.add(value) + } + } + + return wrappedResult + } + } +} diff --git a/packages/arbor-ost/src/handlers/$set/visitors/values.ts b/packages/arbor-ost/src/handlers/$set/visitors/values.ts new file mode 100644 index 00000000..8a2c564d --- /dev/null +++ b/packages/arbor-ost/src/handlers/$set/visitors/values.ts @@ -0,0 +1,13 @@ +import { Visitor } from "../../visitor" + +export class ValuesVisitor extends Visitor { + prop = "values" + + visit({ $node }) { + return function* () { + for (const child of $node[Symbol.iterator]()) { + yield child + } + } + } +} diff --git a/packages/arbor-ost/src/handlers/$weakMap/index.ts b/packages/arbor-ost/src/handlers/$weakMap/index.ts new file mode 100644 index 00000000..e1940a44 --- /dev/null +++ b/packages/arbor-ost/src/handlers/$weakMap/index.ts @@ -0,0 +1,24 @@ +import { OST } from "../../ost" +import { $object } from "../$object" +import { Visitors } from "../visitors" +import { GetVisitor } from "./visitors/get" +import { SetVisitor } from "./visitors/set" +import { DeleteVisitor } from "./visitors/delete" +import { HasVisitor } from "./visitors/has" + +const visitors = new Visitors( + new GetVisitor(), + new SetVisitor(), + new DeleteVisitor(), + new HasVisitor() +) + +export class $weakMap extends $object { + constructor(ost: OST) { + super(ost, visitors) + } + + static accepts(value: unknown): boolean { + return value instanceof WeakMap + } +} diff --git a/packages/arbor-ost/src/handlers/$weakMap/visitors/delete.ts b/packages/arbor-ost/src/handlers/$weakMap/visitors/delete.ts new file mode 100644 index 00000000..9341bfae --- /dev/null +++ b/packages/arbor-ost/src/handlers/$weakMap/visitors/delete.ts @@ -0,0 +1,24 @@ +import { Visitor } from "../../visitor" + +export class DeleteVisitor extends Visitor { + prop = "delete" + + visit({ ost, target, $node }) { + return (key: object) => { + if (!target.has(key)) { + return false + } + + ost.mutate($node, () => { + target.delete(key) + + return { + args: [key], + operation: "delete", + } + }) + + return true + } + } +} diff --git a/packages/arbor-ost/src/handlers/$weakMap/visitors/get.ts b/packages/arbor-ost/src/handlers/$weakMap/visitors/get.ts new file mode 100644 index 00000000..ab6c6a4c --- /dev/null +++ b/packages/arbor-ost/src/handlers/$weakMap/visitors/get.ts @@ -0,0 +1,18 @@ +import { Visitor } from "../../visitor" +import { isProxiable } from "../../$object/visitors/proxiable" + +export class GetVisitor extends Visitor { + prop = "get" + + visit({ target, ost, $node }) { + return (key: object) => { + const childValue = target.get(key) + + if (!isProxiable(childValue)) { + return childValue + } + + return ost.nodeOf(childValue) || $node.$createChild(childValue) + } + } +} diff --git a/packages/arbor-ost/src/handlers/$weakMap/visitors/has.ts b/packages/arbor-ost/src/handlers/$weakMap/visitors/has.ts new file mode 100644 index 00000000..68630842 --- /dev/null +++ b/packages/arbor-ost/src/handlers/$weakMap/visitors/has.ts @@ -0,0 +1,11 @@ +import { Visitor } from "../../visitor" + +export class HasVisitor extends Visitor { + prop = "has" + + visit({ target }) { + return (key: object) => { + return target.has(key) + } + } +} diff --git a/packages/arbor-ost/src/handlers/$weakMap/visitors/set.ts b/packages/arbor-ost/src/handlers/$weakMap/visitors/set.ts new file mode 100644 index 00000000..c504b415 --- /dev/null +++ b/packages/arbor-ost/src/handlers/$weakMap/visitors/set.ts @@ -0,0 +1,19 @@ +import { Visitor } from "../../visitor" +import { Value } from "../../../types" + +export class SetVisitor extends Visitor { + prop = "set" + + visit({ ost, target, $node }) { + return (key: object, value: Value) => { + return ost.mutate($node, () => { + target.set(key, value) + + return { + args: [key, value], + operation: "set", + } + }) + } + } +} diff --git a/packages/arbor-ost/src/handlers/$weakSet/index.ts b/packages/arbor-ost/src/handlers/$weakSet/index.ts new file mode 100644 index 00000000..c6b716bf --- /dev/null +++ b/packages/arbor-ost/src/handlers/$weakSet/index.ts @@ -0,0 +1,22 @@ +import { OST } from "../../ost" +import { $object } from "../$object" +import { Visitors } from "../visitors" +import { AddVisitor } from "./visitors/add" +import { DeleteVisitor } from "./visitors/delete" +import { HasVisitor } from "./visitors/has" + +const visitors = new Visitors( + new AddVisitor(), + new DeleteVisitor(), + new HasVisitor() +) + +export class $weakSet extends $object { + constructor(ost: OST) { + super(ost, visitors) + } + + static accepts(value: unknown): boolean { + return value instanceof WeakSet + } +} diff --git a/packages/arbor-ost/src/handlers/$weakSet/visitors/add.ts b/packages/arbor-ost/src/handlers/$weakSet/visitors/add.ts new file mode 100644 index 00000000..73573842 --- /dev/null +++ b/packages/arbor-ost/src/handlers/$weakSet/visitors/add.ts @@ -0,0 +1,23 @@ +import { Visitor } from "../../visitor" + +export class AddVisitor extends Visitor { + prop = "add" + + visit({ ost, target, $node }) { + return (value: object) => { + // If value already exists, return the proxied node without mutation + if (target.has(value)) { + return $node + } + + return ost.mutate($node, () => { + target.add(value) + + return { + args: [value], + operation: "add", + } + }) + } + } +} diff --git a/packages/arbor-ost/src/handlers/$weakSet/visitors/delete.ts b/packages/arbor-ost/src/handlers/$weakSet/visitors/delete.ts new file mode 100644 index 00000000..65a64f37 --- /dev/null +++ b/packages/arbor-ost/src/handlers/$weakSet/visitors/delete.ts @@ -0,0 +1,27 @@ +import { Visitor } from "../../visitor" + +export class DeleteVisitor extends Visitor { + prop = "delete" + + visit({ ost, target, $node }) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (valueOrNode: any) => { + const value = valueOrNode?.$value || valueOrNode + + if (!target.has(value)) { + return false + } + + ost.mutate($node, () => { + target.delete(value) + + return { + args: [valueOrNode], + operation: "delete", + } + }) + + return true + } + } +} diff --git a/packages/arbor-ost/src/handlers/$weakSet/visitors/has.ts b/packages/arbor-ost/src/handlers/$weakSet/visitors/has.ts new file mode 100644 index 00000000..3ce7130b --- /dev/null +++ b/packages/arbor-ost/src/handlers/$weakSet/visitors/has.ts @@ -0,0 +1,11 @@ +import { Visitor } from "../../visitor" + +export class HasVisitor extends Visitor { + prop = "has" + + visit({ target }) { + return (value: object) => { + return target.has(value) + } + } +} diff --git a/packages/arbor-ost/src/handlers/visitor.ts b/packages/arbor-ost/src/handlers/visitor.ts new file mode 100644 index 00000000..b4c9e404 --- /dev/null +++ b/packages/arbor-ost/src/handlers/visitor.ts @@ -0,0 +1,22 @@ +import { OST } from "../ost" +import { Node, Prop, Value } from "../types" + +export type VisitParams = { + ost: OST + $node: Node + target: Value + prop: Prop + childValue: Value +} + +export class Visitor { + prop?: Prop + + accepts(_: VisitParams) { + return true + } + + visit({ childValue }: VisitParams): unknown { + return childValue + } +} diff --git a/packages/arbor-ost/src/handlers/visitors.ts b/packages/arbor-ost/src/handlers/visitors.ts new file mode 100644 index 00000000..193b3295 --- /dev/null +++ b/packages/arbor-ost/src/handlers/visitors.ts @@ -0,0 +1,57 @@ +import { Prop } from "../types" +import { ChildrenVisitor } from "./$object/visitors/$children" +import { CreateChildVisitor } from "./$object/visitors/$createChild" +import { ParentVisitor } from "./$object/visitors/$parent" +import { PathVisitor } from "./$object/visitors/$path" +import { ProxiableVisitor } from "./$object/visitors/proxiable" +import { SeedVisitor } from "./$object/visitors/$seed" +import { SubscriptionsVisitor } from "./$object/visitors/$subscriptions" +import { ValueVisitor } from "./$object/visitors/$value" +import { DetachedVisitor } from "./$object/visitors/detached" +import { GetterVisitor } from "./$object/visitors/getter" +import { ToStringTagVisitor } from "./$object/visitors/toStringTag" +import { VisitParams, Visitor } from "./visitor" + +const defaultVisitors = [ + new SeedVisitor(), + new PathVisitor(), + new CreateChildVisitor(), + new SubscriptionsVisitor(), + new ValueVisitor(), + new ChildrenVisitor(), + new ParentVisitor(), + new DetachedVisitor(), + new GetterVisitor(), + new ProxiableVisitor(), + new ToStringTagVisitor(), + new Visitor(), +] + +export class Visitors { + propVisitors = new Map() + predicateVisitors: Visitor[] = [] + + constructor(...visitors: Visitor[]) { + defaultVisitors.concat(visitors).forEach((v) => { + if (v.prop != null) { + this.propVisitors.set(v.prop, v) + } else { + this.predicateVisitors.push(v) + } + }) + } + + visit(params: VisitParams) { + const propVisitor = this.propVisitors.get(params.prop) + + if (propVisitor) { + return propVisitor.visit(params) + } + + const predicateVisitor = this.predicateVisitors.find((v) => + v.accepts(params) + ) + + return predicateVisitor?.visit(params) + } +} diff --git a/packages/arbor-ost/src/index.ts b/packages/arbor-ost/src/index.ts new file mode 100644 index 00000000..a6d88461 --- /dev/null +++ b/packages/arbor-ost/src/index.ts @@ -0,0 +1,9 @@ +import { Seed } from "./seed" +import { Node } from "./types" + +export { OST } from "./ost" + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function isNode(value: any): value is Node { + return value?.$seed instanceof Seed +} diff --git a/packages/arbor-ost/src/ost.ts b/packages/arbor-ost/src/ost.ts new file mode 100644 index 00000000..1ab56841 --- /dev/null +++ b/packages/arbor-ost/src/ost.ts @@ -0,0 +1,184 @@ +import { Path } from "./path" +import { Seed } from "./seed" +import { $map } from "./handlers/$map" +import { $set } from "./handlers/$set" +import { $array } from "./handlers/$array" +import { $object } from "./handlers/$object" +import { $weakMap } from "./handlers/$weakMap" +import { $weakSet } from "./handlers/$weakSet" +import { DetachedPathError } from "./errors" +import { Subscriptions } from "./subscriptions" +import { + $, + Mutation, + Node, + ProxyHandlerConstructor, + Subscriber, + Value, +} from "./types" + +export class OST { + /** + * Represents the root of the tree + */ + #rootSeed: Seed + /** + * Map every observable value in the state to their corresponding Seed + */ + #seeds = new WeakMap() + /** + * Map each created Seed to the OST Node they correspond to + */ + #nodes = new WeakMap() + /** + * Map each Seed to the Path that locates the corresponding Node in the OST + */ + #paths = new WeakMap() + /** + * Map each Seed to the subscription registry tracking every subscriber to the corresponding Node. + */ + #subscriptions = new WeakMap() + + /** + * List of supported NodeHandlers + * + * Node handlers are used as proxy handlers when creating the Proxy representing an OST Node + * + * They are responsible for providing the behavior of the Node, e.g. behaves like a regular object vs array vs map vs set, etc... + */ + #handlers: ProxyHandlerConstructor[] = [ + $array, + $set, + $map, + $weakSet, + $weakMap, + $object, + ] + + constructor(root?: V) { + if (root) { + this.createNode(root) + } + } + + createNode( + value: V, + path = Path.root(), + subscriptions = new Subscriptions() + ): $ { + const seed = path.target + const handler = this.#handlers.find((h) => h.accepts(value)) + const $node = new Proxy(value, new handler(this)) as $ + + if (path.isRoot()) { + this.#rootSeed = seed + } + + this.#seeds.set(value, seed) + this.#nodes.set(seed, $node) + this.#paths.set(seed, path) + this.#subscriptions.set(seed, subscriptions) + + return $node + } + + mutate($node: Node, mutation: Mutation) { + if (this.isDetached($node.$value)) { + throw new DetachedPathError(this.humanizePath($node.$path)) + } + + const refreshedNodesInMutationPath = this.refreshNodesInPath($node.$path) + const $newRootNode = refreshedNodesInMutationPath[0] + const $newTargetNode = refreshedNodesInMutationPath.at(-1) as $ + const metadata = mutation($newTargetNode) + + this.#rootSeed = $newRootNode.$seed + + for (const $refreshedNode of refreshedNodesInMutationPath) { + $refreshedNode.$subscriptions.notify({ + target: $newTargetNode, + metadata, + }) + } + + return $newTargetNode + } + + subscribe(s: Subscriber) { + return this.subscribeTo(this.root, s) + } + + subscribeTo($node: Node, s: Subscriber) { + return this.subscriptionsOf($node.$value).subscribe(s) + } + + nodeOf(value: Value): Node { + const seed = this.#seeds.get(value) + return this.#nodes.get(seed) + } + + seedOf(value: Value): Seed { + // Cannot call node.$seed here since that would create a circular dependency + return this.#seeds.get(value) + } + + pathOf(value: Value): Path { + // Cannot call node.$path here since that would create a circular dependency + return this.#paths.get(this.#seeds.get(value)) + } + + parentOf(value: Value): Node { + // Cannot call node.$parent here since that would create a circular dependency + const path = this.#paths.get(this.#seeds.get(value)) + return this.#nodes.get(path.parentSeed) + } + + subscriptionsOf(value: Value): Subscriptions { + // Cannot call node.$subscriptions here since that would create a circular dependency + return this.#subscriptions.get(this.#seeds.get(value)) + } + + isDetached(value?: Value) { + const path = this.pathOf(value) + return !path || path.seeds.some(this.isDetachedSeed.bind(this)) + } + + humanizePath(path: Path) { + return path.humanize((seed) => + this.isDetachedSeed(seed) ? `${seed.value}*` : seed.value.toString() + ) + } + + get root() { + return this.#nodes.get(this.#rootSeed) as $ + } + + private refreshNodesInPath(path: Path) { + return path.seeds.map((seed) => this.refreshNodeBySeed(seed)) + } + + private refreshNodeBySeed(seed: Seed) { + const $affectedNode = this.#nodes.get(seed) + return this.createNode( + $affectedNode.$value, + $affectedNode.$path, + $affectedNode.$subscriptions + ) + } + + private isDetachedSeed(seed: Seed) { + const $node = this.#nodes.get(seed) + + if ($node.$parent == null) { + return $node !== this.root + } + + for (const child of $node.$parent.$children()) { + if ($node.$value === child.$value) { + return false + } + } + + return true + } +} diff --git a/packages/arbor-ost/src/path.ts b/packages/arbor-ost/src/path.ts new file mode 100644 index 00000000..fe200283 --- /dev/null +++ b/packages/arbor-ost/src/path.ts @@ -0,0 +1,33 @@ +import { Seed } from "./seed" + +export class Path { + readonly seeds: Seed[] + + private constructor(...seeds: Seed[]) { + this.seeds = seeds + } + + static root(seed = new Seed()) { + return new Path(seed) + } + + child(seed = new Seed()): Path { + return new Path(...this.seeds.concat([seed])) + } + + isRoot() { + return this.seeds.length === 1 + } + + humanize(decorate: (s: Seed) => string = (s) => s.value.toString()) { + return this.seeds.map((s) => decorate(s)).join(" -> ") + } + + get parentSeed(): Seed { + return this.seeds.at(-2) + } + + get target() { + return this.seeds.at(-1) + } +} diff --git a/packages/arbor-ost/src/scoping/handlers/$map.ts b/packages/arbor-ost/src/scoping/handlers/$map.ts new file mode 100644 index 00000000..cedfd50e --- /dev/null +++ b/packages/arbor-ost/src/scoping/handlers/$map.ts @@ -0,0 +1,31 @@ +import { Node, Prop } from "../../types" +import { $object } from "./$object" +import { isNode } from "../../" + +export class $map extends $object { + static accepts(value: unknown) { + return value instanceof Map + } + + get($node: Node>, prop: Prop, receiver: unknown) { + const scope = this.scope + + if (prop === Symbol.iterator) { + return function* () { + for (const [key, child] of $node.entries()) { + const value = isNode(child) ? scope.createProxy(child) : child + yield [key, value] + } + } + } + + if (prop === "get") { + return (key: unknown) => { + const child = $node.get(key) + return isNode(child) ? scope.createProxy(child) : child + } + } + + return super.get($node, prop, receiver) + } +} diff --git a/packages/arbor-ost/src/scoping/handlers/$object.ts b/packages/arbor-ost/src/scoping/handlers/$object.ts new file mode 100644 index 00000000..8f4e4a3a --- /dev/null +++ b/packages/arbor-ost/src/scoping/handlers/$object.ts @@ -0,0 +1,22 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { Scope } from "../scope" +import { Node, Prop } from "../../types" +import { isNode } from "../../" + +export class $object { + constructor(readonly scope: Scope) {} + + static accepts(_value: unknown) { + return true + } + + get($node: Node, prop: Prop, receiver: unknown) { + if (prop != null) { + this.scope.tracked.get($node.$seed).add(prop) + } + + const child = Reflect.get($node, prop, receiver) + + return isNode(child) ? this.scope.createProxy(child) : child + } +} diff --git a/packages/arbor-ost/src/scoping/handlers/$set.ts b/packages/arbor-ost/src/scoping/handlers/$set.ts new file mode 100644 index 00000000..2abb8811 --- /dev/null +++ b/packages/arbor-ost/src/scoping/handlers/$set.ts @@ -0,0 +1,72 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { Node, Prop } from "../../types" +import { $object } from "./$object" +import { isNode } from "../../" + +export class $set extends $object { + static accepts(value: unknown) { + return value instanceof Set + } + + get($node: Node>, prop: Prop, receiver: unknown) { + const scope = this.scope + + if (prop === Symbol.iterator) { + return function* () { + for (const child of $node.values()) { + yield isNode(child) ? scope.createProxy(child) : child + } + } + } + + if (prop === "forEach") { + return function ( + cb: (value: unknown, value2: unknown, set: Set) => void, + thisArg?: any + ) { + $node.forEach((child) => { + const childNode = isNode(child) ? scope.createProxy(child) : child + cb(childNode, childNode, $node) + }, thisArg) + } + } + + if (prop === "difference") { + return (set: Set) => { + const diff = new Set() + + for (const $child of $node) { + const child = isNode($child) ? $child?.$value : $child + if (!set.has(child)) { + const childNode = isNode($child) + ? scope.createProxy($child) + : $child + diff.add(childNode) + } + } + + return diff + } + } + + if (prop === "intersection") { + return (set: Set) => { + const diff = new Set() + + for (const $child of $node) { + const child = isNode($child) ? $child?.$value : $child + if (set.has(child)) { + const childNode = isNode($child) + ? scope.createProxy($child) + : $child + diff.add(childNode) + } + } + + return diff + } + } + + return super.get($node, prop, receiver) + } +} diff --git a/packages/arbor-ost/src/scoping/scope.ts b/packages/arbor-ost/src/scoping/scope.ts new file mode 100644 index 00000000..230bd4e0 --- /dev/null +++ b/packages/arbor-ost/src/scoping/scope.ts @@ -0,0 +1,53 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { OST } from "../ost" +import { $, Node, Subscriber, Unsubscribe, Value } from "../types" +import { $object } from "./handlers/$object" +import { $map } from "./handlers/$map" +import { $set } from "./handlers/$set" + +export class Scope { + #handlers = [$map, $set, $object] + + constructor( + readonly ost: OST, + readonly proxies = new WeakMap(), + readonly tracked = new WeakMap>() + ) {} + + get root(): $ { + return this.createProxy(this.ost.root) as $ + } + + subscribe(subscriber: Subscriber): Unsubscribe { + return this.ost.subscribe((event) => { + const seed = event.target.$seed + const trackedNode = this.tracked.get(seed) + if ( + trackedNode && + (event.metadata.operation !== "set" || + trackedNode.has(event.metadata.args[0])) + ) { + subscriber(event) + } + }) + } + + createProxy($node: Node) { + const seed = $node.$seed + + if (this.proxies.has(seed)) { + return this.proxies.get(seed) + } + + if (!this.tracked.has(seed)) { + this.tracked.set(seed, new Set()) + } + + const handler = this.#handlers.find(h => h.accepts($node)) + const proxy = new Proxy($node, new handler(this)) + + this.proxies.set(seed, proxy) + + return proxy + } +} diff --git a/packages/arbor-ost/src/seed.ts b/packages/arbor-ost/src/seed.ts new file mode 100644 index 00000000..68320732 --- /dev/null +++ b/packages/arbor-ost/src/seed.ts @@ -0,0 +1,12 @@ +const nextSeed = ( + (seed = 0) => + () => + seed++ +)() + +/** + * Uniquely identifies nodes within an OST instance. + */ +export class Seed { + constructor(readonly value = nextSeed()) {} +} diff --git a/packages/arbor-ost/src/subscriptions.ts b/packages/arbor-ost/src/subscriptions.ts new file mode 100644 index 00000000..d9c9a2e8 --- /dev/null +++ b/packages/arbor-ost/src/subscriptions.ts @@ -0,0 +1,27 @@ +import { MutationEvent, Subscriber, Unsubscribe, Value } from "./types" + +export class Subscriptions { + constructor(private subscriptions = new Set>()) {} + + subscribe(subscriber: Subscriber): Unsubscribe { + this.subscriptions.add(subscriber) + + return () => { + this.subscriptions.delete(subscriber) + } + } + + notify(event: MutationEvent) { + for (const subscriber of this.subscriptions) { + subscriber(event) + } + } + + reset() { + this.subscriptions.clear() + } + + get size() { + return this.subscriptions.size + } +} diff --git a/packages/arbor-ost/src/types/index.ts b/packages/arbor-ost/src/types/index.ts new file mode 100644 index 00000000..da7a5648 --- /dev/null +++ b/packages/arbor-ost/src/types/index.ts @@ -0,0 +1,52 @@ +import { OST } from "../ost" +import { Path } from "../path" +import { Seed } from "../seed" +import { Subscriptions } from "../subscriptions" + +export type Prop = string | symbol + +export interface ProxyHandlerConstructor { + new (ost: OST): ProxyHandler + accepts(value: unknown): boolean +} + +export type $ = Node & { + [K in keyof T]: T[K] extends Function + ? T[K] + : T[K] extends Array + ? $ + : T[K] extends object + ? $ + : T[K] +} + +export type Value = object + +export type Node = V & { + readonly $ost: OST + readonly $value: V + readonly $path: Path + readonly $seed: Seed + readonly $parent?: Node + readonly $subscriptions: Subscriptions + $children(): Iterable + $createChild(value: C): Node +} + +export type MutationMetadata = { + readonly operation: string + readonly args: unknown[] +} + +export type Mutation = (node?: Node) => MutationMetadata + +export type MutationEvent = { + target: Node + metadata: MutationMetadata +} + +export type Subscriber = ( + event: MutationEvent +) => void + +export type Unsubscribe = () => void diff --git a/packages/arbor-ost/src/types/set-methods.d.ts b/packages/arbor-ost/src/types/set-methods.d.ts new file mode 100644 index 00000000..0625b6ba --- /dev/null +++ b/packages/arbor-ost/src/types/set-methods.d.ts @@ -0,0 +1,48 @@ +// Type declarations for new ECMAScript Set methods +// These methods are part of the Set Methods proposal and may not be included +// in all TypeScript lib definitions yet + +/** + * Represents a Set-like object that has the properties needed for Set operations. + * This includes size, has(), and keys() methods as per the Set methods specification. + */ +export interface ReadonlySetLike { + /** + * Returns the number of elements in the set. + */ + readonly size: number + + /** + * Returns true if the set contains the specified element. + * @param value - The value to test for presence + */ + has(value: T): boolean + + /** + * Returns an iterator for the keys (values) in the set. + */ + keys(): IterableIterator +} + +// Declare global Set interface augmentation +declare global { + interface Set { + difference(other: ReadonlySetLike): Set + intersection(other: ReadonlySetLike): Set + union(other: ReadonlySetLike): Set + symmetricDifference(other: ReadonlySetLike): Set + isDisjointFrom(other: ReadonlySetLike): boolean + isSubsetOf(other: ReadonlySetLike): boolean + isSupersetOf(other: ReadonlySetLike): boolean + } + + interface ReadonlySet { + difference(other: ReadonlySetLike): Set + intersection(other: ReadonlySetLike): Set + union(other: ReadonlySetLike): Set + symmetricDifference(other: ReadonlySetLike): Set + isDisjointFrom(other: ReadonlySetLike): boolean + isSubsetOf(other: ReadonlySetLike): boolean + isSupersetOf(other: ReadonlySetLike): boolean + } +} diff --git a/packages/arbor-ost/tests/handlers/$array.test.ts b/packages/arbor-ost/tests/handlers/$array.test.ts new file mode 100644 index 00000000..fe655c9b --- /dev/null +++ b/packages/arbor-ost/tests/handlers/$array.test.ts @@ -0,0 +1,713 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { describe, expect, it, vi } from "vitest" + +import { OST } from "../../src/ost" + +describe("$array", () => { + describe("#push", () => { + it("mutates the underlying value", () => { + const state = { + todos: [ + { id: 1, content: "Learn Arbor" }, + { id: 2, content: "Implement OST" }, + ], + } + + const ost = new OST(state) + const newTodoValue = { id: 3, content: "Learn LLM" } + const length = ost.root.todos.push(newTodoValue) + + expect(length).toEqual(3) + expect(ost.root.todos[2].$value).toBe(newTodoValue) + }) + + it("notifies subscribers of a new item in the array", () => { + const state = { + todos: [ + { id: 1, content: "Learn Arbor" }, + { id: 2, content: "Implement OST" }, + ], + } + + const subscriber = vi.fn() + const ost = new OST(state) + ost.subscribe(subscriber) + ost.root.todos.push({ id: 3, content: "Learn LLM" }) + + expect(subscriber).toHaveBeenCalledOnce() + }) + + it("exposes mutation event metadata to subscribers", async () => { + const state = { + todos: [ + { id: 1, content: "Learn Arbor" }, + { id: 2, content: "Implement OST" }, + ], + } + + const ost = new OST(state) + const newTodo1 = { id: 3, content: "Learn LLM" } + const newTodo2 = { id: 4, content: "Implement dev tools" } + + return new Promise((resolve) => { + ost.subscribe((event) => { + expect(event.target).toBe(ost.root.todos) + expect(event.metadata.args).toEqual([[newTodo1, newTodo2]]) + expect(event.metadata.operation).toEqual("push") + resolve(true) + }) + + ost.root.todos.push(newTodo1, newTodo2) + }) + }) + }) + + describe("#pop", () => { + it("mutates the underlying value", () => { + const todo1 = { id: 1, content: "Learn Arbor" } + const todo2 = { id: 2, content: "Implement OST" } + const state = { + todos: [todo1, todo2], + } + + const ost = new OST(state) + const $todo2 = ost.root.todos[1] + const removed = ost.root.todos.pop() + + expect(state.todos.length).toEqual(1) + expect(removed).toBe(todo2) + expect($todo2).toBeDetachedFrom(ost) + }) + + it("notifies subscribers of a new item in the array", () => { + const state = { + todos: [ + { id: 1, content: "Learn Arbor" }, + { id: 2, content: "Implement OST" }, + ], + } + + const subscriber = vi.fn() + const ost = new OST(state) + ost.subscribe(subscriber) + ost.root.todos.pop() + + expect(subscriber).toHaveBeenCalledOnce() + }) + + it("exposes mutation event metadata to subscribers", async () => { + const todo1 = { id: 1, content: "Learn Arbor" } + const todo2 = { id: 2, content: "Implement OST" } + const state = { + todos: [todo1, todo2], + } + + const ost = new OST(state) + + return new Promise((resolve) => { + ost.subscribe((event) => { + expect(event.target).toBe(ost.root.todos) + expect(event.metadata.args).toEqual([]) + expect(event.metadata.operation).toEqual("pop") + resolve(true) + }) + + ost.root.todos.pop() + }) + }) + }) + + describe("#shift", () => { + it("mutates the underlying value", () => { + const todo1 = { id: 1, content: "Learn Arbor" } + const todo2 = { id: 2, content: "Implement OST" } + const state = { + todos: [todo1, todo2], + } + + const ost = new OST(state) + const $todo1 = ost.root.todos[0] + const removed = ost.root.todos.shift() + + expect(state.todos.length).toEqual(1) + expect(removed).toBe(todo1) + expect($todo1).toBeDetachedFrom(ost) + }) + + it("notifies subscribers of a new item in the array", () => { + const state = { + todos: [ + { id: 1, content: "Learn Arbor" }, + { id: 2, content: "Implement OST" }, + ], + } + + const subscriber = vi.fn() + const ost = new OST(state) + ost.subscribe(subscriber) + ost.root.todos.shift() + + expect(subscriber).toHaveBeenCalledOnce() + }) + + it("exposes mutation event metadata to subscribers", async () => { + const todo1 = { id: 1, content: "Learn Arbor" } + const todo2 = { id: 2, content: "Implement OST" } + const state = { + todos: [todo1, todo2], + } + + const ost = new OST(state) + + return new Promise((resolve) => { + ost.subscribe((event) => { + expect(event.target).toBe(ost.root.todos) + expect(event.metadata.args).toEqual([]) + expect(event.metadata.operation).toEqual("shift") + resolve(true) + }) + + ost.root.todos.shift() + }) + }) + }) + + describe("#unshift", () => { + it("mutates the underlying value", () => { + const todo1 = { id: 1, content: "Learn Arbor" } + const todo2 = { id: 2, content: "Implement OST" } + const todo3 = { id: 3, content: "Write tests" } + const todo4 = { id: 4, content: "Refactor code" } + const state = { + todos: [todo1, todo2], + } + + const ost = new OST(state) + const length = ost.root.todos.unshift(todo3, todo4) + + expect(length).toEqual(4) + expect(state.todos.length).toEqual(4) + expect(ost).not.toHaveNodeFor(todo1) + expect(ost).not.toHaveNodeFor(todo2) + expect(ost).not.toHaveNodeFor(todo3) + expect(ost).not.toHaveNodeFor(todo4) + expect(ost).toHaveNodeValuePair([ost.root.todos[0], todo3]) + expect(ost).toHaveNodeValuePair([ost.root.todos[1], todo4]) + expect(ost).toHaveNodeValuePair([ost.root.todos[2], todo1]) + expect(ost).toHaveNodeValuePair([ost.root.todos[3], todo2]) + }) + + it("notifies subscribers of a new item in the array", () => { + const state = { + todos: [ + { id: 1, content: "Learn Arbor" }, + { id: 2, content: "Implement OST" }, + ], + } + + const subscriber = vi.fn() + const ost = new OST(state) + ost.subscribe(subscriber) + ost.root.todos.unshift({ id: 3, content: "Write tests" }) + + expect(subscriber).toHaveBeenCalledOnce() + }) + + it("exposes mutation event metadata to subscribers", async () => { + const todo1 = { id: 1, content: "Learn Arbor" } + const todo2 = { id: 2, content: "Implement OST" } + const todo3 = { id: 3, content: "Write tests" } + const todo4 = { id: 4, content: "Refactor code" } + const state = { + todos: [todo1, todo2], + } + + const ost = new OST(state) + + return new Promise((resolve) => { + ost.subscribe((event) => { + expect(event.target).toBe(ost.root.todos) + expect(event.metadata.args).toEqual([[todo3, todo4]]) + expect(event.metadata.operation).toEqual("unshift") + resolve(true) + }) + + ost.root.todos.unshift(todo3, todo4) + }) + }) + }) + + describe("#reverse", () => { + it("mutates the underlying value", () => { + const todo1 = { id: 1, content: "Learn Arbor" } + const todo2 = { id: 2, content: "Implement OST" } + const state = { + todos: [todo1, todo2], + } + + const ost = new OST(state) + const $todos = ost.root.todos.reverse() + + expect($todos).toBe(ost.root.todos) + }) + + it("notifies subscribers of a new item in the array", () => { + const state = { + todos: [ + { id: 1, content: "Learn Arbor" }, + { id: 2, content: "Implement OST" }, + ], + } + + const subscriber = vi.fn() + const ost = new OST(state) + ost.subscribe(subscriber) + ost.root.todos.reverse() + + expect(subscriber).toHaveBeenCalledOnce() + }) + + it("exposes mutation event metadata to subscribers", async () => { + const todo1 = { id: 1, content: "Learn Arbor" } + const todo2 = { id: 2, content: "Implement OST" } + const state = { + todos: [todo1, todo2], + } + + const ost = new OST(state) + + return new Promise((resolve) => { + ost.subscribe((event) => { + expect(event.target).toBe(ost.root.todos) + expect(event.metadata.operation).toEqual("reverse") + expect(event.metadata.args).toEqual([]) + resolve(true) + }) + + ost.root.todos.reverse() + }) + }) + }) + + describe("#filter", () => { + it("selects nodes based on a predicate", () => { + const todo1 = { content: "Learn Arbor" } + const todo2 = { content: "Do the dishes" } + const todo3 = { content: "Implement Arbor OST" } + const ost = new OST({ + todos: [todo1, todo2, todo3], + }) + + const selected = ost.root.todos.filter((t) => t.content.includes("Arbor")) + + expect(selected.length).toEqual(2) + expect(selected[0]).toBe(ost.root.todos[0]) + expect(selected[1]).toBe(ost.root.todos[2]) + }) + }) + + describe("#copyWithin", () => { + it("mutates the underlying value", () => { + const todo1 = { content: "Learn Arbor" } + const todo2 = { content: "Do the dishes" } + const todo3 = { content: "Implement Arbor OST" } + const ost = new OST({ + todos: [todo1, todo2, todo3], + }) + + const copied = ost.root.todos.copyWithin(1, 1, 2) + + expect(copied.length).toEqual(3) + expect(copied).toBe(ost.root.todos) + }) + + it("notifies subscribers of a new item in the array", () => { + const subscriber = vi.fn() + const todo1 = { content: "Learn Arbor" } + const todo2 = { content: "Do the dishes" } + const todo3 = { content: "Implement Arbor OST" } + const ost = new OST({ + todos: [todo1, todo2, todo3], + }) + + ost.subscribe(subscriber) + + ost.root.todos.copyWithin(1, 1, 2) + + expect(subscriber).toHaveBeenCalledOnce() + }) + + it("exposes mutation event metadata to subscribers", async () => { + const todo1 = { content: "Learn Arbor" } + const todo2 = { content: "Do the dishes" } + const todo3 = { content: "Implement Arbor OST" } + const ost = new OST({ + todos: [todo1, todo2, todo3], + }) + + return new Promise((resolve) => { + ost.subscribe((event) => { + expect(event.target).toBe(ost.root.todos) + expect(event.metadata.operation).toEqual("copyWithin") + expect(event.metadata.args).toEqual([1, 1, 2]) + resolve(true) + }) + + ost.root.todos.copyWithin(1, 1, 2) + }) + }) + }) + + describe("#splice", () => { + it("mutates the underlying value", () => { + const todo1 = { content: "Learn Arbor" } + const todo2 = { content: "Do the dishes" } + const todo3 = { content: "Implement Arbor OST" } + const todo4 = { content: "New todo 1" } + const todo5 = { content: "New todo 2" } + + const ost = new OST({ + todos: [todo1, todo2, todo3], + }) + + const spliced = ost.root.todos.splice(1, 1, todo4, todo5) + + expect(spliced).toEqual([{ content: "Do the dishes" }]) + expect(ost.root.todos.$value).toEqual([todo1, todo4, todo5, todo3]) + }) + + it("notifies subscribers of a new item in the array", () => { + const subscriber = vi.fn() + const todo1 = { content: "Learn Arbor" } + const todo2 = { content: "Do the dishes" } + const todo3 = { content: "Implement Arbor OST" } + const todo4 = { content: "New todo 1" } + const todo5 = { content: "New todo 2" } + const ost = new OST({ + todos: [todo1, todo2, todo3], + }) + + ost.subscribe(subscriber) + + ost.root.todos.splice(1, 1, todo4, todo5) + + expect(subscriber).toHaveBeenCalledOnce() + }) + + it("exposes mutation event metadata to subscribers", async () => { + const todo1 = { content: "Learn Arbor" } + const todo2 = { content: "Do the dishes" } + const todo3 = { content: "Implement Arbor OST" } + const todo4 = { content: "New todo 1" } + const todo5 = { content: "New todo 2" } + const ost = new OST({ + todos: [todo1, todo2, todo3], + }) + + return new Promise((resolve) => { + ost.subscribe((event) => { + expect(event.target).toBe(ost.root.todos) + expect(event.metadata.operation).toEqual("splice") + expect(event.metadata.args).toEqual([1, 1, [todo4, todo5]]) + resolve(true) + }) + + ost.root.todos.splice(1, 1, todo4, todo5) + }) + }) + }) + + describe("#fill", () => { + it("mutates the underlying value", () => { + const todo1 = { content: "Learn Arbor" } + const todo2 = { content: "Do the dishes" } + const todo3 = { content: "Implement Arbor OST" } + const todo4 = { content: "New todo 1" } + + const ost = new OST({ + todos: [todo1, todo2, todo3], + }) + + const filled = ost.root.todos.fill(todo4, 1, 3) + + expect(filled).toBe(ost.root.todos) + expect(ost).toHaveNodeValuePair([ost.root.todos[0], todo1]) + expect(ost).toHaveNodeValuePair([ost.root.todos[1], todo4]) + expect(ost).toHaveNodeValuePair([ost.root.todos[2], todo4]) + expect(ost.root.todos[3]).toBeUndefined() + }) + + it("notifies subscribers of a new item in the array", () => { + const subscriber = vi.fn() + const todo1 = { content: "Learn Arbor" } + const todo2 = { content: "Do the dishes" } + const todo3 = { content: "Implement Arbor OST" } + const todo4 = { content: "New todo 1" } + const ost = new OST({ + todos: [todo1, todo2, todo3], + }) + + ost.subscribe(subscriber) + + ost.root.todos.fill(todo4, 1, 3) + + expect(subscriber).toHaveBeenCalledOnce() + }) + + it("exposes mutation event metadata to subscribers", async () => { + const todo1 = { content: "Learn Arbor" } + const todo2 = { content: "Do the dishes" } + const todo3 = { content: "Implement Arbor OST" } + const todo4 = { content: "New todo 1" } + const ost = new OST({ + todos: [todo1, todo2, todo3], + }) + + return new Promise((resolve) => { + ost.subscribe((event) => { + expect(event.target).toBe(ost.root.todos) + expect(event.metadata.operation).toEqual("fill") + expect(event.metadata.args).toEqual([todo4, 1, 3]) + resolve(true) + }) + + ost.root.todos.fill(todo4, 1, 3) + }) + }) + }) + + describe("#at", () => { + it("returns the node item at the given position", () => { + const todo1 = { content: "Learn Arbor" } + const todo2 = { content: "Do the dishes" } + const todo3 = { content: "Implement Arbor OST" } + const ost = new OST({ + todos: [todo1, todo2, todo3], + }) + + const node = ost.root.todos.at(1) + + expect(node).toBe(ost.root.todos[1]) + }) + }) + + describe("#find", () => { + it("finds the node item by the given predicate", () => { + const todo1 = { content: "Learn Arbor" } + const todo2 = { content: "Do the dishes" } + const todo3 = { content: "Implement Arbor OST" } + const ost = new OST({ + todos: [todo1, todo2, todo3], + }) + + const node = ost.root.todos.find((t) => t.content.startsWith("Do ")) + + expect(node).toBe(ost.root.todos[1]) + }) + + it("returns OST node wrapper instead of raw value", () => { + const todo1 = { content: "Learn Arbor" } + const todo2 = { content: "Do the dishes" } + const ost = new OST({ + todos: [todo1, todo2], + }) + + const found = ost.root.todos.find((t) => t.content.startsWith("Do ")) + + expect(found).not.toBe(todo2) // Should not be the raw value + expect(ost).toHaveNodeValuePair([found, todo2]) // Should be OST node wrapping the value + expect(found).toBe(ost.root.todos[1]) // Should be the same OST node + }) + + it("predicate receives OST node wrappers as arguments", () => { + const todo1 = { content: "Learn Arbor" } + const todo2 = { content: "Do the dishes" } + const ost = new OST({ + todos: [todo1, todo2], + }) + + const predicateArgs: any[] = [] + ost.root.todos.find((item, index, array) => { + predicateArgs.push({ item, index, array }) + return false + }) + + expect(predicateArgs).toHaveLength(2) + expect(predicateArgs[0].item).toBe(ost.root.todos[0]) // OST node + expect(predicateArgs[0].item.$value).toBe(todo1) // Raw value + expect(predicateArgs[0].index).toBe(0) + expect(predicateArgs[0].array).toBe(ost.root.todos) + expect(predicateArgs[1].item).toBe(ost.root.todos[1]) // OST node + expect(predicateArgs[1].item.$value).toBe(todo2) // Raw value + expect(predicateArgs[1].index).toBe(1) + expect(predicateArgs[1].array).toBe(ost.root.todos) + }) + }) + + describe("#forEach", () => { + it("iterates over OST node wrappers instead of raw values", () => { + const todo1 = { content: "Learn Arbor" } + const todo2 = { content: "Do the dishes" } + const ost = new OST({ + todos: [todo1, todo2], + }) + + const iteratedItems: any[] = [] + ost.root.todos.forEach((item, index, array) => { + iteratedItems.push({ item, index, array }) + }) + + expect(iteratedItems).toHaveLength(2) + expect(iteratedItems[0].item).toBe(ost.root.todos[0]) // OST node + expect(iteratedItems[0].item.$value).toBe(todo1) // Raw value + expect(iteratedItems[0].index).toBe(0) + expect(iteratedItems[0].array).toBe(ost.root.todos) + expect(iteratedItems[1].item).toBe(ost.root.todos[1]) // OST node + expect(iteratedItems[1].item.$value).toBe(todo2) // Raw value + expect(iteratedItems[1].index).toBe(1) + expect(iteratedItems[1].array).toBe(ost.root.todos) + }) + }) + + describe("#map", () => { + it("provides OST node wrappers to mapper function", () => { + const todo1 = { content: "Learn Arbor" } + const todo2 = { content: "Do the dishes" } + const ost = new OST({ + todos: [todo1, todo2], + }) + + const mappedItems: any[] = [] + const result = ost.root.todos.map((item, index, array) => { + mappedItems.push({ item, index, array }) + return item.content.toUpperCase() + }) + + expect(mappedItems).toHaveLength(2) + expect(mappedItems[0].item).toBe(ost.root.todos[0]) // OST node + expect(mappedItems[0].item.$value).toBe(todo1) // Raw value + expect(mappedItems[1].item).toBe(ost.root.todos[1]) // OST node + expect(mappedItems[1].item.$value).toBe(todo2) // Raw value + expect(result).toEqual(["LEARN ARBOR", "DO THE DISHES"]) + }) + }) + + describe("#some", () => { + it("provides OST node wrappers to predicate function", () => { + const todo1 = { content: "Learn Arbor" } + const todo2 = { content: "Do the dishes" } + const ost = new OST({ + todos: [todo1, todo2], + }) + + const predicateArgs: any[] = [] + const result = ost.root.todos.some((item, index, array) => { + predicateArgs.push({ item, index, array }) + return item.content.includes("dishes") + }) + + expect(result).toBe(true) + expect(predicateArgs).toHaveLength(2) + expect(predicateArgs[0].item).toBe(ost.root.todos[0]) // OST node + expect(predicateArgs[1].item).toBe(ost.root.todos[1]) // OST node + }) + }) + + describe("#every", () => { + it("provides OST node wrappers to predicate function", () => { + const todo1 = { content: "Learn Arbor" } + const todo2 = { content: "Do the dishes" } + const ost = new OST({ + todos: [todo1, todo2], + }) + + const predicateArgs: any[] = [] + const result = ost.root.todos.every((item, index, array) => { + predicateArgs.push({ item, index, array }) + return typeof item.content === "string" + }) + + expect(result).toBe(true) + expect(predicateArgs).toHaveLength(2) + expect(predicateArgs[0].item).toBe(ost.root.todos[0]) // OST node + expect(predicateArgs[1].item).toBe(ost.root.todos[1]) // OST node + }) + }) + + describe("#findIndex", () => { + it("provides OST node wrappers to predicate function", () => { + const todo1 = { content: "Learn Arbor" } + const todo2 = { content: "Do the dishes" } + const ost = new OST({ + todos: [todo1, todo2], + }) + + const predicateArgs: any[] = [] + const result = ost.root.todos.findIndex((item, index, array) => { + predicateArgs.push({ item, index, array }) + return item.content.includes("dishes") + }) + + expect(result).toBe(1) + expect(predicateArgs).toHaveLength(2) + expect(predicateArgs[0].item).toBe(ost.root.todos[0]) // OST node + expect(predicateArgs[1].item).toBe(ost.root.todos[1]) // OST node + }) + }) + + describe("#reduce", () => { + it("provides OST node wrappers to reducer function", () => { + const todo1 = { content: "Learn Arbor" } + const todo2 = { content: "Do the dishes" } + const ost = new OST({ + todos: [todo1, todo2], + }) + + const reducerArgs: any[] = [] + const result = ost.root.todos.reduce((acc, item, index, array) => { + reducerArgs.push({ acc, item, index, array }) + return acc + item.content.length + }, 0) + + expect(result).toBe(todo1.content.length + todo2.content.length) + expect(reducerArgs).toHaveLength(2) + expect(reducerArgs[0].item).toBe(ost.root.todos[0]) // OST node + expect(reducerArgs[1].item).toBe(ost.root.todos[1]) // OST node + }) + }) + + describe("#Symbol.iterator", () => { + it("yields OST node wrappers instead of raw values", () => { + const todo1 = { content: "Learn Arbor" } + const todo2 = { content: "Do the dishes" } + const ost = new OST({ + todos: [todo1, todo2], + }) + + const items = [...ost.root.todos] + + expect(items).toHaveLength(2) + expect(items[0]).toBe(ost.root.todos[0]) // OST node + expect(items[0].$value).toBe(todo1) // Raw value + expect(items[1]).toBe(ost.root.todos[1]) // OST node + expect(items[1].$value).toBe(todo2) // Raw value + }) + + it("works with for...of loops", () => { + const todo1 = { content: "Learn Arbor" } + const todo2 = { content: "Do the dishes" } + const ost = new OST({ + todos: [todo1, todo2], + }) + + const items: any[] = [] + for (const item of ost.root.todos) { + items.push(item) + } + + expect(items).toHaveLength(2) + expect(items[0]).toBe(ost.root.todos[0]) // OST node + expect(items[1]).toBe(ost.root.todos[1]) // OST node + }) + }) +}) diff --git a/packages/arbor-ost/tests/handlers/$map.test.ts b/packages/arbor-ost/tests/handlers/$map.test.ts new file mode 100644 index 00000000..ab0b2757 --- /dev/null +++ b/packages/arbor-ost/tests/handlers/$map.test.ts @@ -0,0 +1,504 @@ +import { describe, expect, it, vi } from "vitest" + +import { OST } from "../../src/ost" +import { node } from "../../src/decorators/node" + +describe("$map", () => { + describe("#set", () => { + it("mutates the underlying value", () => { + const todos = new Map() + todos.set(1, { id: 1, content: "Learn Arbor" }) + todos.set(2, { id: 2, content: "Implement OST" }) + + const newTodoValue = { id: 3, content: "Learn LLM" } + const state = { todos } + const ost = new OST(state) + + ost.root.todos.set(3, newTodoValue) + + expect(ost.root.todos.size).toEqual(3) + expect(ost.root.todos.get(3).$value).toBe(newTodoValue) + }) + + it("can store non-proxiable values", () => { + const todos = new Map() + todos.set(1, "Learn Arbor") + todos.set(2, "Implement OST") + + const newTodoValue = "Learn LLM" + const state = { todos } + const ost = new OST(state) + + ost.root.todos.set(3, newTodoValue) + + expect(ost.root.todos.size).toEqual(3) + expect(ost.root.todos.get(3)).toBe(newTodoValue) + }) + + it("notifies subscribers of a new item in the array", () => { + const todos = new Map() + todos.set(1, { id: 1, content: "Learn Arbor" }) + todos.set(2, { id: 2, content: "Implement OST" }) + + const newTodoValue = { id: 3, content: "Learn LLM" } + const state = { todos } + const ost = new OST(state) + const subscriber = vi.fn() + + ost.subscribe(subscriber) + ost.root.todos.set(3, newTodoValue) + + expect(subscriber).toHaveBeenCalledOnce() + }) + + it("exposes mutation event metadata to subscribers", async () => { + const todos = new Map() + todos.set(1, { id: 1, content: "Learn Arbor" }) + todos.set(2, { id: 2, content: "Implement OST" }) + + const state = { todos } + const ost = new OST(state) + + return new Promise((resolve) => { + const newTodoValue = { id: 3, content: "Learn LLM" } + + ost.subscribe((event) => { + expect(event.target).toBe(ost.root.todos) + expect(event.metadata.args).toEqual([3, newTodoValue]) + expect(event.metadata.operation).toEqual("set") + resolve(true) + }) + + ost.root.todos.set(3, newTodoValue) + }) + }) + }) + + describe("#get", () => { + it("returns the value of the key", () => { + const todo1 = { id: 1, content: "Learn Arbor" } + const todo2 = { id: 2, content: "Implement OST" } + const todos = new Map() + todos.set(1, todo1) + todos.set(2, todo2) + + const state = { todos } + const ost = new OST(state) + + expect(ost.root.todos.get(1).$value).toBe(todo1) + expect(ost.root.todos.get(2).$value).toBe(todo2) + }) + + it("caches the node representing the map item", () => { + const todo1 = { id: 1, content: "Learn Arbor" } + const todo2 = { id: 2, content: "Implement OST" } + const todos = new Map() + todos.set(1, todo1) + todos.set(2, todo2) + + const state = { todos } + const ost = new OST(state) + + expect(ost.root.todos.get(1)).toBe(ost.root.todos.get(1)) + expect(ost.root.todos.get(2)).toBe(ost.root.todos.get(2)) + }) + }) + + describe("#delete", () => { + it("mutates the underlying value", () => { + const todos = new Map() + todos.set(1, { id: 1, content: "Learn Arbor" }) + todos.set(2, { id: 2, content: "Implement OST" }) + + const state = { todos } + const ost = new OST(state) + + ost.root.todos.delete(2) + + expect(ost.root.todos.size).toEqual(1) + expect(ost.root.todos.get(2)).toBeUndefined() + }) + + it("notifies subscribers of a new item in the array", () => { + const todos = new Map() + todos.set(1, { id: 1, content: "Learn Arbor" }) + todos.set(2, { id: 2, content: "Implement OST" }) + + const state = { todos } + const ost = new OST(state) + const subscriber = vi.fn() + + ost.subscribe(subscriber) + ost.root.todos.delete(2) + + expect(subscriber).toHaveBeenCalledOnce() + }) + + it("does not notify subscribers if deleted key does not exist in the map", () => { + const todos = new Map() + todos.set(1, { id: 1, content: "Learn Arbor" }) + todos.set(2, { id: 2, content: "Implement OST" }) + + const state = { todos } + const ost = new OST(state) + const subscriber = vi.fn() + + ost.subscribe(subscriber) + ost.root.todos.delete(3) + + expect(subscriber).not.toHaveBeenCalledOnce() + }) + + it("exposes mutation event metadata to subscribers", async () => { + const todos = new Map() + todos.set(1, { id: 1, content: "Learn Arbor" }) + todos.set(2, { id: 2, content: "Implement OST" }) + + const state = { todos } + const ost = new OST(state) + + return new Promise((resolve) => { + ost.subscribe((event) => { + expect(event.target).toBe(ost.root.todos) + expect(event.metadata.args).toEqual([2]) + expect(event.metadata.operation).toEqual("delete") + resolve(true) + }) + + ost.root.todos.delete(2) + }) + }) + }) + + describe("#clear", () => { + it("mutates the underlying value", () => { + const todos = new Map() + todos.set(1, { id: 1, content: "Learn Arbor" }) + todos.set(2, { id: 2, content: "Implement OST" }) + + const state = { todos } + const ost = new OST(state) + + ost.root.todos.clear() + + expect(ost.root.todos.size).toEqual(0) + expect(ost.root.todos.get(1)).toBeUndefined() + expect(ost.root.todos.get(2)).toBeUndefined() + }) + + it("notifies subscribers of a new item in the array", () => { + const todos = new Map() + todos.set(1, { id: 1, content: "Learn Arbor" }) + todos.set(2, { id: 2, content: "Implement OST" }) + + const state = { todos } + const ost = new OST(state) + const subscriber = vi.fn() + + ost.subscribe(subscriber) + ost.root.todos.clear() + + expect(subscriber).toHaveBeenCalledOnce() + }) + + it("does not notify subscribers if map is already empty", () => { + const todos = new Map() + + const state = { todos } + const ost = new OST(state) + const subscriber = vi.fn() + + ost.subscribe(subscriber) + ost.root.todos.clear() + + expect(subscriber).not.toHaveBeenCalledOnce() + }) + + it("exposes mutation event metadata to subscribers", async () => { + const todos = new Map() + todos.set(1, { id: 1, content: "Learn Arbor" }) + todos.set(2, { id: 2, content: "Implement OST" }) + + const state = { todos } + const ost = new OST(state) + + return new Promise((resolve) => { + ost.subscribe((event) => { + expect(event.target).toBe(ost.root.todos) + expect(event.metadata.args).toEqual([]) + expect(event.metadata.operation).toEqual("clear") + resolve(true) + }) + + ost.root.todos.clear() + }) + }) + }) + + describe("#entries", () => { + it("returns an iterator that exposes the key and node pair held by the map node", () => { + const todos = new Map() + todos.set(1, { id: 1, content: "Learn Arbor" }) + todos.set(2, { id: 2, content: "Implement OST" }) + + const state = { todos } + const ost = new OST(state) + + const iterator = ost.root.todos.entries() + + expect(iterator.next().value).toEqual([1, ost.root.todos.get(1)]) + expect(iterator.next().value).toEqual([2, ost.root.todos.get(2)]) + expect(iterator.next().done).toBe(true) + }) + + it("lazily creates nodes for entries accessed", () => { + const todos = new Map() + const todo1 = { id: 1, content: "Learn Arbor" } + todos.set(1, todo1) + const todo2 = { id: 2, content: "Implement OST" } + todos.set(2, todo2) + + const state = { todos } + const ost = new OST(state) + + const iterator = ost.root.todos.entries() + + expect(ost).not.toHaveNodeFor(todo1) + expect(ost).not.toHaveNodeFor(todo2) + + iterator.next() + + expect(ost).toHaveNodeFor(todo1) + expect(ost).not.toHaveNodeFor(todo2) + + iterator.next() + + expect(ost).toHaveNodeFor(todo1) + expect(ost).toHaveNodeFor(todo2) + }) + }) + + describe("#forEach", () => { + it("iterates over the entries of the map", () => { + const todos = new Map() + todos.set(1, { id: 1, content: "Learn Arbor" }) + todos.set(2, { id: 2, content: "Implement OST" }) + + const state = { todos } + const ost = new OST(state) + + const entries = [] + ost.root.todos.forEach((value, key, map) => { + entries.push({ value, key }) + expect(map).toBe(ost.root.todos) + }) + + expect(entries[0].value).toEqual(ost.root.todos.get(1)) + expect(entries[0].key).toEqual(1) + expect(entries[1].value).toEqual(ost.root.todos.get(2)) + expect(entries[1].key).toEqual(2) + }) + }) + + describe("#has", () => { + it("returns true if the given key exists in the map", () => { + const todos = new Map() + const todo1 = { id: 1, content: "Learn Arbor" } + const todo2 = { id: 2, content: "Implement OST" } + todos.set(1, todo1) + todos.set(2, todo2) + + const state = { todos } + const ost = new OST(state) + + expect(ost.root.todos.has(1)).toBe(true) + expect(ost.root.todos.has(2)).toBe(true) + expect(ost.root.todos.has(3)).toBe(false) + }) + }) + + describe("keys", () => { + it("returns an iterator over the keys of the map", () => { + const todos = new Map() + const todo1 = { id: 1, content: "Learn Arbor" } + const todo2 = { id: 2, content: "Implement OST" } + todos.set(1, todo1) + todos.set(2, todo2) + + const state = { todos } + const ost = new OST(state) + + const iterator = ost.root.todos.keys() + expect(iterator.next().value).toEqual(1) + expect(iterator.next().value).toEqual(2) + expect(iterator.next().done).toBe(true) + }) + }) + + describe("values", () => { + it("returns an iterator over the node values of the map", () => { + const todos = new Map() + const todo1 = { id: 1, content: "Learn Arbor" } + const todo2 = { id: 2, content: "Implement OST" } + todos.set(1, todo1) + todos.set(2, todo2) + + const state = { todos } + const ost = new OST(state) + + const iterator = ost.root.todos.values() + expect(iterator.next().value).toBe(ost.root.todos.get(1)) + expect(iterator.next().value).toBe(ost.root.todos.get(2)) + expect(iterator.next().done).toBe(true) + }) + + it("lazily creates nodes for entries accessed", () => { + const todos = new Map() + const todo1 = { id: 1, content: "Learn Arbor" } + todos.set(1, todo1) + const todo2 = { id: 2, content: "Implement OST" } + todos.set(2, todo2) + + const state = { todos } + const ost = new OST(state) + + const iterator = ost.root.todos.values() + + expect(ost).not.toHaveNodeFor(todo1) + expect(ost).not.toHaveNodeFor(todo2) + + iterator.next() + + expect(ost).toHaveNodeFor(todo1) + expect(ost).not.toHaveNodeFor(todo2) + + iterator.next() + + expect(ost).toHaveNodeFor(todo1) + expect(ost).toHaveNodeFor(todo2) + }) + }) + + describe("#Symbol.iterator", () => { + it("returns an iterator that exposes the key and node pair held by the map node", () => { + const todos = new Map() + todos.set(1, { id: 1, content: "Learn Arbor" }) + todos.set(2, { id: 2, content: "Implement OST" }) + + const state = { todos } + const ost = new OST(state) + + const iterator = ost.root.todos[Symbol.iterator]() + + expect(iterator.next().value).toEqual([1, ost.root.todos.get(1)]) + expect(iterator.next().value).toEqual([2, ost.root.todos.get(2)]) + expect(iterator.next().done).toBe(true) + }) + + it("lazily creates nodes for entries accessed", () => { + const todos = new Map() + const todo1 = { id: 1, content: "Learn Arbor" } + todos.set(1, todo1) + const todo2 = { id: 2, content: "Implement OST" } + todos.set(2, todo2) + + const state = { todos } + const ost = new OST(state) + + const iterator = ost.root.todos[Symbol.iterator]() + + expect(ost).not.toHaveNodeFor(todo1) + expect(ost).not.toHaveNodeFor(todo2) + + iterator.next() + + expect(ost).toHaveNodeFor(todo1) + expect(ost).not.toHaveNodeFor(todo2) + + iterator.next() + + expect(ost).toHaveNodeFor(todo1) + expect(ost).toHaveNodeFor(todo2) + }) + }) + + describe("custom @node maps", () => { + @node + class MyMap extends Map { + get first() { + return this.values().next().value + } + + get last() { + let lastTodo = null + + for (const todo of this.values()) { + lastTodo = todo + } + + return lastTodo + } + + deleteLast() { + this.delete(this.last.id) + } + } + + it("supports custom Map types when decorated with @node", () => { + const todos = new MyMap() + todos.set(1, { id: 1, content: "Learn Arbor" }) + todos.set(2, { id: 2, content: "Implement OST" }) + + const state = { todos } + const ost = new OST(state) + + expect(ost).not.toHaveNodeFor(todos.get(1)) + expect(ost).not.toHaveNodeFor(todos.get(2)) + + const iterator = ost.root.todos[Symbol.iterator]() + + iterator.next() + + expect(ost).toHaveNodeFor(todos.get(1)) + expect(ost).not.toHaveNodeFor(todos.get(2)) + + iterator.next() + + expect(ost).toHaveNodeFor(todos.get(1)) + expect(ost).toHaveNodeFor(todos.get(2)) + + expect(ost.root.todos.first).toBe(ost.root.todos.get(1)) + }) + + it("executes custom methods within the context of the proxy", () => { + const todos = new MyMap() + todos.set(1, { id: 1, content: "Learn Arbor" }) + todos.set(2, { id: 2, content: "Implement OST" }) + + const state = { todos } + const ost = new OST(state) + + ost.root.todos.deleteLast() + + expect(ost.root.todos.size).toBe(1) + expect(ost.root.todos.last).toBe(ost.root.todos.get(1)) + }) + }) + + it("handles mutations to items within the map", () => { + const ost = new OST( + new Map([ + [0, { a: 1, b: 2 }], + [1, { a: 2, b: 3 }], + ]) + ) + + const subscriber = vi.fn() + ost.subscribe(subscriber) + + ost.root.get(0).a = 2 + ost.root.get(0).a = 1 + + expect(subscriber).toHaveBeenCalledTimes(2) + }) +}) diff --git a/packages/arbor-ost/tests/handlers/$object.test.ts b/packages/arbor-ost/tests/handlers/$object.test.ts new file mode 100644 index 00000000..45c3f4cf --- /dev/null +++ b/packages/arbor-ost/tests/handlers/$object.test.ts @@ -0,0 +1,569 @@ +import { describe, expect, it, vi } from "vitest" + +import { $ } from "../../src/types" +import { OST } from "../../src/ost" +import { node } from "../../src/decorators/node" +import { DetachedPathError } from "../../src/errors" +import { detached } from "../../src/decorators/detached" + +describe("$object", () => { + describe("get trap", () => { + it("lazily creates nodes in the OST as parts of the state are accessed", () => { + const state = { + todos: [ + { id: 1, content: "Learn Arbor", author: { name: "Alice" } }, + { id: 2, content: "Implement OST", author: { name: "Bob" } }, + ], + } + + const ost = new OST() + + expect(ost).not.toHaveNodeFor(state) + expect(ost).not.toHaveNodeFor(state.todos) + expect(ost).not.toHaveNodeFor(state.todos[0]) + expect(ost).not.toHaveNodeFor(state.todos[0].author) + expect(ost).not.toHaveNodeFor(state.todos[1]) + expect(ost).not.toHaveNodeFor(state.todos[1].author) + + const $root = ost.createNode(state) + + expect(ost).toHaveNodeFor(state) + expect(ost).not.toHaveNodeFor(state.todos) + expect(ost).not.toHaveNodeFor(state.todos[0]) + expect(ost).not.toHaveNodeFor(state.todos[0].author) + expect(ost).not.toHaveNodeFor(state.todos[1]) + expect(ost).not.toHaveNodeFor(state.todos[1].author) + + $root.todos + + expect(ost).toHaveNodeFor(state) + expect(ost).toHaveNodeFor(state.todos) + expect(ost).not.toHaveNodeFor(state.todos[0]) + expect(ost).not.toHaveNodeFor(state.todos[0].author) + expect(ost).not.toHaveNodeFor(state.todos[1]) + expect(ost).not.toHaveNodeFor(state.todos[1].author) + + $root.todos[0] + + expect(ost).toHaveNodeFor(state) + expect(ost).toHaveNodeFor(state.todos) + expect(ost).toHaveNodeFor(state.todos[0]) + expect(ost).not.toHaveNodeFor(state.todos[0].author) + expect(ost).not.toHaveNodeFor(state.todos[1]) + expect(ost).not.toHaveNodeFor(state.todos[1].author) + + $root.todos[0].author + + expect(ost).toHaveNodeFor(state) + expect(ost).toHaveNodeFor(state.todos) + expect(ost).toHaveNodeFor(state.todos[0]) + expect(ost).toHaveNodeFor(state.todos[0].author) + expect(ost).not.toHaveNodeFor(state.todos[1]) + expect(ost).not.toHaveNodeFor(state.todos[1].author) + + $root.todos[1] + + expect(ost).toHaveNodeFor(state) + expect(ost).toHaveNodeFor(state.todos) + expect(ost).toHaveNodeFor(state.todos[0]) + expect(ost).toHaveNodeFor(state.todos[0].author) + expect(ost).toHaveNodeFor(state.todos[1]) + expect(ost).not.toHaveNodeFor(state.todos[1].author) + + $root.todos[1].author + + expect(ost).toHaveNodeFor(state) + expect(ost).toHaveNodeFor(state.todos) + expect(ost).toHaveNodeFor(state.todos[0]) + expect(ost).toHaveNodeFor(state.todos[0].author) + expect(ost).toHaveNodeFor(state.todos[1]) + expect(ost).toHaveNodeFor(state.todos[1].author) + }) + + it("caches nodes when accessing the same path more than once", () => { + const state = { + todos: [ + { id: 1, content: "Learn Arbor" }, + { id: 2, content: "Implement OST" }, + ], + } + + const ost = new OST() + const $root = ost.createNode(state) + const $todos = $root.todos + const $todo1 = $root.todos[0] + const $todo2 = $root.todos[1] + + expect($root).toBe(ost.root) + expect($todos).toBe(ost.root.todos) + expect($todo1).toBe(ost.root.todos[0]) + expect($todo2).toBe(ost.root.todos[1]) + }) + + it("binds this in methods to the corresponding OST node", () => { + const state = { + completed() { + return this.todos.filter((t) => t.done) + }, + todos: [ + { + id: 1, + content: "Learn Arbor", + done: true, + }, + { + id: 2, + content: "Implement OST", + done: false, + }, + ], + } + + const ost = new OST() + const $root = ost.createNode(state) + const $firstTodo = $root.todos[0] + + const completedTodos = $root.completed() + + expect(completedTodos.length).toBe(1) + expect(completedTodos[0]).toBe($firstTodo) + }) + + it("executes getters within the context of the proxy", () => { + const state = { + get lastTodo() { + return this.todos.at(-1) + }, + todos: [ + { + id: 1, + content: "Learn Arbor", + done: true, + }, + { + id: 2, + content: "Implement OST", + done: false, + }, + ], + } + + const todo2 = state.todos[1] + const ost = new OST(state) + + expect(ost.root.lastTodo).toBe(ost.nodeOf(todo2)) + }) + + it("allows using classes decorated with @node as nodes in the OST", () => { + @node + class Todo { + constructor(public id: number, public content: string) {} + } + + @node + class Todos extends Array {} + + const todo1 = new Todo(1, "Learn Arbor") + const todo2 = new Todo(2, "Implement OST") + const todos = new Todos(todo1, todo2) + + const ost = new OST({ + todos, + }) + + expect(ost.root.todos).toBeInstanceOf(Todos) + expect(ost.root.todos).toBe(ost.nodeOf(todos)) + expect(ost.root.todos[0]).toBeInstanceOf(Todo) + expect(ost.root.todos[0]).toBe(ost.nodeOf(todo1)) + expect(ost.root.todos[1]).toBeInstanceOf(Todo) + expect(ost.root.todos[1]).toBe(ost.nodeOf(todo2)) + }) + + it("binds class methods to the proxy itself", () => { + @node + class Todo { + constructor(public id: number, public content: string) {} + } + + @node + class Todos extends Array { + getLast() { + return this.at(-1) + } + } + + const todo1 = new Todo(1, "Learn Arbor") + const todo2 = new Todo(2, "Implement OST") + + const ost = new OST({ + todos: new Todos(todo1, todo2), + }) + + expect(ost.root.todos.getLast()).toBe(ost.nodeOf(todo2)) + }) + + it("runs mutations triggered by methods within the context of an OST node", () => { + @node + class Todo { + done = false + + constructor(public content: string) {} + + complete() { + this.done = true + } + } + + @node + class Todos extends Array { + get incomplete() { + return this.filter((t) => !t.done) + } + } + + const ost = new OST({ + todos: new Todos(new Todo("Learn Arbor"), new Todo("Implement OST")), + }) + + const subscriber = vi.fn() + ost.subscribe(subscriber) + + const incomplete = ost.root.todos.incomplete + + expect(incomplete[0]).toBe(ost.root.todos[0]) + expect(incomplete[1]).toBe(ost.root.todos[1]) + + incomplete[0].complete() + incomplete[1].complete() + + expect(ost).not.toHaveNodeFor(incomplete) + expect(subscriber).toHaveBeenCalledTimes(2) + }) + + it("does not create nodes for detached object props", () => { + @node + class Todo { + constructor(public id: number, public content: string) {} + } + + @node + class TodosApp { + @detached todo: Todo + } + + const todo = new Todo(1, "Learn Arbor") + const ost = new OST(new TodosApp()) + ost.root.todo = todo as $ + + expect(ost.root.todo).toBe(todo) + expect(ost.root.todo).not.toBe(ost.nodeOf(todo)) + }) + }) + + describe("set trap", () => { + it("mutates underlying values correctly", () => { + const state = { + todos: [ + { + id: 1, + content: "Learn Arbor", + done: false, + complete() { + this.done = true + }, + }, + { + id: 2, + content: "Implement OST", + done: false, + complete() { + this.done = true + }, + }, + ], + } + + const ost = new OST(state) + + ost.root.todos[0].complete() + + expect(state.todos[0].done).toBe(true) + expect(state.todos[1].done).toBe(false) + }) + + it("notifies subscribers when a node is mutated", () => { + const state = { + todos: [ + { + id: 1, + content: "Learn Arbor", + done: false, + complete() { + this.done = true + }, + }, + { + id: 2, + content: "Implement OST", + done: false, + complete() { + this.done = true + }, + }, + ], + } + + const subscriber = vi.fn() + const ost = new OST() + const $root = ost.createNode(state) + $root.$subscriptions.subscribe(subscriber) + + $root.todos[0].complete() + + expect(subscriber).toHaveBeenCalledTimes(1) + }) + + it("does not notify subscribers when updating a detached property", () => { + @node + class Counter { + @detached count = 0 + } + + const subscriber = vi.fn() + const ost = new OST(new Counter()) + ost.subscribe(subscriber) + + ost.root.count++ + + expect(subscriber).not.toHaveBeenCalled() + }) + + it("exposes mutation event metadata to subscribers", async () => { + const state = { + todos: [ + { + id: 1, + content: "Learn Arbor", + done: false, + complete() { + this.done = true + }, + }, + { + id: 2, + content: "Implement OST", + done: false, + complete() { + this.done = true + }, + }, + ], + } + + const ost = new OST(state) + + return new Promise((resolve) => { + ost.root.$subscriptions.subscribe((event) => { + expect(event.target).toBe(ost.root.todos[0]) + expect(event.metadata.args).toEqual(["done", true]) + expect(event.metadata.operation).toEqual("set") + resolve(true) + }) + + ost.root.todos[0].complete() + }) + }) + + it("throws DetachedPathError when operating on a detached node", () => { + const ost = new OST({ + todos: [ + { id: 1, content: "Learn Arbor" }, + { id: 2, content: "Implement OST" }, + ], + }) + + const $todo1 = ost.root.todos[0] + + delete ost.root.todos[0] + + expect(() => { + $todo1.content = "Learn Arbor OST" + }).toThrow(DetachedPathError) + }) + }) + + describe("delete trap", () => { + it("deletes properties of node correctly", () => { + const state: { content: string; authorName?: string }[] = [ + { content: "Learn Arbor", authorName: "Alice" }, + { content: "Implement OST", authorName: "Bob" }, + ] + + const ost = new OST(state) + + delete ost.root[0].authorName + + expect(state).toEqual([ + { content: "Learn Arbor" }, + { content: "Implement OST", authorName: "Bob" }, + ]) + }) + + it("notifies subscribers about the deletion", () => { + const state: { content: string; authorName?: string }[] = [ + { content: "Learn Arbor", authorName: "Alice" }, + { content: "Implement OST", authorName: "Bob" }, + ] + + const ost = new OST(state) + + const subscriber1 = vi.fn() + const subscriber2 = vi.fn() + const subscriber3 = vi.fn() + + ost.root.$subscriptions.subscribe(subscriber1) + ost.root[0].$subscriptions.subscribe(subscriber2) + ost.root[1].$subscriptions.subscribe(subscriber3) + + delete ost.root[0].authorName + + expect(subscriber1).toHaveBeenCalledOnce() + expect(subscriber2).toHaveBeenCalledOnce() + expect(subscriber3).not.toHaveBeenCalled() + }) + + it("exposes mutation event metadata to subscribers", async () => { + const state: { content: string; authorName?: string }[] = [ + { content: "Learn Arbor", authorName: "Alice" }, + { content: "Implement OST", authorName: "Bob" }, + ] + + const ost = new OST(state) + + return new Promise((resolve) => { + ost.root.$subscriptions.subscribe((event) => { + expect(event.target).toBe(ost.root[0]) + expect(event.metadata.args).toEqual(["authorName"]) + expect(event.metadata.operation).toEqual("delete") + resolve(true) + }) + + delete ost.root[0].authorName + }) + }) + + it("does not notify subscribers when deleting a detached property", () => { + @node + class Counter { + @detached count?: number = 0 + } + + const subscriber = vi.fn() + const ost = new OST(new Counter()) + ost.subscribe(subscriber) + + delete ost.root.count + + expect(subscriber).not.toHaveBeenCalled() + expect(ost.root.count).toBeUndefined() + }) + + it("throws a DetachedPathError when mutating a detached node", () => { + const state: { content: string; authorName?: string }[] = [ + { content: "Learn Arbor", authorName: "Alice" }, + { content: "Implement OST", authorName: "Bob" }, + ] + + const ost = new OST(state) + + const $todo1 = ost.root[0] + + delete ost.root[0] + + expect(() => { + delete $todo1.authorName + }).toThrow(DetachedPathError) + }) + }) + + describe("#$children", () => { + it("does not create nodes for state values that have not been accessed yet", () => { + const state = { + todos: [ + { id: 1, content: "Learn Arbor" }, + { id: 2, content: "Implement OST" }, + ], + } + + const ost = new OST(state) + + const children1 = Array.from(ost.root.todos.$children()) + expect(children1.length).toBe(0) + + ost.root.todos[0] // force node creation for todo #1 + + const children2 = Array.from(ost.root.todos.$children()) + expect(children2.length).toBe(1) + expect(children2[0].$value).toBe(state.todos[0]) + expect(children2[0]).toBe(ost.nodeOf(state.todos[0])) + + ost.root.todos[1] // force node creation for todo #2 + + const children3 = Array.from(ost.root.todos.$children()) + expect(children3.length).toBe(2) + expect(children3[0].$value).toBe(state.todos[0]) + expect(children3[0]).toBe(ost.nodeOf(state.todos[0])) + expect(children3[1].$value).toBe(state.todos[1]) + expect(children3[1]).toBe(ost.nodeOf(state.todos[1])) + }) + }) + + describe("Symbol.toStringTag", () => { + it("returns the string representation of the object node", () => { + const ost = new OST([{ content: "Learn Arbor", authorName: "Alice" }]) + + const $node1 = ost.root + const $node2 = ost.root[0] + + expect($node1[Symbol.toStringTag]).toBe( + `ArborNode` + ) + expect($node2[Symbol.toStringTag]).toBe( + `ArborNode` + ) + }) + + it("handle custom types", () => { + @node + class Todo { + constructor(public content: string) {} + } + + @node + class Todos extends Array {} + + const ost = new OST(new Todos(new Todo("Learn Arbor"))) + + const $node1 = ost.root + const $node2 = ost.root[0] + + expect($node1[Symbol.toStringTag]).toBe( + `ArborNode` + ) + expect($node2[Symbol.toStringTag]).toBe( + `ArborNode` + ) + }) + }) + + describe("Object.values", () => { + it("exposes OST nodes rather than underlying values", () => { + const ost = new OST({ a: { c: 1 }, b: { c: 2 } }) + + const values = Object.values(ost.root) + + expect(values[0]).toBe(ost.root.a) + expect(values[1]).toBe(ost.root.b) + }) + }) +}) diff --git a/packages/arbor-ost/tests/handlers/$set.test.ts b/packages/arbor-ost/tests/handlers/$set.test.ts new file mode 100644 index 00000000..64ecf734 --- /dev/null +++ b/packages/arbor-ost/tests/handlers/$set.test.ts @@ -0,0 +1,875 @@ +import { describe, expect, it, vi } from "vitest" + +import { OST } from "../../src/ost" +import { node } from "../../src/decorators/node" + +describe("$set", () => { + describe("#add", () => { + it("mutates the underlying value", () => { + const todos = new Set() + const newTodoValue = { id: 3, content: "Learn LLM" } + const state = { todos } + const ost = new OST(state) + + ost.root.todos.add(newTodoValue) + + expect(ost.root.$value).toBe(state) + expect(ost.root.todos.$value).toBe(todos) + expect(ost.root.todos.size).toEqual(1) + expect(ost.root.todos.has(newTodoValue)).toBe(true) + + const iterator = ost.root.todos.values() + const todoNode = iterator.next().value + + expect(todoNode).toBe(ost.nodeOf(newTodoValue)) + expect(ost.root.todos.has(todoNode)).toBe(true) + }) + + it("can store non-proxiable values", () => { + const todos = new Set() + const ost = new OST({ todos }) + + ost.root.todos.add("Learn LLM") + + expect(ost.root.todos.size).toEqual(1) + expect(ost.root.todos.has("Learn LLM")).toBe(true) + }) + + it("notifies subscribers of a new item in the array", () => { + const todos = new Set() + const ost = new OST({ todos }) + const newTodoValue = { id: 3, content: "Learn LLM" } + const subscriber = vi.fn() + + ost.subscribe(subscriber) + ost.root.todos.add(newTodoValue) + + expect(subscriber).toHaveBeenCalledOnce() + }) + + it("exposes mutation event metadata to subscribers", async () => { + const todos = new Set() + const ost = new OST({ todos }) + const subscriber = vi.fn() + + ost.subscribe(subscriber) + + return new Promise((resolve) => { + const newTodoValue = { id: 3, content: "Learn LLM" } + + ost.subscribe((event) => { + expect(event.target).toBe(ost.root.todos) + expect(event.metadata.args).toEqual([newTodoValue]) + expect(event.metadata.operation).toEqual("add") + resolve(true) + }) + + ost.root.todos.add(newTodoValue) + }) + }) + }) + + describe("#delete", () => { + it("mutates the underlying value", () => { + const todo1 = { id: 1, content: "Learn Arbor" } + const todo2 = { id: 2, content: "Implement OST" } + const todos = new Set() + todos.add(todo1) + todos.add(todo2) + + const state = { todos } + const ost = new OST(state) + + ost.root.todos.delete(todo2) + + expect(ost.root.todos.size).toEqual(1) + expect(ost.root.todos.has(todo2)).toBe(false) + expect(ost.root.todos.has(todo1)).toBe(true) + }) + + it("notifies subscribers of a deleted item", () => { + const todo1 = { id: 1, content: "Learn Arbor" } + const todo2 = { id: 2, content: "Implement OST" } + const todos = new Set() + todos.add(todo1) + todos.add(todo2) + + const state = { todos } + const ost = new OST(state) + const subscriber = vi.fn() + + ost.subscribe(subscriber) + ost.root.todos.delete(todo2) + + expect(subscriber).toHaveBeenCalledOnce() + }) + + it("does not notify subscribers if deleted value does not exist in the set", () => { + const todo1 = { id: 1, content: "Learn Arbor" } + const todo2 = { id: 2, content: "Implement OST" } + const todos = new Set() + todos.add(todo1) + todos.add(todo2) + + const state = { todos } + const ost = new OST(state) + const subscriber = vi.fn() + + ost.subscribe(subscriber) + ost.root.todos.delete({ id: 3, content: "Not in set" }) + + expect(subscriber).not.toHaveBeenCalledOnce() + }) + + it("exposes mutation event metadata to subscribers", async () => { + const todo1 = { id: 1, content: "Learn Arbor" } + const todo2 = { id: 2, content: "Implement OST" } + const todos = new Set() + todos.add(todo1) + todos.add(todo2) + + const state = { todos } + const ost = new OST(state) + + return new Promise((resolve) => { + ost.subscribe((event) => { + expect(event.target).toBe(ost.root.todos) + expect(event.metadata.args).toEqual([todo2]) + expect(event.metadata.operation).toEqual("delete") + resolve(true) + }) + + ost.root.todos.delete(todo2) + }) + }) + }) + + describe("#clear", () => { + it("mutates the underlying value", () => { + const todo1 = { id: 1, content: "Learn Arbor" } + const todo2 = { id: 2, content: "Implement OST" } + const todos = new Set() + todos.add(todo1) + todos.add(todo2) + + const state = { todos } + const ost = new OST(state) + + ost.root.todos.clear() + + expect(ost.root.todos.size).toEqual(0) + expect(ost.root.todos.has(todo1)).toBe(false) + expect(ost.root.todos.has(todo2)).toBe(false) + }) + + it("notifies subscribers of clear operation", () => { + const todo1 = { id: 1, content: "Learn Arbor" } + const todo2 = { id: 2, content: "Implement OST" } + const todos = new Set() + todos.add(todo1) + todos.add(todo2) + + const state = { todos } + const ost = new OST(state) + const subscriber = vi.fn() + + ost.subscribe(subscriber) + ost.root.todos.clear() + + expect(subscriber).toHaveBeenCalledOnce() + }) + + it("does not notify subscribers if set is already empty", () => { + const todos = new Set() + + const state = { todos } + const ost = new OST(state) + const subscriber = vi.fn() + + ost.subscribe(subscriber) + ost.root.todos.clear() + + expect(subscriber).not.toHaveBeenCalledOnce() + }) + + it("exposes mutation event metadata to subscribers", async () => { + const todo1 = { id: 1, content: "Learn Arbor" } + const todo2 = { id: 2, content: "Implement OST" } + const todos = new Set() + todos.add(todo1) + todos.add(todo2) + + const state = { todos } + const ost = new OST(state) + + return new Promise((resolve) => { + ost.subscribe((event) => { + expect(event.target).toBe(ost.root.todos) + expect(event.metadata.args).toEqual([]) + expect(event.metadata.operation).toEqual("clear") + resolve(true) + }) + + ost.root.todos.clear() + }) + }) + }) + + describe("#has", () => { + it("returns true if the given value exists in the set", () => { + const todo1 = { id: 1, content: "Learn Arbor" } + const todo2 = { id: 2, content: "Implement OST" } + const todos = new Set() + todos.add(todo1) + todos.add(todo2) + + const state = { todos } + const ost = new OST(state) + + expect(ost.root.todos.has(todo1)).toBe(true) + expect(ost.root.todos.has(todo2)).toBe(true) + expect(ost.root.todos.has({ id: 3, content: "Not in set" })).toBe(false) + }) + }) + + describe("#entries", () => { + it("returns an iterator that exposes the value and value pair (as per Set semantics)", () => { + const todo1 = { id: 1, content: "Learn Arbor" } + const todo2 = { id: 2, content: "Implement OST" } + const todos = new Set() + todos.add(todo1) + todos.add(todo2) + + const state = { todos } + const ost = new OST(state) + + const iterator = ost.root.todos.entries() + const entries = [] + + let result = iterator.next() + while (!result.done) { + entries.push(result.value) + result = iterator.next() + } + + expect(entries.length).toBe(2) + expect(entries[0][0]).toBe(entries[0][1]) // Set entries have same value for key and value + expect(entries[1][0]).toBe(entries[1][1]) + expect(entries[0][0].$value).toBe(todo1) + expect(entries[1][0].$value).toBe(todo2) + }) + + it("lazily creates nodes for entries accessed", () => { + const todo1 = { id: 1, content: "Learn Arbor" } + const todo2 = { id: 2, content: "Implement OST" } + const todos = new Set() + todos.add(todo1) + todos.add(todo2) + + const state = { todos } + const ost = new OST(state) + + const iterator = ost.root.todos.entries() + + expect(ost).not.toHaveNodeFor(todo1) + expect(ost).not.toHaveNodeFor(todo2) + + iterator.next() + + expect(ost).toHaveNodeFor(todo1) + expect(ost).not.toHaveNodeFor(todo2) + + iterator.next() + + expect(ost).toHaveNodeFor(todo1) + expect(ost).toHaveNodeFor(todo2) + }) + }) + + describe("#forEach", () => { + it("iterates over the values of the set", () => { + const todo1 = { id: 1, content: "Learn Arbor" } + const todo2 = { id: 2, content: "Implement OST" } + const todos = new Set() + todos.add(todo1) + todos.add(todo2) + + const state = { todos } + const ost = new OST(state) + + const values = [] + ost.root.todos.forEach((value1, value2, set) => { + values.push({ value1, value2 }) + expect(value1).toBe(value2) // Set forEach passes same value twice + expect(set).toBe(ost.root.todos) + }) + + expect(values.length).toBe(2) + expect(values[0].value1.$value).toBe(todo1) + expect(values[1].value1.$value).toBe(todo2) + }) + }) + + describe("#keys", () => { + it("returns an iterator over the values of the set (same as values for Set)", () => { + const todo1 = { id: 1, content: "Learn Arbor" } + const todo2 = { id: 2, content: "Implement OST" } + const todos = new Set() + todos.add(todo1) + todos.add(todo2) + + const state = { todos } + const ost = new OST(state) + + const iterator = ost.root.todos.keys() + const keys = [] + + let result = iterator.next() + while (!result.done) { + keys.push(result.value) + result = iterator.next() + } + + expect(keys.length).toBe(2) + expect(keys[0].$value).toBe(todo1) + expect(keys[1].$value).toBe(todo2) + }) + + it("lazily creates nodes for entries accessed", () => { + const todo1 = { id: 1, content: "Learn Arbor" } + const todo2 = { id: 2, content: "Implement OST" } + const todos = new Set() + todos.add(todo1) + todos.add(todo2) + + const state = { todos } + const ost = new OST(state) + + const iterator = ost.root.todos.keys() + + expect(ost).not.toHaveNodeFor(todo1) + expect(ost).not.toHaveNodeFor(todo2) + + iterator.next() + + expect(ost).toHaveNodeFor(todo1) + expect(ost).not.toHaveNodeFor(todo2) + + iterator.next() + + expect(ost).toHaveNodeFor(todo1) + expect(ost).toHaveNodeFor(todo2) + }) + }) + + describe("#values", () => { + it("returns an iterator over the node values of the set", () => { + const todo1 = { id: 1, content: "Learn Arbor" } + const todo2 = { id: 2, content: "Implement OST" } + const todos = new Set() + todos.add(todo1) + todos.add(todo2) + + const state = { todos } + const ost = new OST(state) + + const iterator = ost.root.todos.values() + const values = [] + + let result = iterator.next() + while (!result.done) { + values.push(result.value) + result = iterator.next() + } + + expect(values.length).toBe(2) + expect(values[0].$value).toBe(todo1) + expect(values[1].$value).toBe(todo2) + }) + + it("lazily creates nodes for entries accessed", () => { + const todo1 = { id: 1, content: "Learn Arbor" } + const todo2 = { id: 2, content: "Implement OST" } + const todos = new Set() + todos.add(todo1) + todos.add(todo2) + + const state = { todos } + const ost = new OST(state) + + const iterator = ost.root.todos.values() + + expect(ost).not.toHaveNodeFor(todo1) + expect(ost).not.toHaveNodeFor(todo2) + + iterator.next() + + expect(ost).toHaveNodeFor(todo1) + expect(ost).not.toHaveNodeFor(todo2) + + iterator.next() + + expect(ost).toHaveNodeFor(todo1) + expect(ost).toHaveNodeFor(todo2) + }) + }) + + describe("#Symbol.iterator", () => { + it("returns an iterator that exposes the value and value pair (as per Set semantics)", () => { + const todo1 = { id: 1, content: "Learn Arbor" } + const todo2 = { id: 2, content: "Implement OST" } + const todos = new Set() + todos.add(todo1) + todos.add(todo2) + + const state = { todos } + const ost = new OST(state) + + const iterator = ost.root.todos[Symbol.iterator]() + const entries = [] + + let result = iterator.next() + while (!result.done) { + entries.push(result.value) + result = iterator.next() + } + + expect(entries.length).toBe(2) + expect(entries[0].$value).toBe(todo1) + expect(entries[1].$value).toBe(todo2) + }) + + it("lazily creates nodes for entries accessed", () => { + const todo1 = { id: 1, content: "Learn Arbor" } + const todo2 = { id: 2, content: "Implement OST" } + const todos = new Set() + todos.add(todo1) + todos.add(todo2) + + const state = { todos } + const ost = new OST(state) + + const iterator = ost.root.todos[Symbol.iterator]() + + expect(ost).not.toHaveNodeFor(todo1) + expect(ost).not.toHaveNodeFor(todo2) + + iterator.next() + + expect(ost).toHaveNodeFor(todo1) + expect(ost).not.toHaveNodeFor(todo2) + + iterator.next() + + expect(ost).toHaveNodeFor(todo1) + expect(ost).toHaveNodeFor(todo2) + }) + }) + + describe("custom @node sets", () => { + @node + class MySet extends Set { + get first() { + return this.values().next().value + } + + get last() { + let lastTodo = null + + for (const todo of this.values()) { + lastTodo = todo + } + + return lastTodo + } + + deleteLast() { + this.delete(this.last) + } + } + + it("supports custom Set types when decorated with @node", () => { + const todo1 = { id: 1, content: "Learn Arbor" } + const todo2 = { id: 2, content: "Implement OST" } + const todos = new MySet() + todos.add(todo1) + todos.add(todo2) + + const state = { todos } + const ost = new OST(state) + + expect(ost).not.toHaveNodeFor(todo1) + expect(ost).not.toHaveNodeFor(todo2) + + const iterator = ost.root.todos[Symbol.iterator]() + + iterator.next() + + expect(ost).toHaveNodeFor(todo1) + expect(ost).not.toHaveNodeFor(todo2) + + iterator.next() + + expect(ost).toHaveNodeFor(todo1) + expect(ost).toHaveNodeFor(todo2) + + expect(ost.root.todos.first.$value).toBe(todo1) + }) + + it("executes custom methods within the context of the proxy", () => { + const todo1 = { id: 1, content: "Learn Arbor" } + const todo2 = { id: 2, content: "Implement OST" } + const todos = new MySet() + todos.add(todo1) + todos.add(todo2) + + const state = { todos } + const ost = new OST(state) + + ost.root.todos.deleteLast() + + expect(ost.root.todos.size).toBe(1) + expect(ost.root.todos.has(todo2)).toBe(false) + expect(ost.root.todos.has(todo1)).toBe(true) + }) + }) + + describe("Set composition methods", () => { + describe("#difference", () => { + it("returns a new set with elements in this set but not in the given set", () => { + const setA = new Set([1, 2, 3, 4]) + const setB = new Set([3, 4, 5, 6]) + const state = { setA, setB } + const ost = new OST(state) + + const result = ost.root.setA.difference(ost.root.setB) + + expect(result).toBeInstanceOf(Set) + expect([...result]).toEqual([1, 2]) + }) + + it("wraps proxiable values in OST nodes", () => { + const obj1 = { id: 1, name: "first" } + const obj2 = { id: 2, name: "second" } + const obj3 = { id: 3, name: "third" } + const setA = new Set([obj1, obj2, obj3]) + const setB = new Set([obj2]) + const state = { setA, setB } + const ost = new OST(state) + + const result = ost.root.setA.difference(ost.root.setB) + + expect(result).toBeInstanceOf(Set) + expect(result.size).toBe(2) + + const resultArray = [...result] + expect(ost).toHaveNodeValuePair([resultArray[0], obj1]) + expect(ost).toHaveNodeValuePair([resultArray[1], obj3]) + }) + }) + + describe("#intersection", () => { + it("returns a new set with elements in both sets", () => { + const setA = new Set([1, 2, 3, 4]) + const setB = new Set([3, 4, 5, 6]) + const state = { setA, setB } + const ost = new OST(state) + + const result = ost.root.setA.intersection(ost.root.setB) + + expect(result).toBeInstanceOf(Set) + expect([...result]).toEqual([3, 4]) + }) + + it("wraps proxiable values in OST nodes", () => { + const obj1 = { id: 1, name: "first" } + const obj2 = { id: 2, name: "second" } + const obj3 = { id: 3, name: "third" } + const setA = new Set([obj1, obj2]) + const setB = new Set([obj2, obj3]) + const state = { setA, setB } + const ost = new OST(state) + + const result = ost.root.setA.intersection(ost.root.setB) + + expect(result).toBeInstanceOf(Set) + expect(result.size).toBe(1) + + const resultArray = [...result] + expect(ost).toHaveNodeValuePair([resultArray[0], obj2]) + }) + }) + + describe("#union", () => { + it("returns a new set with elements from both sets", () => { + const setA = new Set([1, 2, 3]) + const setB = new Set([3, 4, 5]) + const state = { setA, setB } + const ost = new OST(state) + + const result = ost.root.setA.union(ost.root.setB) + + expect(result).toBeInstanceOf(Set) + expect([...result].sort()).toEqual([1, 2, 3, 4, 5]) + }) + + it("wraps proxiable values in OST nodes", () => { + const obj1 = { id: 1, name: "first" } + const obj2 = { id: 2, name: "second" } + const obj3 = { id: 3, name: "third" } + const setA = new Set([obj1, obj2]) + const setB = new Set([obj2, obj3]) + const state = { setA, setB } + const ost = new OST(state) + + const result = ost.root.setA.union(ost.root.setB) + + expect(result).toBeInstanceOf(Set) + expect(result.size).toBe(3) + + const resultArray = [...result] + expect(ost).toHaveNodeValuePair([resultArray[0], obj1]) + expect(ost).toHaveNodeValuePair([resultArray[1], obj2]) + expect(ost).toHaveNodeValuePair([resultArray[2], obj3]) + }) + }) + + describe("#symmetricDifference", () => { + it("returns a new set with elements in either set but not both", () => { + const setA = new Set([1, 2, 3]) + const setB = new Set([3, 4, 5]) + const state = { setA, setB } + const ost = new OST(state) + + const result = ost.root.setA.symmetricDifference(ost.root.setB) + + expect(result).toBeInstanceOf(Set) + expect([...result].sort()).toEqual([1, 2, 4, 5]) + }) + + it("wraps proxiable values in OST nodes", () => { + const obj1 = { id: 1, name: "first" } + const obj2 = { id: 2, name: "second" } + const obj3 = { id: 3, name: "third" } + const setA = new Set([obj1, obj2]) + const setB = new Set([obj2, obj3]) + const state = { setA, setB } + const ost = new OST(state) + + const result = ost.root.setA.symmetricDifference(ost.root.setB) + + expect(result).toBeInstanceOf(Set) + expect(result.size).toBe(2) + + const resultArray = [...result] + expect(ost).toHaveNodeValuePair([resultArray[0], obj1]) + expect(ost).toHaveNodeValuePair([resultArray[1], obj3]) + }) + }) + + describe("#isDisjointFrom", () => { + it("returns true if sets have no elements in common", () => { + const setA = new Set([1, 2]) + const setB = new Set([3, 4]) + const setC = new Set([2, 3]) + const state = { setA, setB, setC } + const ost = new OST(state) + + expect(ost.root.setA.isDisjointFrom(ost.root.setB)).toBe(true) + expect(ost.root.setA.isDisjointFrom(ost.root.setC)).toBe(false) + }) + + it("works with proxiable values", () => { + const obj1 = { id: 1, name: "first" } + const obj2 = { id: 2, name: "second" } + const obj3 = { id: 3, name: "third" } + const setA = new Set([obj1]) + const setB = new Set([obj2]) + const setC = new Set([obj1, obj3]) + const state = { setA, setB, setC } + const ost = new OST(state) + + expect(ost.root.setA.isDisjointFrom(ost.root.setB)).toBe(true) + expect(ost.root.setA.isDisjointFrom(ost.root.setC)).toBe(false) + }) + }) + + describe("#isSubsetOf", () => { + it("returns true if all elements of this set are in the given set", () => { + const setA = new Set([1, 2]) + const setB = new Set([1, 2, 3, 4]) + const setC = new Set([1, 5]) + const state = { setA, setB, setC } + const ost = new OST(state) + + expect(ost.root.setA.isSubsetOf(ost.root.setB)).toBe(true) + expect(ost.root.setA.isSubsetOf(ost.root.setC)).toBe(false) + }) + + it("works with proxiable values", () => { + const obj1 = { id: 1, name: "first" } + const obj2 = { id: 2, name: "second" } + const obj3 = { id: 3, name: "third" } + const setA = new Set([obj1, obj2]) + const setB = new Set([obj1, obj2, obj3]) + const setC = new Set([obj1, obj3]) + const state = { setA, setB, setC } + const ost = new OST(state) + + expect(ost.root.setA.isSubsetOf(ost.root.setB)).toBe(true) + expect(ost.root.setA.isSubsetOf(ost.root.setC)).toBe(false) + }) + }) + + describe("#isSupersetOf", () => { + it("returns true if all elements of the given set are in this set", () => { + const setA = new Set([1, 2, 3, 4]) + const setB = new Set([1, 2]) + const setC = new Set([1, 5]) + const state = { setA, setB, setC } + const ost = new OST(state) + + expect(ost.root.setA.isSupersetOf(ost.root.setB)).toBe(true) + expect(ost.root.setA.isSupersetOf(ost.root.setC)).toBe(false) + }) + + it("works with proxiable values", () => { + const obj1 = { id: 1, name: "first" } + const obj2 = { id: 2, name: "second" } + const obj3 = { id: 3, name: "third" } + const setA = new Set([obj1, obj2, obj3]) + const setB = new Set([obj1, obj2]) + const setC = new Set([obj1, { id: 4, name: "fourth" }]) + const state = { setA, setB, setC } + const ost = new OST(state) + + expect(ost.root.setA.isSupersetOf(ost.root.setB)).toBe(true) + expect(ost.root.setA.isSupersetOf(ost.root.setC)).toBe(false) + }) + }) + }) + + describe("Edge cases and comprehensive tests", () => { + it("should handle empty sets in composition methods", () => { + const emptySet = new Set() + const filledSet = new Set([1, 2, 3]) + const state = { emptySet, filledSet } + const ost = new OST(state) + + expect(ost.root.emptySet.difference(ost.root.filledSet).size).toBe(0) + expect(ost.root.filledSet.difference(ost.root.emptySet).size).toBe(3) + expect(ost.root.emptySet.intersection(ost.root.filledSet).size).toBe(0) + expect(ost.root.emptySet.union(ost.root.filledSet).size).toBe(3) + expect( + ost.root.emptySet.symmetricDifference(ost.root.filledSet).size + ).toBe(3) + + expect(ost.root.emptySet.isDisjointFrom(ost.root.filledSet)).toBe(true) + expect(ost.root.emptySet.isSubsetOf(ost.root.filledSet)).toBe(true) + expect(ost.root.filledSet.isSupersetOf(ost.root.emptySet)).toBe(true) + }) + + it("should handle identical sets in composition methods", () => { + const setA = new Set([1, 2, 3]) + const setB = new Set([1, 2, 3]) + const state = { setA, setB } + const ost = new OST(state) + + expect(ost.root.setA.difference(ost.root.setB).size).toBe(0) + expect(ost.root.setA.intersection(ost.root.setB).size).toBe(3) + expect(ost.root.setA.union(ost.root.setB).size).toBe(3) + expect(ost.root.setA.symmetricDifference(ost.root.setB).size).toBe(0) + + expect(ost.root.setA.isDisjointFrom(ost.root.setB)).toBe(false) + expect(ost.root.setA.isSubsetOf(ost.root.setB)).toBe(true) + expect(ost.root.setA.isSupersetOf(ost.root.setB)).toBe(true) + }) + + it("should handle mixed types (proxiable and non-proxiable) in composition methods", () => { + const obj1 = { id: 1, name: "obj1" } + const obj2 = { id: 2, name: "obj2" } + const setA = new Set([obj1, "string1", 42]) + const setB = new Set([obj2, "string1", 99]) + const state = { setA, setB } + const ost = new OST(state) + + const intersection = ost.root.setA.intersection(ost.root.setB) + expect(intersection.size).toBe(1) + expect([...intersection][0]).toBe("string1") + + const union = ost.root.setA.union(ost.root.setB) + expect(union.size).toBe(5) + + const difference = ost.root.setA.difference(ost.root.setB) + const diffArray = [...difference] + + expect(difference.size).toBe(2) + expect(ost).toHaveNodeValuePair([diffArray[0], obj1]) + expect(diffArray[1]).toEqual(42) + }) + + it("should handle Set methods chaining", () => { + const obj1 = { id: 1, name: "first" } + const obj2 = { id: 2, name: "second" } + const todos = new Set() + const state = { todos } + const ost = new OST(state) + + // Test method chaining with add + const result = ost.root.todos.add(obj1).add(obj2) + expect(result).toBe(ost.root.todos) + expect(ost.root.todos.size).toBe(2) + expect(ost.root.todos.has(obj1)).toBe(true) + expect(ost.root.todos.has(obj2)).toBe(true) + }) + + it("should properly handle node caching across composition methods", () => { + const obj1 = { id: 1, name: "first" } + const obj2 = { id: 2, name: "second" } + const setA = new Set([obj1, obj2]) + const setB = new Set([obj2]) + const state = { setA, setB } + const ost = new OST(state) + + expect(ost).not.toHaveNodeFor(obj1) + expect(ost).not.toHaveNodeFor(obj2) + + // Access obj1 through intersection + const intersection = ost.root.setA.intersection(ost.root.setB) + const intersectionArray = [...intersection] + + expect(ost).toHaveNodeFor(obj2) + expect(ost).not.toHaveNodeFor(obj1) + + // Now access obj1 through difference + const difference = ost.root.setA.difference(ost.root.setB) + const differenceArray = [...difference] + + expect(ost).toHaveNodeFor(obj1) + expect(ost).toHaveNodeFor(obj2) + + // Verify same node instances are returned + expect(intersectionArray[0]).toBe(ost.nodeOf(obj2)) + expect(differenceArray[0]).toBe(ost.nodeOf(obj1)) + }) + }) + + it("handles mutations to items within the map", () => { + const ost = new OST( + new Set([ + { a: 1, b: 2 }, + { a: 2, b: 3 }, + ]) + ) + + const subscriber = vi.fn() + ost.subscribe(subscriber) + + const iterator = ost.root.values() + const item = iterator.next().value + item.a = 3 + item.b = 4 + + expect(subscriber).toHaveBeenCalledTimes(2) + }) +}) diff --git a/packages/arbor-ost/tests/handlers/$weakMap.test.ts b/packages/arbor-ost/tests/handlers/$weakMap.test.ts new file mode 100644 index 00000000..9e0960ae --- /dev/null +++ b/packages/arbor-ost/tests/handlers/$weakMap.test.ts @@ -0,0 +1,483 @@ +import { describe, expect, it, vi } from "vitest" + +import { OST } from "../../src/ost" +import { node } from "../../src/decorators/node" + +describe("$weakMap", () => { + describe("#set", () => { + it("mutates the underlying value", () => { + const key1 = { id: 1 } + const key2 = { id: 2 } + const todos = new WeakMap() + todos.set(key1, { id: 1, content: "Learn Arbor" }) + todos.set(key2, { id: 2, content: "Implement OST" }) + + const newKey = { id: 3 } + const newTodoValue = { id: 3, content: "Learn LLM" } + const state = { todos } + const ost = new OST(state) + + ost.root.todos.set(newKey, newTodoValue) + + expect(ost.root.todos.has(newKey)).toBe(true) + expect(ost.root.todos.get(newKey).$value).toBe(newTodoValue) + }) + + it("can store non-proxiable values", () => { + const key1 = { id: 1 } + const key2 = { id: 2 } + const todos = new WeakMap() + todos.set(key1, "Learn Arbor") + todos.set(key2, "Implement OST") + + const newKey = { id: 3 } + const newTodoValue = "Learn LLM" + const state = { todos } + const ost = new OST(state) + + ost.root.todos.set(newKey, newTodoValue) + + expect(ost.root.todos.has(newKey)).toBe(true) + expect(ost.root.todos.get(newKey)).toBe(newTodoValue) + }) + + it("notifies subscribers of a new item in the weakmap", () => { + const key1 = { id: 1 } + const key2 = { id: 2 } + const todos = new WeakMap() + todos.set(key1, { id: 1, content: "Learn Arbor" }) + todos.set(key2, { id: 2, content: "Implement OST" }) + + const newKey = { id: 3 } + const newTodoValue = { id: 3, content: "Learn LLM" } + const state = { todos } + const ost = new OST(state) + const subscriber = vi.fn() + + ost.subscribe(subscriber) + + ost.root.todos.set(newKey, newTodoValue) + + expect(subscriber).toHaveBeenCalledOnce() + }) + + it("exposes mutation event metadata to subscribers", () => { + const key1 = { id: 1 } + const key2 = { id: 2 } + const todos = new WeakMap() + todos.set(key1, { id: 1, content: "Learn Arbor" }) + todos.set(key2, { id: 2, content: "Implement OST" }) + + const newKey = { id: 3 } + const newTodoValue = { id: 3, content: "Learn LLM" } + const state = { todos } + const ost = new OST(state) + const subscriber = vi.fn() + + ost.subscribe(subscriber) + + ost.root.todos.set(newKey, newTodoValue) + + expect(subscriber).toHaveBeenCalledWith({ + target: ost.root.todos, + metadata: { + operation: "set", + args: [newKey, newTodoValue], + }, + }) + }) + + it("returns the WeakMap instance for chaining", () => { + const key = { id: 1 } + const todos = new WeakMap() + const state = { todos } + const ost = new OST(state) + + const result = ost.root.todos.set(key, { content: "test" }) + + expect(result).toBe(ost.root.todos) + }) + }) + + describe("#get", () => { + it("returns the node of the value for the given key", () => { + const key1 = { id: 1 } + const key2 = { id: 2 } + const todos = new WeakMap() + todos.set(key1, { id: 1, content: "Learn Arbor" }) + todos.set(key2, { id: 2, content: "Implement OST" }) + + const state = { todos } + const ost = new OST(state) + + const value = ost.root.todos.get(key1) + + expect(value.id).toBe(1) + expect(value.content).toBe("Learn Arbor") + }) + + it("returns non-proxiable values as is", () => { + const key1 = { id: 1 } + const key2 = { id: 2 } + const todos = new WeakMap() + todos.set(key1, "Learn Arbor") + todos.set(key2, "Implement OST") + + const state = { todos } + const ost = new OST(state) + + const value = ost.root.todos.get(key1) + + expect(value).toBe("Learn Arbor") + }) + + it("returns undefined for non-existent keys", () => { + const key1 = { id: 1 } + const nonExistentKey = { id: 999 } + const todos = new WeakMap() + todos.set(key1, { id: 1, content: "Learn Arbor" }) + + const state = { todos } + const ost = new OST(state) + + const value = ost.root.todos.get(nonExistentKey) + + expect(value).toBeUndefined() + }) + + it("caches the node representing the weakmap item", () => { + const key1 = { id: 1 } + const todos = new WeakMap() + todos.set(key1, { id: 1, content: "Learn Arbor" }) + + const state = { todos } + const ost = new OST(state) + + const value1 = ost.root.todos.get(key1) + const value2 = ost.root.todos.get(key1) + + expect(value1).toBe(value2) + }) + }) + + describe("#delete", () => { + it("mutates the underlying value", () => { + const key1 = { id: 1 } + const key2 = { id: 2 } + const todos = new WeakMap() + todos.set(key1, { id: 1, content: "Learn Arbor" }) + todos.set(key2, { id: 2, content: "Implement OST" }) + + const state = { todos } + const ost = new OST(state) + + const result = ost.root.todos.delete(key1) + + expect(result).toBe(true) + expect(ost.root.todos.has(key1)).toBe(false) + }) + + it("returns false when deleting non-existent key", () => { + const key1 = { id: 1 } + const key2 = { id: 2 } + const nonExistentKey = { id: 999 } + const todos = new WeakMap() + todos.set(key1, { id: 1, content: "Learn Arbor" }) + todos.set(key2, { id: 2, content: "Implement OST" }) + + const state = { todos } + const ost = new OST(state) + + const result = ost.root.todos.delete(nonExistentKey) + + expect(result).toBe(false) + }) + + it("notifies subscribers of a deleted item", () => { + const key1 = { id: 1 } + const key2 = { id: 2 } + const todos = new WeakMap() + todos.set(key1, { id: 1, content: "Learn Arbor" }) + todos.set(key2, { id: 2, content: "Implement OST" }) + + const state = { todos } + const ost = new OST(state) + const subscriber = vi.fn() + + ost.subscribe(subscriber) + + ost.root.todos.delete(key1) + + expect(subscriber).toHaveBeenCalledOnce() + }) + + it("does not notify subscribers if deleted key does not exist in the weakmap", () => { + const key1 = { id: 1 } + const key2 = { id: 2 } + const nonExistentKey = { id: 999 } + const todos = new WeakMap() + todos.set(key1, { id: 1, content: "Learn Arbor" }) + todos.set(key2, { id: 2, content: "Implement OST" }) + + const state = { todos } + const ost = new OST(state) + const subscriber = vi.fn() + + ost.subscribe(subscriber) + + ost.root.todos.delete(nonExistentKey) + + expect(subscriber).not.toHaveBeenCalled() + }) + + it("exposes mutation event metadata to subscribers", () => { + const key1 = { id: 1 } + const key2 = { id: 2 } + const todos = new WeakMap() + todos.set(key1, { id: 1, content: "Learn Arbor" }) + todos.set(key2, { id: 2, content: "Implement OST" }) + + const state = { todos } + const ost = new OST(state) + const subscriber = vi.fn() + + ost.subscribe(subscriber) + + ost.root.todos.delete(key1) + + expect(subscriber).toHaveBeenCalledWith({ + target: ost.root.todos, + metadata: { + operation: "delete", + args: [key1], + }, + }) + }) + }) + + describe("#has", () => { + it("returns true if the given key exists in the weakmap", () => { + const key1 = { id: 1 } + const key2 = { id: 2 } + const nonExistentKey = { id: 999 } + const todos = new WeakMap() + todos.set(key1, { id: 1, content: "Learn Arbor" }) + todos.set(key2, { id: 2, content: "Implement OST" }) + + const state = { todos } + const ost = new OST(state) + + expect(ost.root.todos.has(key1)).toBe(true) + expect(ost.root.todos.has(key2)).toBe(true) + expect(ost.root.todos.has(nonExistentKey)).toBe(false) + }) + + it("works with complex object keys", () => { + const complexKey = { nested: { data: "test" }, array: [1, 2, 3] } + const value = { content: "complex test" } + const todos = new WeakMap() + todos.set(complexKey, value) + + const state = { todos } + const ost = new OST(state) + + expect(ost.root.todos.has(complexKey)).toBe(true) + }) + + it("does not trigger mutations or notifications", () => { + const key1 = { id: 1 } + const todos = new WeakMap() + todos.set(key1, { id: 1, content: "Learn Arbor" }) + + const state = { todos } + const ost = new OST(state) + const subscriber = vi.fn() + + ost.subscribe(subscriber) + + ost.root.todos.has(key1) + + expect(subscriber).not.toHaveBeenCalled() + }) + }) + + describe("WeakMap constraints", () => { + it("only accepts object keys", () => { + const todos = new WeakMap() + const state = { todos } + const ost = new OST(state) + + // These should work (objects as keys) + const objKey = { id: 1 } + const funcKey = () => {} // eslint-disable-line @typescript-eslint/no-empty-function + const arrayKey = [1, 2, 3] + + expect(() => ost.root.todos.set(objKey, "value1")).not.toThrow() + expect(() => ost.root.todos.set(funcKey, "value2")).not.toThrow() + expect(() => ost.root.todos.set(arrayKey, "value3")).not.toThrow() + + expect(ost.root.todos.has(objKey)).toBe(true) + expect(ost.root.todos.has(funcKey)).toBe(true) + expect(ost.root.todos.has(arrayKey)).toBe(true) + }) + + it("handles complex object keys correctly", () => { + const complexKey = { nested: { data: "test" }, array: [1, 2, 3] } + const value = { content: "complex test" } + const todos = new WeakMap() + + const state = { todos } + const ost = new OST(state) + + ost.root.todos.set(complexKey, value) + + expect(ost.root.todos.has(complexKey)).toBe(true) + expect(ost.root.todos.get(complexKey).$value).toBe(value) + }) + }) + + describe("custom @node weakmaps", () => { + @node + class MyWeakMap extends WeakMap { + setIfNotExists(key: object, value: any) { + // eslint-disable-line @typescript-eslint/no-explicit-any + if (!this.has(key)) { + this.set(key, value) + return true + } + return false + } + + getWithDefault(key: object, defaultValue: any) { + // eslint-disable-line @typescript-eslint/no-explicit-any + return this.has(key) ? this.get(key) : defaultValue + } + } + + it("supports custom WeakMap types when decorated with @node", () => { + const key1 = { id: 1 } + const key2 = { id: 2 } + const todos = new MyWeakMap() + todos.set(key1, { id: 1, content: "Learn Arbor" }) + + const state = { todos } + const ost = new OST(state) + + expect( + ost.root.todos.setIfNotExists(key2, { id: 2, content: "Test" }) + ).toBe(true) + expect( + ost.root.todos.setIfNotExists(key1, { id: 1, content: "Updated" }) + ).toBe(false) + expect(ost.root.todos.getWithDefault(key2, null).$value).toEqual({ + id: 2, + content: "Test", + }) + }) + + it("executes custom methods within the context of the proxy", () => { + const key1 = { id: 1 } + const todos = new MyWeakMap() + + const state = { todos } + const ost = new OST(state) + const subscriber = vi.fn() + + ost.subscribe(subscriber) + + ost.root.todos.setIfNotExists(key1, { id: 1, content: "Test" }) + + expect(subscriber).toHaveBeenCalledOnce() + }) + + it("custom methods trigger mutations when appropriate", () => { + const key1 = { id: 1 } + const todos = new MyWeakMap() + + const state = { todos } + const ost = new OST(state) + const subscriber = vi.fn() + + ost.subscribe(subscriber) + + // First call should trigger mutation + ost.root.todos.setIfNotExists(key1, { id: 1, content: "Test" }) + expect(subscriber).toHaveBeenCalledOnce() + + // Second call should not trigger mutation + ost.root.todos.setIfNotExists(key1, { id: 1, content: "Updated" }) + expect(subscriber).toHaveBeenCalledOnce() + }) + }) + + describe("Edge cases and comprehensive tests", () => { + it("should handle rapid set/delete operations", () => { + const key1 = { id: 1 } + const key2 = { id: 2 } + const todos = new WeakMap() + + const state = { todos } + const ost = new OST(state) + + // Rapid operations + ost.root.todos.set(key1, "value1") + ost.root.todos.set(key2, "value2") + ost.root.todos.delete(key1) + ost.root.todos.set(key1, "value1-new") + + expect(ost.root.todos.has(key1)).toBe(true) + expect(ost.root.todos.has(key2)).toBe(true) + expect(ost.root.todos.get(key1)).toBe("value1-new") + }) + + it("should handle mixed operations", () => { + const key1 = { id: 1 } + const key2 = { id: 2 } + const key3 = { id: 3 } + const todos = new WeakMap() + + const state = { todos } + const ost = new OST(state) + + ost.root.todos.set(key1, { content: "test1" }) + expect(ost.root.todos.has(key1)).toBe(true) + + ost.root.todos.set(key2, "string-value") + expect(ost.root.todos.get(key2)).toBe("string-value") + + ost.root.todos.set(key3, 123) + expect(ost.root.todos.get(key3)).toBe(123) + + expect(ost.root.todos.delete(key2)).toBe(true) + expect(ost.root.todos.has(key2)).toBe(false) + }) + + it("should work with different types of object values", () => { + const key1 = { id: 1 } + const key2 = { id: 2 } + const key3 = { id: 3 } + const key4 = { id: 4 } + const todos = new WeakMap() + + const state = { todos } + const ost = new OST(state) + + // Object value + ost.root.todos.set(key1, { nested: "object" }) + expect(ost.root.todos.get(key1).nested).toBe("object") + + // Array value + ost.root.todos.set(key2, [1, 2, 3]) + expect(ost.root.todos.get(key2)[0]).toBe(1) + + // Function value + const fn = () => "test" + ost.root.todos.set(key3, fn) + expect(ost.root.todos.get(key3)).toBe(fn) + + // Primitive values + ost.root.todos.set(key4, "primitive") + expect(ost.root.todos.get(key4)).toBe("primitive") + }) + }) +}) diff --git a/packages/arbor-ost/tests/handlers/$weakSet.test.ts b/packages/arbor-ost/tests/handlers/$weakSet.test.ts new file mode 100644 index 00000000..ddbef18d --- /dev/null +++ b/packages/arbor-ost/tests/handlers/$weakSet.test.ts @@ -0,0 +1,496 @@ +import { describe, expect, it, vi } from "vitest" + +import { OST } from "../../src/ost" +import { node } from "../../src/decorators/node" + +describe("$weakSet", () => { + describe("#add", () => { + it("mutates the underlying value", () => { + const value1 = { id: 1, content: "Learn Arbor" } + const value2 = { id: 2, content: "Implement OST" } + const todos = new WeakSet() + todos.add(value1) + todos.add(value2) + + const newTodoValue = { id: 3, content: "Learn LLM" } + const state = { todos } + const ost = new OST(state) + + ost.root.todos.add(newTodoValue) + + expect(ost.root.todos.has(newTodoValue)).toBe(true) + }) + + it("can store complex object values", () => { + const value1 = { nested: { data: "test" }, array: [1, 2, 3] } + const value2 = function () { + return "hello" + } + const todos = new WeakSet() + + const state = { todos } + const ost = new OST(state) + + ost.root.todos.add(value1) + ost.root.todos.add(value2) + + expect(ost.root.todos.has(value1)).toBe(true) + expect(ost.root.todos.has(value2)).toBe(true) + }) + + it("does not mutate if value already exists", () => { + const value1 = { id: 1, content: "Learn Arbor" } + const todos = new WeakSet() + todos.add(value1) + + const state = { todos } + const ost = new OST(state) + const subscriber = vi.fn() + + ost.subscribe(subscriber) + ost.root.todos.add(value1) // Adding existing value + + expect(subscriber).not.toHaveBeenCalled() + }) + + it("notifies subscribers of a new item in the weakset", () => { + const value1 = { id: 1, content: "Learn Arbor" } + const value2 = { id: 2, content: "Implement OST" } + const todos = new WeakSet() + todos.add(value1) + todos.add(value2) + + const newTodoValue = { id: 3, content: "Learn LLM" } + const state = { todos } + const ost = new OST(state) + const subscriber = vi.fn() + + ost.subscribe(subscriber) + ost.root.todos.add(newTodoValue) + + expect(subscriber).toHaveBeenCalledOnce() + }) + + it("exposes mutation event metadata to subscribers", async () => { + const value1 = { id: 1, content: "Learn Arbor" } + const value2 = { id: 2, content: "Implement OST" } + const todos = new WeakSet() + todos.add(value1) + todos.add(value2) + + const state = { todos } + const ost = new OST(state) + + return new Promise((resolve) => { + const newTodoValue = { id: 3, content: "Learn LLM" } + + ost.subscribe((event) => { + expect(event.target).toBe(ost.root.todos) + expect(event.metadata.args).toEqual([newTodoValue]) + expect(event.metadata.operation).toEqual("add") + resolve(true) + }) + + ost.root.todos.add(newTodoValue) + }) + }) + + it("returns the WeakSet instance for chaining", () => { + const value = { id: 1, content: "test" } + const todos = new WeakSet() + const state = { todos } + const ost = new OST(state) + + const result = ost.root.todos.add(value) + + expect(result).toBe(ost.root.todos) + }) + }) + + describe("#delete", () => { + it("mutates the underlying value", () => { + const value1 = { id: 1, content: "Learn Arbor" } + const value2 = { id: 2, content: "Implement OST" } + const todos = new WeakSet() + todos.add(value1) + todos.add(value2) + + const state = { todos } + const ost = new OST(state) + + const result = ost.root.todos.delete(value2) + + expect(result).toBe(true) + expect(ost.root.todos.has(value2)).toBe(false) + }) + + it("returns false when deleting non-existent value", () => { + const value1 = { id: 1, content: "Learn Arbor" } + const value2 = { id: 2, content: "Implement OST" } + const nonExistentValue = { id: 999, content: "Non-existent" } + const todos = new WeakSet() + todos.add(value1) + todos.add(value2) + + const state = { todos } + const ost = new OST(state) + + const result = ost.root.todos.delete(nonExistentValue) + + expect(result).toBe(false) + }) + + it("notifies subscribers of a deleted item", () => { + const value1 = { id: 1, content: "Learn Arbor" } + const value2 = { id: 2, content: "Implement OST" } + const todos = new WeakSet() + todos.add(value1) + todos.add(value2) + + const state = { todos } + const ost = new OST(state) + const subscriber = vi.fn() + + ost.subscribe(subscriber) + ost.root.todos.delete(value2) + + expect(subscriber).toHaveBeenCalledOnce() + }) + + it("does not notify subscribers if deleted value does not exist in the weakset", () => { + const value1 = { id: 1, content: "Learn Arbor" } + const value2 = { id: 2, content: "Implement OST" } + const nonExistentValue = { id: 999, content: "Non-existent" } + const todos = new WeakSet() + todos.add(value1) + todos.add(value2) + + const state = { todos } + const ost = new OST(state) + const subscriber = vi.fn() + + ost.subscribe(subscriber) + ost.root.todos.delete(nonExistentValue) + + expect(subscriber).not.toHaveBeenCalled() + }) + + it("exposes mutation event metadata to subscribers", async () => { + const value1 = { id: 1, content: "Learn Arbor" } + const value2 = { id: 2, content: "Implement OST" } + const todos = new WeakSet() + todos.add(value1) + todos.add(value2) + + const state = { todos } + const ost = new OST(state) + + return new Promise((resolve) => { + ost.subscribe((event) => { + expect(event.target).toBe(ost.root.todos) + expect(event.metadata.args).toEqual([value2]) + expect(event.metadata.operation).toEqual("delete") + resolve(true) + }) + + ost.root.todos.delete(value2) + }) + }) + }) + + describe("#has", () => { + it("returns true if the given value exists in the weakset", () => { + const value1 = { id: 1, content: "Learn Arbor" } + const value2 = { id: 2, content: "Implement OST" } + const nonExistentValue = { id: 999, content: "Non-existent" } + const todos = new WeakSet() + todos.add(value1) + todos.add(value2) + + const state = { todos } + const ost = new OST(state) + + expect(ost.root.todos.has(value1)).toBe(true) + expect(ost.root.todos.has(value2)).toBe(true) + expect(ost.root.todos.has(nonExistentValue)).toBe(false) + }) + + it("works with complex object values", () => { + const value1 = { nested: { data: "test" }, array: [1, 2, 3] } + const value2 = function () { + return "hello" + } + const todos = new WeakSet() + todos.add(value1) + todos.add(value2) + + const state = { todos } + const ost = new OST(state) + + expect(ost.root.todos.has(value1)).toBe(true) + expect(ost.root.todos.has(value2)).toBe(true) + }) + + it("does not trigger mutations or notifications", () => { + const value1 = { id: 1, content: "test" } + const todos = new WeakSet() + todos.add(value1) + + const state = { todos } + const ost = new OST(state) + const subscriber = vi.fn() + + ost.subscribe(subscriber) + ost.root.todos.has(value1) + + expect(subscriber).not.toHaveBeenCalled() + }) + }) + + describe("WeakSet constraints", () => { + it("only accepts object values", () => { + const todos = new WeakSet() + const state = { todos } + const ost = new OST(state) + + // These should work (objects as values) + const objValue = { id: 1 } + const funcValue = () => {} // eslint-disable-line @typescript-eslint/no-empty-function + const arrayValue = [1, 2, 3] + + expect(() => ost.root.todos.add(objValue)).not.toThrow() + expect(() => ost.root.todos.add(funcValue)).not.toThrow() + expect(() => ost.root.todos.add(arrayValue)).not.toThrow() + + expect(ost.root.todos.has(objValue)).toBe(true) + expect(ost.root.todos.has(funcValue)).toBe(true) + expect(ost.root.todos.has(arrayValue)).toBe(true) + }) + + it("handles complex object values correctly", () => { + const todos = new WeakSet() + const state = { todos } + const ost = new OST(state) + + const complexValue = { + nested: { data: "test" }, + array: [1, 2, 3], + method: function () { + return "hello" + }, + } + + ost.root.todos.add(complexValue) + + expect(ost.root.todos.has(complexValue)).toBe(true) + }) + }) + + describe("custom @node weaksets", () => { + @node + class MyWeakSet extends WeakSet { + addIfNotExists(value: object) { + if (!this.has(value)) { + this.add(value) + return true + } + return false + } + + addAll(...values: object[]) { + let added = 0 + for (const value of values) { + if (this.addIfNotExists(value)) { + added++ + } + } + return added + } + } + + it("supports custom WeakSet types when decorated with @node", () => { + const value1 = { id: 1, content: "Learn Arbor" } + const value2 = { id: 2, content: "Implement OST" } + const todos = new MyWeakSet() + todos.add(value1) + todos.add(value2) + + const state = { todos } + const ost = new OST(state) + + expect(ost.root.todos.has(value1)).toBe(true) + expect(ost.root.todos.has(value2)).toBe(true) + }) + + it("executes custom methods within the context of the proxy", () => { + const value1 = { id: 1, content: "Learn Arbor" } + const value2 = { id: 2, content: "Implement OST" } + const value3 = { id: 3, content: "Learn LLM" } + const todos = new MyWeakSet() + + const state = { todos } + const ost = new OST(state) + + // Test addIfNotExists + expect(ost.root.todos.addIfNotExists(value1)).toBe(true) + expect(ost.root.todos.addIfNotExists(value1)).toBe(false) // Already exists + + // Test addAll + expect(ost.root.todos.addAll(value2, value3, value1)).toBe(2) // Only value2 and value3 are new + }) + + it("custom methods trigger mutations when appropriate", () => { + const value1 = { id: 1, content: "test" } + const todos = new MyWeakSet() + const state = { todos } + const ost = new OST(state) + const subscriber = vi.fn() + + ost.subscribe(subscriber) + ost.root.todos.addIfNotExists(value1) + + expect(subscriber).toHaveBeenCalledOnce() + }) + }) + + describe("Edge cases and comprehensive tests", () => { + it("should handle rapid add/delete operations", () => { + const todos = new WeakSet() + const state = { todos } + const ost = new OST(state) + const subscriber = vi.fn() + + ost.subscribe(subscriber) + + const values = Array.from({ length: 10 }, (_, i) => ({ + id: i, + content: `Task ${i}`, + })) + + // Rapid add operations + values.forEach((value) => { + ost.root.todos.add(value) + }) + + expect(subscriber).toHaveBeenCalledTimes(10) + + // Verify all items exist + values.forEach((value) => { + expect(ost.root.todos.has(value)).toBe(true) + }) + + // Rapid delete operations + values.forEach((value) => { + ost.root.todos.delete(value) + }) + + expect(subscriber).toHaveBeenCalledTimes(20) + + // Verify all items are gone + values.forEach((value) => { + expect(ost.root.todos.has(value)).toBe(false) + }) + }) + + it("should handle mixed operations", () => { + const todos = new WeakSet() + const state = { todos } + const ost = new OST(state) + + const value1 = { id: 1, content: "First" } + const value2 = { id: 2, content: "Second" } + + // Add, check, add duplicate, check, delete, check + ost.root.todos.add(value1) + expect(ost.root.todos.has(value1)).toBe(true) + + ost.root.todos.add(value1) // Add duplicate (should not trigger mutation) + expect(ost.root.todos.has(value1)).toBe(true) + + ost.root.todos.add(value2) // Add new + expect(ost.root.todos.has(value1)).toBe(true) + expect(ost.root.todos.has(value2)).toBe(true) + + ost.root.todos.delete(value1) + expect(ost.root.todos.has(value1)).toBe(false) + expect(ost.root.todos.has(value2)).toBe(true) + }) + + it("should handle duplicate add operations without mutations", () => { + const value1 = { id: 1, content: "test" } + const todos = new WeakSet() + const state = { todos } + const ost = new OST(state) + const subscriber = vi.fn() + + // First add should trigger mutation + ost.subscribe(subscriber) + ost.root.todos.add(value1) + expect(subscriber).toHaveBeenCalledOnce() + + // Second add of same value should not trigger mutation + ost.root.todos.add(value1) + expect(subscriber).toHaveBeenCalledOnce() // Still only once + }) + + it("should handle delete operations on non-existent values", () => { + const value1 = { id: 1, content: "exists" } + const value2 = { id: 2, content: "does not exist" } + const todos = new WeakSet() + todos.add(value1) + + const state = { todos } + const ost = new OST(state) + const subscriber = vi.fn() + + ost.subscribe(subscriber) + + // Delete existing value - should trigger mutation + ost.root.todos.delete(value1) + expect(subscriber).toHaveBeenCalledOnce() + + // Delete non-existent value - should not trigger mutation + ost.root.todos.delete(value2) + expect(subscriber).toHaveBeenCalledOnce() // Still only once + }) + + it("should work with different types of object values", () => { + const todos = new WeakSet() + const state = { todos } + const ost = new OST(state) + + // Different types of objects + const plainObject = { type: "plain" } + const array = [1, 2, 3] + const func = function () { + return "test" + } + const date = new Date() + const regex = /test/g + + // Add all types + ost.root.todos.add(plainObject) + ost.root.todos.add(array) + ost.root.todos.add(func) + ost.root.todos.add(date) + ost.root.todos.add(regex) + + // Verify all exist + expect(ost.root.todos.has(plainObject)).toBe(true) + expect(ost.root.todos.has(array)).toBe(true) + expect(ost.root.todos.has(func)).toBe(true) + expect(ost.root.todos.has(date)).toBe(true) + expect(ost.root.todos.has(regex)).toBe(true) + + // Delete some + ost.root.todos.delete(array) + ost.root.todos.delete(func) + + expect(ost.root.todos.has(plainObject)).toBe(true) + expect(ost.root.todos.has(array)).toBe(false) + expect(ost.root.todos.has(func)).toBe(false) + expect(ost.root.todos.has(date)).toBe(true) + expect(ost.root.todos.has(regex)).toBe(true) + }) + }) +}) diff --git a/packages/arbor-ost/tests/matchers/index.ts b/packages/arbor-ost/tests/matchers/index.ts new file mode 100644 index 00000000..e7b26cee --- /dev/null +++ b/packages/arbor-ost/tests/matchers/index.ts @@ -0,0 +1,6 @@ +import "./toBeNodeOf" +import "./toBeRootNode" +import "./toHaveNodeFor" +import "./toHaveParentNode" +import "./toBeDetachedFrom" +import "./toHaveNodeValuePair" diff --git a/packages/arbor-ost/tests/matchers/toBeDetachedFrom.ts b/packages/arbor-ost/tests/matchers/toBeDetachedFrom.ts new file mode 100644 index 00000000..24a315f0 --- /dev/null +++ b/packages/arbor-ost/tests/matchers/toBeDetachedFrom.ts @@ -0,0 +1,19 @@ +import { expect } from "vitest" + +import { Node } from "../../src/types" +import { OST } from "../../src/ost" + +expect.extend({ + toBeDetachedFrom(node: Node, ost: OST) { + const pass = ost.isDetached(node.$value) + + return { + pass, + actual: node, + message: () => + `Node ${ost.humanizePath(node.$path)} ${ + pass ? "is" : "is not" + } detached from the given OST.`, + } + }, +}) diff --git a/packages/arbor-ost/tests/matchers/toBeNodeOf.ts b/packages/arbor-ost/tests/matchers/toBeNodeOf.ts new file mode 100644 index 00000000..6c71a3b6 --- /dev/null +++ b/packages/arbor-ost/tests/matchers/toBeNodeOf.ts @@ -0,0 +1,21 @@ +import { expect } from "vitest" + +import { Node, Value } from "../../src/types" + +expect.extend({ + toBeNodeOf(node: Node, value: Value) { + const expected = node.$ost.nodeOf(value) + const pass = + expected === node + + return { + pass, + actual: node, + expected, + message: () => + `Node ${node.$path.humanize()} ${ + pass ? "is" : "is not" + } the node of ${value}`, + } + }, +}) diff --git a/packages/arbor-ost/tests/matchers/toBeRootNode.ts b/packages/arbor-ost/tests/matchers/toBeRootNode.ts new file mode 100644 index 00000000..330b0c9d --- /dev/null +++ b/packages/arbor-ost/tests/matchers/toBeRootNode.ts @@ -0,0 +1,23 @@ +import { expect } from "vitest" + +import { Node } from "../../src/types" +import { Path } from "../../src/path" +import { Seed } from "../../src/seed" + +expect.extend({ + toBeRootNode(node: Node, expected) { + const pass = + node.$parent === undefined && + node.$path instanceof Path && + node.$path.isRoot() && + node.$seed instanceof Seed + + return { + pass, + actual: node, + expected: expected, + message: () => + `Received value is ${pass ? "" : "not "}the OST's root node`, + } + }, +}) diff --git a/packages/arbor-ost/tests/matchers/toHaveNodeFor.ts b/packages/arbor-ost/tests/matchers/toHaveNodeFor.ts new file mode 100644 index 00000000..52836a1c --- /dev/null +++ b/packages/arbor-ost/tests/matchers/toHaveNodeFor.ts @@ -0,0 +1,17 @@ +import { expect } from "vitest" + +import { Value } from "../../src/types" +import { OST } from "../../src/ost" + +expect.extend({ + toHaveNodeFor(ost: OST, value: Value) { + const pass = ost.seedOf(value) != null + + return { + pass, + actual: ost, + message: () => + `Ost ${pass ? "has" : "does not have"} a node for the given value.`, + } + }, +}) diff --git a/packages/arbor-ost/tests/matchers/toHaveNodeValuePair.ts b/packages/arbor-ost/tests/matchers/toHaveNodeValuePair.ts new file mode 100644 index 00000000..67bfd742 --- /dev/null +++ b/packages/arbor-ost/tests/matchers/toHaveNodeValuePair.ts @@ -0,0 +1,17 @@ +import { expect } from "vitest" + +import { Node, Value } from "../../src/types" +import { OST } from "../../src/ost" + +expect.extend({ + toHaveNodeValuePair(ost: OST, [node, value]: [Node, Value]) { + const pass = ost.nodeOf(value) === node && node.$value === value + + return { + pass, + actual: ost, + message: () => + `Ost ${pass ? "has" : "does not have"} the given node-value pair.`, + } + }, +}) diff --git a/packages/arbor-ost/tests/matchers/toHaveParentNode.ts b/packages/arbor-ost/tests/matchers/toHaveParentNode.ts new file mode 100644 index 00000000..12454fd8 --- /dev/null +++ b/packages/arbor-ost/tests/matchers/toHaveParentNode.ts @@ -0,0 +1,20 @@ +import { expect } from "vitest" + +import { Node } from "../../src/types" + +expect.extend({ + toHaveParentNode(node: Node, parent?: Node) { + const pass = + node.$parent === parent && node.$path.parentSeed === parent?.$seed + + return { + pass, + actual: node, + expected: parent, + message: () => + `Node ${node.$path.humanize()} ${ + pass ? "has" : "does not have" + } parent node equals to ${parent ? parent.$path.humanize() : parent}`, + } + }, +}) diff --git a/packages/arbor-ost/tests/ost.test.ts b/packages/arbor-ost/tests/ost.test.ts new file mode 100644 index 00000000..a460a3f3 --- /dev/null +++ b/packages/arbor-ost/tests/ost.test.ts @@ -0,0 +1,257 @@ +import { describe, expect, it, vi } from "vitest" + +import { OST } from "../src/ost" + +describe("OST", () => { + describe("#createNode", () => { + it("creates nodes for each proxiable object within the state value", () => { + const state = { + todos: [ + { id: 1, content: "Learn Arbor" }, + { id: 2, content: "Implement OST" }, + ], + } + + const ost = new OST() + const $rootNode = ost.createNode(state) + const $todosNode = $rootNode.$createChild(state.todos) + const $todo1Node = $todosNode.$createChild(state.todos[0]) + const $todo2Node = $todosNode.$createChild(state.todos[1]) + + expect(ost).toHaveNodeValuePair([$rootNode, state]) + expect(ost).toHaveNodeValuePair([$todosNode, state.todos]) + expect(ost).toHaveNodeValuePair([$todo1Node, state.todos[0]]) + expect(ost).toHaveNodeValuePair([$todo2Node, state.todos[1]]) + + expect($rootNode).toHaveParentNode(undefined) + expect($todosNode).toHaveParentNode($rootNode) + expect($todo1Node).toHaveParentNode($todosNode) + expect($todo2Node).toHaveParentNode($todosNode) + }) + }) + + describe("#humanizePath", () => { + it("creates a string representation of OST paths", () => { + const state = { + todos: [ + { id: 1, content: "Learn Arbor" }, + { id: 2, content: "Implement OST" }, + ], + } + + const ost = new OST() + const $rootNode = ost.createNode(state) + const $todosNode = $rootNode.$createChild(state.todos) + const $todo1Node = $todosNode.$createChild(state.todos[0]) + const $todo2Node = $todosNode.$createChild(state.todos[1]) + + expect(ost.humanizePath($rootNode.$path)).toEqual( + `${$rootNode.$seed.value}` + ) + expect(ost.humanizePath($todosNode.$path)).toEqual( + `${$rootNode.$seed.value} -> ${$todosNode.$seed.value}` + ) + expect(ost.humanizePath($todo1Node.$path)).toEqual( + `${$rootNode.$seed.value} -> ${$todosNode.$seed.value} -> ${$todo1Node.$seed.value}` + ) + expect(ost.humanizePath($todo2Node.$path)).toEqual( + `${$rootNode.$seed.value} -> ${$todosNode.$seed.value} -> ${$todo2Node.$seed.value}` + ) + }) + + it("marks detached seeds in the path", () => { + const state = { + todos: [ + { id: 1, content: "Learn Arbor", author: { name: "Alice" } }, + { id: 2, content: "Implement OST", author: { name: "Bob" } }, + ], + } + + const ost = new OST() + const $rootNode = ost.createNode(state) + const $todosNode = $rootNode.$createChild(state.todos) + const $todo1Node = $todosNode.$createChild(state.todos[0]) + const todo2Node = $todosNode.$createChild(state.todos[1]) + const todo1AuthorNode = $todo1Node.$createChild(state.todos[0].author) + const todo2AuthorNode = todo2Node.$createChild(state.todos[1].author) + + state.todos.shift() + + expect(ost.humanizePath($rootNode.$path)).toEqual( + `${$rootNode.$seed.value}` + ) + expect(ost.humanizePath($todosNode.$path)).toEqual( + `${$rootNode.$seed.value} -> ${$todosNode.$seed.value}` + ) + expect(ost.humanizePath($todo1Node.$path)).toEqual( + `${$rootNode.$seed.value} -> ${$todosNode.$seed.value} -> ${$todo1Node.$seed.value}*` + ) + expect(ost.humanizePath(todo1AuthorNode.$path)).toEqual( + `${$rootNode.$seed.value} -> ${$todosNode.$seed.value} -> ${$todo1Node.$seed.value}* -> ${todo1AuthorNode.$seed.value}` + ) + expect(ost.humanizePath(todo2Node.$path)).toEqual( + `${$rootNode.$seed.value} -> ${$todosNode.$seed.value} -> ${todo2Node.$seed.value}` + ) + expect(ost.humanizePath(todo2AuthorNode.$path)).toEqual( + `${$rootNode.$seed.value} -> ${$todosNode.$seed.value} -> ${todo2Node.$seed.value} -> ${todo2AuthorNode.$seed.value}` + ) + }) + }) + + describe("#isDetached", () => { + it("returns true if argument is null or undefined", () => { + const ost = new OST() + + expect(ost.isDetached(null)).toBe(true) + expect(ost.isDetached(undefined)).toBe(true) + }) + + it("returns true if value referenced by path is no longer in the state tree", () => { + const state = { + todos: [ + { id: 1, content: "Learn Arbor", author: { name: "Alice" } }, + { id: 2, content: "Implement OST", author: { name: "Bob" } }, + ], + } + + const ost = new OST() + const $rootNode = ost.createNode(state) + const $todosNode = $rootNode.$createChild(state.todos) + const $todo1Node = $todosNode.$createChild(state.todos[0]) + const $todo2Node = $todosNode.$createChild(state.todos[1]) + const $todo1AuthorNode = $todo1Node.$createChild(state.todos[0].author) + const $todo2AuthorNode = $todo2Node.$createChild(state.todos[1].author) + + state.todos.shift() + + expect($rootNode).not.toBeDetachedFrom(ost) + expect($todosNode).not.toBeDetachedFrom(ost) + expect($todo1Node).toBeDetachedFrom(ost) + expect($todo1AuthorNode).toBeDetachedFrom(ost) + expect($todo2Node).not.toBeDetachedFrom(ost) + expect($todo2AuthorNode).not.toBeDetachedFrom(ost) + }) + }) + + describe("#mutate", () => { + it("refreshes the nodes in the mutation path with new memory references", () => { + const state = { + todos: [ + { id: 1, content: "Learn Arbor", author: { name: "Alice" } }, + { id: 2, content: "Implement OST", author: { name: "Bob" } }, + ], + } + + const ost = new OST() + const $rootNode = ost.createNode(state) + const $todosNode = $rootNode.$createChild(state.todos) + const $todo1Node = $todosNode.$createChild(state.todos[0]) + const $todo2Node = $todosNode.$createChild(state.todos[1]) + const $todo1AuthorNode = $todo1Node.$createChild(state.todos[0].author) + const $todo2AuthorNode = $todo2Node.$createChild(state.todos[1].author) + + ost.mutate($todo1Node, () => { + Reflect.set($todo1Node.$value, "content", "Learn Arbor OST", $todo1Node) + + return { + args: ["content", "Learn Arbor OST"], + operation: "set", + } + }) + + expect(state.todos[0].content).toEqual("Learn Arbor OST") + expect($todo1Node.$value.content).toEqual("Learn Arbor OST") + + const $newRootNode = ost.root + const $newTodosNode = ost.nodeOf(state.todos) + const $newTodo1Node = ost.nodeOf(state.todos[0]) + const $newTodo2Node = ost.nodeOf(state.todos[1]) + const $newTodo1AuthorNode = ost.nodeOf(state.todos[0].author) + const $newTodo2AuthorNode = ost.nodeOf(state.todos[1].author) + + expect($newRootNode).not.toBe($rootNode) + expect($newRootNode.$value).toBe(state) + expect($newTodosNode).not.toBe($todosNode) + expect($newTodosNode.$value).toBe($todosNode.$value) + expect($newTodo1Node).not.toBe($todo1Node) + expect($newTodo1Node.$value).toBe($todo1Node.$value) + expect($newTodo1AuthorNode).toBe($todo1AuthorNode) + expect($newTodo2Node).toBe($todo2Node) + expect($newTodo2AuthorNode).toBe($todo2AuthorNode) + }) + + it("returns the new reference of the mutated node", () => { + const state = { + todos: [ + { id: 1, content: "Learn Arbor", author: { name: "Alice" } }, + { id: 2, content: "Implement OST", author: { name: "Bob" } }, + ], + } + + const ost = new OST(state) + const nodeToMutate = ost.root.todos[0].author + + const mutatedNode = ost.mutate(nodeToMutate, () => { + Reflect.set(nodeToMutate.$value, "name", "Alice Doe", nodeToMutate) + + return { + args: ["name", "Alice Doe"], + operation: "set", + } + }) + + expect(mutatedNode).not.toBe(nodeToMutate) + expect(mutatedNode.$seed).toBe(nodeToMutate.$seed) + }) + + it("notifies all subscribers affected by the mutation path", () => { + const state = { + todos: [ + { id: 1, content: "Learn Arbor", author: { name: "Alice" } }, + { id: 2, content: "Implement OST", author: { name: "Bob" } }, + ], + } + + const ost = new OST() + const $rootNode = ost.createNode(state) + const $todosNode = $rootNode.$createChild(state.todos) + const $todo1Node = $todosNode.$createChild(state.todos[0]) + const $todo2Node = $todosNode.$createChild(state.todos[1]) + const $todo1AuthorNode = $todo1Node.$createChild(state.todos[0].author) + const $todo2AuthorNode = $todo2Node.$createChild(state.todos[1].author) + + const subscriberOfAllMutations = vi.fn() + const rootNodeSubscriber = vi.fn() + const todosNodeSubscriber = vi.fn() + const todo1NodeSubscriber = vi.fn() + const todo1AuthorNodeSubscriber = vi.fn() + const todo2NodeSubscriber = vi.fn() + const todo2AuthorNodeSubscriber = vi.fn() + + ost.subscribe(subscriberOfAllMutations) + ost.subscribeTo($rootNode, rootNodeSubscriber) + ost.subscribeTo($todosNode, todosNodeSubscriber) + ost.subscribeTo($todo1Node, todo1NodeSubscriber) + ost.subscribeTo($todo1AuthorNode, todo1AuthorNodeSubscriber) + ost.subscribeTo($todo2Node, todo2NodeSubscriber) + ost.subscribeTo($todo2AuthorNode, todo2AuthorNodeSubscriber) + + ost.mutate($todo1Node, () => { + Reflect.set($todo1Node.$value, "content", "Learn Arbor OST", $todo1Node) + + return { + args: ["content", "Learn Arbor OST"], + operation: "set", + } + }) + + expect(subscriberOfAllMutations).toHaveBeenCalledTimes(1) + expect(rootNodeSubscriber).toHaveBeenCalledTimes(1) + expect(todosNodeSubscriber).toHaveBeenCalledTimes(1) + expect(todo1NodeSubscriber).toHaveBeenCalledTimes(1) + expect(todo1AuthorNodeSubscriber).toHaveBeenCalledTimes(0) + expect(todo2NodeSubscriber).toHaveBeenCalledTimes(0) + expect(todo2AuthorNodeSubscriber).toHaveBeenCalledTimes(0) + }) + }) +}) diff --git a/packages/arbor-ost/tests/scoping/scope.test.ts b/packages/arbor-ost/tests/scoping/scope.test.ts new file mode 100644 index 00000000..5833d3c1 --- /dev/null +++ b/packages/arbor-ost/tests/scoping/scope.test.ts @@ -0,0 +1,490 @@ +import { describe, expect, it, vi } from "vitest" +import { Scope } from "../../src/scoping/scope" +import { OST } from "../../src/ost" + +describe("Scope", () => { + describe("$object", () => { + it("tracks property access on root nodes", () => { + const ost = new OST({ a: { b: 2 }, c: 3 }) + const scope = new Scope(ost) + const subscriber = vi.fn() + + scope.subscribe(subscriber) + + ost.root.c = 4 + + expect(subscriber).not.toHaveBeenCalled() + + void scope.root.c + + ost.root.c = 5 + + expect(subscriber).toHaveBeenCalled() + }) + + it("tracks property access on deeply nested nodes", () => { + const ost = new OST({ a: { b: { c: { d: 2, e: 3 } } }, f: 2 }) + const scope = new Scope(ost) + const subscriber = vi.fn() + + scope.subscribe(subscriber) + + ost.root.a.b.c.d = 4 + + expect(subscriber).not.toHaveBeenCalled() + + void scope.root.a.b.c.d + + ost.root.f = 5 // untracked by the scope + ost.root.a.b.c.e = 5 // untracked by the scope + ost.root.a.b.c.d = 5 + ost.root.a.b.c = { d: 6, e: 4 } as typeof ost.root.a.b.c + ost.root.a.b = { c: { d: 6, e: 4 } } as typeof ost.root.a.b + ost.root.a = { b: { c: { d: 6, e: 4 } } } as typeof ost.root.a + + expect(subscriber).toHaveBeenCalledTimes(4) + }) + + it("caches the scope proxies for optimal memory usage", () => { + const ost = new OST({ a: { b: { c: { d: 2, e: 3 } } }, f: 2 }) + const scope = new Scope(ost) + + expect(scope.root).toBe(scope.root) + expect(scope.root.a).toBe(scope.root.a) + expect(scope.root.a.b).toBe(scope.root.a.b) + expect(scope.root.a.b.c).toBe(scope.root.a.b.c) + expect(scope.root.a.b.c.d).toBe(scope.root.a.b.c.d) + expect(scope.root.a.b.c.e).toBe(scope.root.a.b.c.e) + expect(scope.root.f).toBe(scope.root.f) + }) + }) + + describe("$array", () => { + describe("Symbol.iterator", () => { + it("exposes tracked nodes", () => { + const ost = new OST([{ a: 1 }, { b: 1 }]) + const scope = new Scope(ost) + + const iterator = scope.root[Symbol.iterator]() + + expect(iterator.next().value).toBe(scope.root[0]) + expect(iterator.next().value).toBe(scope.root[1]) + expect(iterator.next().done).toBe(true) + }) + }) + + describe("#values", () => { + it("exposes tracked nodes", () => { + const ost = new OST([{ a: 1 }, { b: 1 }]) + const scope = new Scope(ost) + + const iterator = scope.root.values() + + expect(iterator.next().value).toBe(scope.root[0]) + expect(iterator.next().value).toBe(scope.root[1]) + expect(iterator.next().done).toBe(true) + }) + }) + + describe("#find", () => { + it("exposes tracked node", () => { + const ost = new OST([{ a: 1 }, { a: 2 }]) + const scope = new Scope(ost) + + const node1 = scope.root.find((n) => n.a === 1) + const node2 = scope.root.find((n) => n.a === 2) + + expect(node1).toBe(scope.root[0]) + expect(node2).toBe(scope.root[1]) + }) + }) + + describe("scope mutations", () => { + it("can trigger mutations from the scope itself", () => { + const ost = new OST([{ a: 1 }, { a: 2 }]) + const subscriber = vi.fn() + const scope = new Scope(ost) + scope.subscribe(subscriber) + + const iterator = scope.root.values() + + void iterator.next().value.a++ + + expect(subscriber).toHaveBeenCalledTimes(1) + }) + }) + }) + + describe("$map", () => { + describe("#get", () => { + it("tracks property access of items", () => { + const ost = new OST( + new Map([ + [0, { a: 1, b: 2 }], + [1, { a: 2, b: 3 }], + ]) + ) + + const subscriber = vi.fn() + const scope = new Scope(ost) + scope.subscribe(subscriber) + + void scope.root.get(0).a + + ost.root.get(0).a = 2 + ost.root.get(0).b = 3 + ost.root.get(1).a = 1 + ost.root.get(1).b = 2 + + expect(subscriber).toHaveBeenCalledTimes(1) + }) + }) + + describe("scope mutations", () => { + it("can trigger mutations from the scope itself", () => { + const ost = new OST( + new Map([ + [0, { a: 1, b: 2 }], + [1, { a: 2, b: 3 }], + ]) + ) + + const subscriber = vi.fn() + const scope = new Scope(ost) + scope.subscribe(subscriber) + + const iterator = scope.root.values() + + void iterator.next().value.a++ + + expect(subscriber).toHaveBeenCalledTimes(1) + }) + }) + + describe("#values", () => { + it("tracks property access of items", () => { + const ost = new OST( + new Map([ + [0, { a: 1, b: 2 }], + [1, { a: 2, b: 3 }], + ]) + ) + + const subscriber = vi.fn() + const scope = new Scope(ost) + scope.subscribe(subscriber) + + const iterator = scope.root.values() + + void iterator.next().value.a + + ost.root.get(0).a = 2 + ost.root.get(0).b = 3 + ost.root.get(1).a = 3 + ost.root.get(1).b = 4 + + expect(subscriber).toHaveBeenCalledTimes(1) + }) + }) + + describe("#entries", () => { + it("tracks property access of items", () => { + const ost = new OST( + new Map([ + [0, { a: 1, b: 2 }], + [1, { a: 2, b: 3 }], + ]) + ) + + const subscriber = vi.fn() + const scope = new Scope(ost) + scope.subscribe(subscriber) + + const iterator = scope.root.entries() + + void iterator.next().value[1].a + + ost.root.get(0).a = 2 + ost.root.get(0).b = 3 + ost.root.get(1).a = 3 + ost.root.get(1).b = 4 + + expect(subscriber).toHaveBeenCalledTimes(1) + }) + }) + + describe("#forEach", () => { + it("tracks property access of items", () => { + const ost = new OST( + new Map([ + [0, { a: 1, b: 2 }], + [1, { a: 2, b: 3 }], + ]) + ) + + const subscriber = vi.fn() + const scope = new Scope(ost) + scope.subscribe(subscriber) + + scope.root.forEach((value, key) => { + if (key === 0) { + void value.a // scope only tracking property "a" of item where key == 0 + } + }) + + ost.root.get(0).a = 2 + ost.root.get(0).b = 3 + ost.root.get(1).a = 3 + ost.root.get(1).b = 4 + + expect(subscriber).toHaveBeenCalledTimes(1) + }) + }) + + describe("Symbol.iterator", () => { + it("tracks property access of items", () => { + const ost = new OST( + new Map([ + [0, { a: 1, b: 2 }], + [1, { a: 2, b: 3 }], + ]) + ) + + const subscriber = vi.fn() + const scope = new Scope(ost) + scope.subscribe(subscriber) + + const iterator = scope.root[Symbol.iterator]() + + void iterator.next().value[1].a + + ost.root.get(0).a = 2 + ost.root.get(0).b = 3 + ost.root.get(1).a = 3 + ost.root.get(1).b = 4 + + expect(subscriber).toHaveBeenCalledTimes(1) + }) + }) + }) + + describe("$set", () => { + describe("scope mutations", () => { + it("can trigger mutations from the scope itself", () => { + const ost = new OST( + new Set([ + { a: 1, b: 2 }, + { a: 2, b: 3 }, + ]) + ) + + const subscriber = vi.fn() + const scope = new Scope(ost) + scope.subscribe(subscriber) + + const iterator = scope.root.values() + + iterator.next().value.a++ + + expect(subscriber).toHaveBeenCalledTimes(1) + }) + }) + + describe("#values", () => { + it("tracks property access of items", () => { + const ost = new OST( + new Set([ + { a: 1, b: 2 }, + { a: 2, b: 3 }, + ]) + ) + + const subscriber = vi.fn() + const scope = new Scope(ost) + scope.subscribe(subscriber) + + const iterator = scope.root.values() + + void iterator.next().value.a + + for (const node of ost.root.values()) { + node.a = 4 + node.b = 5 + } + + expect(subscriber).toHaveBeenCalledTimes(1) + }) + }) + + describe("#entries", () => { + it("tracks property access of items", () => { + const ost = new OST( + new Set([ + { a: 1, b: 2 }, + { a: 2, b: 3 }, + ]) + ) + + const subscriber = vi.fn() + const scope = new Scope(ost) + scope.subscribe(subscriber) + + const iterator = scope.root.entries() + + void iterator.next().value[1].a + + for (const node of ost.root.values()) { + node.a = 4 + node.b = 5 + } + + expect(subscriber).toHaveBeenCalledTimes(1) + }) + }) + + describe("#forEach", () => { + it("tracks property access of items", () => { + const ost = new OST( + new Set([ + { a: 1, b: 2 }, + { a: 2, b: 3 }, + ]) + ) + + const subscriber = vi.fn() + const scope = new Scope(ost) + scope.subscribe(subscriber) + + scope.root.forEach((value) => { + void value.a + }) + + const iterator = ost.root.values() + + const first = iterator.next().value + const second = iterator.next().value + + first.a = 2 + first.b = 3 + second.a = 3 + second.b = 4 + + expect(subscriber).toHaveBeenCalledTimes(2) + }) + }) + + describe("Symbol.iterator", () => { + it("tracks property access of items", () => { + const ost = new OST( + new Set([ + { a: 1, b: 2 }, + { a: 2, b: 3 }, + ]) + ) + + const subscriber = vi.fn() + const scope = new Scope(ost) + scope.subscribe(subscriber) + + const iterator = scope.root[Symbol.iterator]() + + void iterator.next().value.a + + for (const node of ost.root.values()) { + node.a = 4 + node.b = 5 + } + + expect(subscriber).toHaveBeenCalledOnce() + }) + }) + + describe("#has", () => { + it("understands raw/unproxied values as arguments", () => { + const item1 = { a: 1, b: 2 } + const item2 = { a: 2, b: 3 } + + const ost = new OST(new Set([item1, item2])) + + const scope = new Scope(ost) + const ostValues = ost.root.values() + const scopeValues = scope.root.values() + + const node1 = ostValues.next().value + const node2 = ostValues.next().value + + const scoped1 = scopeValues.next().value + const scoped2 = scopeValues.next().value + + expect(scope.root.has(item1)).toBe(true) + expect(scope.root.has(item2)).toBe(true) + + expect(scope.root.has(node1)).toBe(true) + expect(scope.root.has(node2)).toBe(true) + + expect(scope.root.has(scoped1)).toBe(true) + expect(scope.root.has(scoped2)).toBe(true) + }) + }) + + describe("#difference", () => { + it("can path track items in the result diff", () => { + const item1 = { a: 1, b: 2 } + const item2 = { a: 2, b: 3 } + const item3 = { a: 4, b: 5 } + const setA = new Set([item1, item2, item3]) + + const setB = new Set([item3]) + + const ost = new OST(setA) + + const subscriber = vi.fn() + const scope = new Scope(ost) + scope.subscribe(subscriber) + + const scoped = scope.root.difference(setB) + + const iterator = scoped.values() + + void iterator.next().value.a + + for (const node of ost.root.values()) { + node.a = 4 + node.b = 5 + } + + expect(subscriber).toHaveBeenCalledTimes(1) + }) + }) + + describe("#intersection", () => { + it("can path track items in the result diff", () => { + const item1 = { a: 1, b: 2 } + const item2 = { a: 2, b: 3 } + const item3 = { a: 4, b: 5 } + const setA = new Set([item1, item2, item3]) + + const setB = new Set([item2, item3]) + + const ost = new OST(setA) + + const subscriber = vi.fn() + const scope = new Scope(ost) + scope.subscribe(subscriber) + + const scoped = scope.root.intersection(setB) + + const iterator = scoped.values() + + void iterator.next().value.a + + for (const node of ost.root.values()) { + node.a = 4 + node.b = 5 + } + + expect(subscriber).toHaveBeenCalledTimes(1) + }) + }) + }) +}) diff --git a/packages/arbor-ost/tests/vitest.d.ts b/packages/arbor-ost/tests/vitest.d.ts new file mode 100644 index 00000000..a4ba520e --- /dev/null +++ b/packages/arbor-ost/tests/vitest.d.ts @@ -0,0 +1,14 @@ +import "vitest" +import { Node, Value } from "../src/types" +import { OST } from "../src/ost" + +declare module "vitest" { + interface Assertion { + toBeDetachedFrom: (ost: OST) => T + toBeNodeOf: (value: Value) => T + toBeRootNode: () => T + toHaveNodeFor: (value: Value) => T + toHaveParentNode: (parent?: Node) => T + toHaveNodeValuePair: (pair: [unknown, unknown]) => T + } +} diff --git a/packages/arbor-ost/tsconfig.eslint.json b/packages/arbor-ost/tsconfig.eslint.json new file mode 100644 index 00000000..52d5d6d5 --- /dev/null +++ b/packages/arbor-ost/tsconfig.eslint.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/**/*", "tests/**/*", "vitest.config.ts"], + "exclude": [""] +} diff --git a/packages/arbor-ost/tsconfig.json b/packages/arbor-ost/tsconfig.json new file mode 100644 index 00000000..fa4067b1 --- /dev/null +++ b/packages/arbor-ost/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "baseUrl": "./src", + "outDir": "./dist" + }, + "include": ["src/**/*", "tests/**/*"] +} diff --git a/packages/arbor-ost/vitest.config.ts b/packages/arbor-ost/vitest.config.ts new file mode 100644 index 00000000..737d0d6b --- /dev/null +++ b/packages/arbor-ost/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config" + +export default defineConfig({ + test: { + setupFiles: ["tests/matchers/index.ts"], + }, +}) diff --git a/packages/arbor-react/tests/vitest.d.ts b/packages/arbor-react/tests/vitest.d.ts index a5131cef..1e345900 100644 --- a/packages/arbor-react/tests/vitest.d.ts +++ b/packages/arbor-react/tests/vitest.d.ts @@ -6,8 +6,8 @@ declare module "vitest" { toBeArborNode: () => T toBeScopedNode: () => T toBeProxiedExactlyOnce: () => T - toBeNodeOf: (expected: unknown) => T - toHaveNodeFor: (expected: unknown) => T + toBeNodeOf: (expected: object) => T + toHaveNodeFor: (expected: object) => T toHaveLinkFor: (expected: unknown) => T } } diff --git a/packages/arbor-store/src/arbor.ts b/packages/arbor-store/src/arbor.ts index 1963bca5..55f1a34f 100644 --- a/packages/arbor-store/src/arbor.ts +++ b/packages/arbor-store/src/arbor.ts @@ -139,6 +139,8 @@ export class Arbor { */ #paths = new SeedMap() + #seeds = new WeakMap() + /** * Create a new Arbor instance. * @@ -150,15 +152,32 @@ export class Arbor { } getLinkFor(value: object): Link | undefined { - return this.#links.getFor(value) + return this.#links.getFor(this.getSeedFor(value)) } getNodeFor(value: V | Seed): Node | undefined { - return this.#nodes.getFor(value) as Node + return this.#nodes.getFor(this.getSeedFor(value)) as Node } getPathFor(value: object): Path | undefined { - return this.#paths.getFor(value) + return this.#paths.getFor(this.getSeedFor(value)) + } + + getSeedFor(value: object): Seed | undefined { + if (value instanceof Seed) { + return value + } + + return this.#seeds.get(value) + } + + plantSeedFor(value: object): Seed { + const seed = this.#seeds.get(value) + if (!seed) { + this.#seeds.set(value, new Seed()) + } + + return this.#seeds.get(value) } getNodeAt(path: Path): Node | undefined { @@ -173,7 +192,8 @@ export class Arbor { const node = this.getNodeFor(value) if (node) { - const seed = Seed.from(node) + // const seed = Seed.from(node) + const seed = this.getSeedFor(node.$value) node.$subscriptions.reset() this.#nodes.delete(seed) @@ -183,7 +203,8 @@ export class Arbor { } attachNode(node: Node, link?: Link, path?: Path) { - const seed = Seed.from(node) + // const seed = Seed.from(node) + const seed = this.getSeedFor(node.$value) if (seed) { this.#nodes.set(seed, node) @@ -242,7 +263,8 @@ export class Arbor { childValue: V ): Node | undefined { if (!this.getNodeFor(childValue)) { - const childPath = pathFor(parent).child(Seed.plant(childValue)) + const seed = this.plantSeedFor(childValue) + const childPath = pathFor(parent).child(seed) this.createNode(childPath, childValue, link) } @@ -299,7 +321,7 @@ export class Arbor { * @returns the root node. */ setState(value: T): ArborNode { - const seed = Seed.plant(value) + const seed = this.plantSeedFor(value) const path = Path.root(seed) this.#root = this.createNode( diff --git a/packages/arbor-store/src/handlers/default.ts b/packages/arbor-store/src/handlers/default.ts index b6dd328f..e3832782 100644 --- a/packages/arbor-store/src/handlers/default.ts +++ b/packages/arbor-store/src/handlers/default.ts @@ -1,7 +1,6 @@ import { Arbor } from "../arbor" import { isDetachedProperty } from "../decorators" import { isNode, isProxiable } from "../guards" -import { Seed } from "../path" import { Subscriptions } from "../subscriptions" import type { Link, Node } from "../types" import { isGetter, pathFor, recursivelyUnwrap } from "../utilities" @@ -113,6 +112,13 @@ export class DefaultHandler return true } + // This is problematic because it will move nodes around the OST, making the behavior + // unepected from a developer standpoint. + // + // Ideally Arbor would hold state as an Observable State Graph with a root node instead of a Tree. + // This would allow nodes to have multiple paths in the graph from the root node, which + // could enable for application state to hold multiple references to the same node on different + // parts of the state. if (isNode(newValue)) { // Detaches the previous node from the state tree since it's being overwritten by a new one if (target[prop]) { @@ -121,7 +127,7 @@ export class DefaultHandler // In case the new value happens to be an existing node, we preemptively add it back to the // state tree so that stale references to this node can continue to trigger mutations. - const path = pathFor(this).child(Seed.plant(value)) + const path = pathFor(this).child(this.$tree.plantSeedFor(value)) this.$tree.createNode(path, value, prop) } diff --git a/packages/arbor-store/tests/arbor.test.ts b/packages/arbor-store/tests/arbor.test.ts index 54bed9b3..b9d28518 100644 --- a/packages/arbor-store/tests/arbor.test.ts +++ b/packages/arbor-store/tests/arbor.test.ts @@ -10,7 +10,7 @@ import { isNode } from "../src/guards" describe("Arbor", () => { describe("state tree", () => { - it("updates a node within the state tree", () => { + it.only("updates a node within the state tree", () => { const store = new Arbor([ { name: "Carol", active: true }, { name: "Alice", active: true }, diff --git a/packages/arbor-store/tests/utilities/isDetached.test.ts b/packages/arbor-store/tests/utilities/isDetached.test.ts index 31cf08bd..7d67793f 100644 --- a/packages/arbor-store/tests/utilities/isDetached.test.ts +++ b/packages/arbor-store/tests/utilities/isDetached.test.ts @@ -25,4 +25,34 @@ describe("isDetached", () => { expect(isDetached(node)).toBe(true) }) + + it("returns true if the node belongs to a detached path", () => { + const store = new Arbor({ + todos: [ + { id: 1, text: "Do the dishes", author: { name: "Alice" } }, + { id: 2, text: "Walk the dogs", author: { name: "Bob" } }, + ], + }) + + const alice = store.state.todos[0].author + delete store.state.todos[0] + + expect(isDetached(alice)).toBe(true) + }) + + it("returns false for array items that moved positions but still exist in the observable state tree", () => { + const store = new Arbor({ + todos: [ + { id: 1, text: "Do the dishes" }, + { id: 2, text: "Walk the dogs" }, + ], + }) + + const todo1 = store.state.todos[0] + const todo2 = store.state.todos[1] + delete store.state.todos[0] + + expect(isDetached(todo1)).toBe(true) + expect(isDetached(todo2)).toBe(false) + }) }) diff --git a/packages/arbor-store/tests/vitest.d.ts b/packages/arbor-store/tests/vitest.d.ts index eda6a341..1b62d5ca 100644 --- a/packages/arbor-store/tests/vitest.d.ts +++ b/packages/arbor-store/tests/vitest.d.ts @@ -9,8 +9,8 @@ declare module "vitest" { toBeScopedNode: () => T toBeScoping: (node: ArborNode, prop: keyof D) => T toBeProxiedExactlyOnce: () => T - toBeNodeOf: (expected: unknown) => T - toHaveNodeFor: (expected: unknown) => T + toBeNodeOf: (expected: object) => T + toHaveNodeFor: (expected: object) => T toHaveLinkFor: (expected: unknown) => T toHaveLink: (link?: Link) => T } diff --git a/tsconfig.json b/tsconfig.json index 315ac0ef..05389a66 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,13 +5,16 @@ "esModuleInterop": true, "isolatedModules": true, "module": "CommonJS", - "target": "ES2015", + "target": "ES2020", "declaration": true, "declarationMap": true, "emitDeclarationOnly": true, "experimentalDecorators": true, - "lib": ["es2022", "dom", "dom.iterable"], - "jsx": "react" + "lib": ["es2023", "dom", "dom.iterable"], + "jsx": "react", + "typeRoots": ["./node_modules/@types"], + "types": [] }, + "include": ["**/*.ts", "**/*.d.ts"], "exclude": ["examples"] } diff --git a/yarn.lock b/yarn.lock index a88b66c7..e814ed53 100644 --- a/yarn.lock +++ b/yarn.lock @@ -23,6 +23,18 @@ __metadata: languageName: unknown linkType: soft +"@arborjs/ost@workspace:packages/arbor-ost": + version: 0.0.0-use.local + resolution: "@arborjs/ost@workspace:packages/arbor-ost" + dependencies: + esbuild: ^0.17.19 + eslint: ^8.40.0 + prettier: ^2.8.8 + typescript: ^5.0.4 + vitest: ^1.6.0 + languageName: unknown + linkType: soft + "@arborjs/plugins@workspace:^, @arborjs/plugins@workspace:packages/arbor-plugins": version: 0.0.0-use.local resolution: "@arborjs/plugins@workspace:packages/arbor-plugins" @@ -5760,7 +5772,7 @@ fsevents@~2.3.2: eslint-plugin-import: ^2.27.5 eslint-plugin-jsx-a11y: ^6.7.1 turbo: ^1.9.4 - typescript: ^5.0.4 + typescript: ^5.5.0 languageName: unknown linkType: soft @@ -6560,6 +6572,16 @@ fsevents@~2.3.2: languageName: node linkType: hard +"typescript@npm:^5.5.0": + version: 5.8.3 + resolution: "typescript@npm:5.8.3" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: cb1d081c889a288b962d3c8ae18d337ad6ee88a8e81ae0103fa1fecbe923737f3ba1dbdb3e6d8b776c72bc73bfa6d8d850c0306eed1a51377d2fccdfd75d92c4 + languageName: node + linkType: hard + "typescript@patch:typescript@^5.0.4#~builtin": version: 5.0.4 resolution: "typescript@patch:typescript@npm%3A5.0.4#~builtin::version=5.0.4&hash=7ad353" @@ -6570,6 +6592,16 @@ fsevents@~2.3.2: languageName: node linkType: hard +"typescript@patch:typescript@^5.5.0#~builtin": + version: 5.8.3 + resolution: "typescript@patch:typescript@npm%3A5.8.3#~builtin::version=5.8.3&hash=7ad353" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 1b503525a88ff0ff5952e95870971c4fb2118c17364d60302c21935dedcd6c37e6a0a692f350892bafcef6f4a16d09073fe461158547978d2f16fbe4cb18581c + languageName: node + linkType: hard + "ufo@npm:^1.5.3": version: 1.5.3 resolution: "ufo@npm:1.5.3" @@ -6781,7 +6813,7 @@ fsevents@~2.3.2: languageName: node linkType: hard -"vitest@npm:^1.6.1": +"vitest@npm:^1.6.0, vitest@npm:^1.6.1": version: 1.6.1 resolution: "vitest@npm:1.6.1" dependencies: