diff --git a/packages/primitives/src/index.test.ts b/packages/primitives/src/index.test.ts index afedb506..a6a93015 100644 --- a/packages/primitives/src/index.test.ts +++ b/packages/primitives/src/index.test.ts @@ -1,5 +1,6 @@ import './reatomArray.test' -import './reatomRecord.test' import './reatomEnum.test' +import './reatomLinkedList.test' +import './reatomRecord.test' import './reatomString.test' -import './withComputed' +import './withComputed.test' diff --git a/packages/primitives/src/index.ts b/packages/primitives/src/index.ts index e922d756..0e5bbd0c 100644 --- a/packages/primitives/src/index.ts +++ b/packages/primitives/src/index.ts @@ -2,6 +2,7 @@ export * from './reatomArray' export * from './reatomBoolean' export * from './reatomEnum' export * from './reatomMap' +export * from './reatomLinkedList' export * from './reatomNumber' export * from './reatomRecord' export * from './reatomSet' diff --git a/packages/primitives/src/reatomArray.test.ts b/packages/primitives/src/reatomArray.test.ts index 24f57930..4b4a590a 100644 --- a/packages/primitives/src/reatomArray.test.ts +++ b/packages/primitives/src/reatomArray.test.ts @@ -1,8 +1,10 @@ import { createCtx } from '@reatom/core' -import { test } from 'uvu' +import { suite } from 'uvu' import * as assert from 'uvu/assert' import { reatomArray } from './reatomArray' + +const test = suite('reatomArray') test(`reatomArray. init`, () => { const ctx = createCtx() diff --git a/packages/primitives/src/reatomEnum.test.ts b/packages/primitives/src/reatomEnum.test.ts index 0a1f6a86..54d4fc2f 100644 --- a/packages/primitives/src/reatomEnum.test.ts +++ b/packages/primitives/src/reatomEnum.test.ts @@ -1,9 +1,11 @@ import { createCtx } from '@reatom/core' -import { test } from 'uvu' +import { suite } from 'uvu' import * as assert from 'uvu/assert' import { reatomEnum } from './reatomEnum' +const test = suite('reatomEnum') + test(`reatomEnum. static enum property`, async () => { const enumAtom = reatomEnum(['a', 'b']) diff --git a/packages/primitives/src/reatomLinkedList.test.ts b/packages/primitives/src/reatomLinkedList.test.ts new file mode 100644 index 00000000..13c1ef0c --- /dev/null +++ b/packages/primitives/src/reatomLinkedList.test.ts @@ -0,0 +1,139 @@ +import { atom } from '@reatom/core' +import { createTestCtx } from '@reatom/testing' +import { suite } from 'uvu' +import * as assert from 'uvu/assert' + +import { reatomLinkedList } from './reatomLinkedList' +import { parseAtoms } from '@reatom/lens' + +const test = suite('reatomLinkedList') + +test(`should respect initState, create and remove elements properly`, () => { + const ctx = createTestCtx() + const list = reatomLinkedList({ + create: (ctx, n: number) => atom(n), + initState: [atom(1), atom(2)], + }) + + const last = list.create(ctx, 3) + assert.equal( + ctx.get(list.array).map((v) => ctx.get(v)), + [1, 2, 3], + ) + + list.remove(ctx, last) + assert.equal(parseAtoms(ctx, list.array), [1, 2]) + list.remove(ctx, last) + assert.equal(parseAtoms(ctx, list.array), [1, 2]) + + list.remove(ctx, list.find(ctx, (n) => ctx.get(n) === 1)!) + assert.equal(parseAtoms(ctx, list.array), [2]) + + list.remove(ctx, list.find(ctx, (n) => ctx.get(n) === 2)!) + assert.equal(parseAtoms(ctx, list.array), []) + + try { + list.remove(ctx, list.find(ctx, (n) => ctx.get(n) === 2)!) + assert.ok(false, 'Error expected') + } catch (error: any) { + assert.is( + error?.message, + 'Reatom error: The passed data is not a linked list node.', + ) + } +}) + +test(`should swap elements`, () => { + const ctx = createTestCtx() + const list = reatomLinkedList((ctx, n: number) => ({ n })) + const { array } = list.reatomMap((ctx, { n }) => ({ n })) + const track = ctx.subscribeTrack( + atom((ctx) => ctx.spy(array).map(({ n }) => n)), + ) + const one = list.create(ctx, 1) + const two = list.create(ctx, 2) + const three = list.create(ctx, 3) + const four = list.create(ctx, 4) + + // const [one, two, three, four] = ctx.get( + // () => + // [ + // list.create(ctx, 1), + // list.create(ctx, 2), + // list.create(ctx, 3), + // list.create(ctx, 4), + // ] as const, + // ) + + assert.equal(track.lastInput(), [1, 2, 3, 4]) + + list.swap(ctx, one, two) + assert.equal(track.lastInput(), [2, 1, 3, 4]) + + list.swap(ctx, one, two) + assert.equal(track.lastInput(), [1, 2, 3, 4]) + + list.swap(ctx, three, four) + assert.equal(track.lastInput(), [1, 2, 4, 3]) + + list.swap(ctx, four, three) + assert.equal(track.lastInput(), [1, 2, 3, 4]) + + list.remove(ctx, two) + assert.equal(track.lastInput(), [1, 3, 4]) + + list.remove(ctx, three) + assert.equal(track.lastInput(), [1, 4]) + + list.swap(ctx, four, one) + assert.equal(track.lastInput(), [4, 1]) + + list.swap(ctx, four, one) + assert.equal(track.lastInput(), [1, 4]) + + list.remove(ctx, one) + assert.equal(track.lastInput(), [4]) + + // TODO + // assert.throws(() => list.swap(ctx, four, one)) + + list.clear(ctx) + assert.equal(parseAtoms(ctx, list.array), []) +}) + +test(`should move elements`, () => { + const ctx = createTestCtx() + const list = reatomLinkedList((ctx, n: number) => ({ n })) + const one = list.create(ctx, 1) + const two = list.create(ctx, 2) + const three = list.create(ctx, 3) + const four = list.create(ctx, 4) + const track = ctx.subscribeTrack(list.array) + + assert.equal( + track.lastInput().map(({ n }) => n), + [1, 2, 3, 4], + ) + + list.move(ctx, one, four) + assert.equal( + track.lastInput().map(({ n }) => n), + [2, 3, 4, 1], + ) + assert.is(track.calls.length, 2) + + list.move(ctx, one, four) + assert.equal( + track.lastInput().map(({ n }) => n), + [2, 3, 4, 1], + ) + assert.is(track.calls.length, 2) + + list.move(ctx, one, null) + assert.equal( + track.lastInput().map(({ n }) => n), + [1, 2, 3, 4], + ) +}) + +test.run() diff --git a/packages/primitives/src/reatomLinkedList.ts b/packages/primitives/src/reatomLinkedList.ts new file mode 100644 index 00000000..527b09dd --- /dev/null +++ b/packages/primitives/src/reatomLinkedList.ts @@ -0,0 +1,555 @@ +import { + type Atom, + type Action, + type Ctx, + type Rec, + atom, + action, + type Fn, + throwReatomError, + __count, +} from '@reatom/core' +import { isObject } from '@reatom/utils' + +const readonly = ( + anAtom: T, +): { + [K in keyof T]: T[K] +} => ({ + ...anAtom, +}) + +export const LL_PREV = Symbol('Reatom linked list prev') +export const LL_NEXT = Symbol('Reatom linked list next') + +/** Linked List is reusing the model reference to simplify the reference sharing and using it as a key of LL methods. + * Btw, symbols works fine with serialization and will not add a garbage to an output. + */ +export type LLNode = T & { + [LL_PREV]: null | LLNode + [LL_NEXT]: null | LLNode +} + +type LLChanges = + | { kind: 'create'; node: Node } + | { kind: 'remove'; node: Node } + | { kind: 'swap'; a: Node; b: Node } + | { kind: 'move'; node: Node; after: null | Node } + | { kind: 'clear' } + +export interface LinkedList { + head: null | Node + tail: null | Node + size: number + version: number + changes: Array> +} + +export interface LinkedListLikeAtom + extends Atom { + __reatomLinkedList: true +} + +export interface LinkedListAtom< + Params extends any[] = any[], + Node extends Rec = Rec, +> extends LinkedListLikeAtom>> { + batch: Action<[cb: Fn], void> + + create: Action> + remove: Action<[LLNode], boolean> + swap: Action<[a: LLNode, b: LLNode], void> + move: Action<[node: LLNode, after: null | LLNode], void> + clear: Action<[], void> + + find: (ctx: Ctx, cb: (node: LLNode) => boolean) => null | LLNode + + array: Atom>> + + reatomMap: ( + cb: (ctx: Ctx, node: LLNode) => T, + options?: + | string + | { + name?: string + onCreate?: (ctx: Ctx, node: LLNode) => void + onRemove?: (ctx: Ctx, node: LLNode, origin: LLNode) => void + onSwap?: (ctx: Ctx, payload: { a: LLNode; b: LLNode }) => void + onMove?: (ctx: Ctx, node: LLNode) => void + onClear?: ( + ctx: Ctx, + lastState: LinkedListDerivedState, LLNode>, + ) => void + }, + ) => LinkedListDerivedAtom, LLNode> + + // reatomFilter: ( + // cb: (ctx: CtxSpy, node: Node) => any, + // name?: string, + // ) => Atom> + + // reatomReduce: ( + // options: { + // init: T + // add: (ctx: CtxSpy, acc: T, node: LLNode) => T + // del: (ctx: Ctx, acc: T, node: LLNode) => T + // }, + // name?: string, + // ) => Atom +} + +export interface LinkedListDerivedState + extends LinkedList { + map: WeakMap +} + +export interface LinkedListDerivedAtom + extends LinkedListLikeAtom> { + array: Atom> +} + +const addLL = ( + state: LinkedList, + node: Node | Omit, + after: null | Node, +) => { + if (node === after) return + + if (after) { + ;(node as Node)[LL_PREV] = after + ;(node as Node)[LL_NEXT] = after[LL_NEXT] + after[LL_NEXT] = node as Node + if (state.tail === after) { + state.tail = node as Node + } + } else { + ;(node as Node)[LL_PREV] = null + ;(node as Node)[LL_NEXT] = state.head + if (state.head) { + state.head[LL_PREV] = node as Node + } + if (!state.tail) { + state.tail = node as Node + } + state.head = node as Node + } + state.size++ +} + +const removeLL = (state: LinkedList, node: Node) => { + if (state.head === node) { + state.head = node[LL_NEXT] as Node + } else if (node[LL_PREV] !== null) { + node[LL_PREV][LL_NEXT] = node[LL_NEXT] + } + + if (state.tail === node) { + state.tail = node[LL_PREV] as Node + } else if (node[LL_NEXT] !== null) { + node[LL_NEXT][LL_PREV] = node[LL_PREV] + } + + node[LL_PREV] = null + node[LL_NEXT] = null + + state.size-- +} + +const swapLL = ( + state: LinkedList, + a: Node, + b: Node, +): void => { + if (a === b) return + if (state.head === b) return swapLL(state, b, a) + + const prevA = a[LL_PREV] === b ? b[LL_PREV] : a[LL_PREV] + const prevB = b[LL_PREV] === a ? a[LL_PREV] : b[LL_PREV] + + removeLL(state, a) + removeLL(state, b) + addLL(state, a, prevB) + addLL(state, b, prevA) +} + +const moveLL = ( + state: LinkedList, + node: Node, + after: null | Node, +) => { + removeLL(state, node) + addLL(state, node, after) +} + +const clearLL = (state: LinkedList) => { + while (state.tail) removeLL(state, state.tail) +} + +const toArray = ( + head: null | LLNode, + prev?: Array>, +): Array> => { + let arr: Array> = [] + let i = 0 + while (head) { + if (prev !== undefined && prev[i] !== head) prev = undefined + arr.push(head) + head = head[LL_NEXT] + i++ + } + return arr.length === prev?.length ? prev : arr +} + +export const reatomLinkedList = ( + options: + | ((ctx: Ctx, ...params: Params) => Node) + | { + create: (ctx: Ctx, ...params: Params) => Node + initState?: Array + }, + name = __count('reatomLinkedList'), +): LinkedListAtom => { + const { create: userCreate, initState = [] } = + typeof options === 'function' ? { create: options } : options + const _name = name + + const isLL = (node: Node): node is LLNode => + !!node && LL_NEXT in node && LL_PREV in node + + const throwModel = (node: Node) => + throwReatomError(isLL(node), 'The data is already in a linked list.') + const throwNotModel = (node: Node) => + throwReatomError(!isLL(node), 'The passed data is not a linked list node.') + + // for batching + let STATE: null | LinkedList> = null + + const linkedList = atom(STATE!, name) + linkedList.__reatom.initState = () => { + try { + STATE = { + size: 0, + version: 1, + changes: [], + head: null, + tail: null, + } + + for (const node of initState) { + throwModel(node) + addLL(STATE, node, STATE.tail) + } + + return STATE + } finally { + STATE = null + } + } + + const batch = action((ctx, cb: Fn) => { + if (STATE) return STATE + + STATE = linkedList(ctx, ({ head, tail, size, version }) => ({ + size, + version: version + 1, + changes: [], + head, + tail, + })) + + try { + return cb(ctx) + } finally { + STATE = null + } + }, `${name}._batch`) + + const create = action((ctx, ...params: Params): LLNode => { + if (!STATE) return batch(ctx, () => create(ctx, ...params)) + + const node = userCreate(ctx, ...params) as LLNode + + throwReatomError( + !isObject(node) && typeof node !== 'function', + `reatomLinkedList can operate only with objects or functions, received "${node}".`, + ) + throwModel(node) + + addLL(STATE, node, STATE.tail) + + STATE.changes.push({ kind: 'create', node }) + + return node + }, `${name}.create`) + + const remove = action((ctx, node: LLNode): boolean => { + if (!STATE) return batch(ctx, () => remove(ctx, node)) + throwNotModel(node) + + removeLL(STATE, node) + + STATE.changes.push({ kind: 'remove', node }) + + return true + }, `${name}.remove`) + + const swap = action((ctx, a: LLNode, b: LLNode): void => { + if (!STATE) return batch(ctx, () => swap(ctx, a, b)) + + throwNotModel(a) + throwNotModel(b) + + if (a === b) return + + swapLL(STATE, a, b) + + STATE.changes.push({ kind: 'swap', a, b }) + }, `${name}.swap`) + + const move = action( + (ctx, node: LLNode, after: null | LLNode): void => { + if (!STATE) return batch(ctx, () => move(ctx, node, after)) + throwNotModel(node) + + moveLL(STATE, node, after) + + STATE.changes.push({ kind: 'move', node, after }) + }, + `${name}.move`, + ) + + const clear = action((ctx): void => { + if (!STATE) return batch(ctx, () => clear(ctx)) + + clearLL(STATE) + + STATE.changes.push({ kind: 'clear' }) + }, `${name}.clear`) + + const find = ( + ctx: Ctx, + cb: (node: LLNode) => boolean, + ): null | LLNode => { + for (let { head } = ctx.get(linkedList); head; head = head[LL_NEXT]) { + if (cb(head)) return head + } + return null + } + + const array = atom( + (ctx, state: Array> = []) => + toArray(ctx.spy(linkedList).head, state), + `${name}.array`, + ) + + const reatomMap = ( + cb: (ctx: Ctx, node: LLNode) => T, + options: + | string + | { + name?: string + onCreate?: (ctx: Ctx, node: LLNode) => void + onRemove?: (ctx: Ctx, node: LLNode, origin: LLNode) => void + onSwap?: (ctx: Ctx, payload: { a: LLNode; b: LLNode }) => void + onMove?: (ctx: Ctx, node: LLNode) => void + onClear?: ( + ctx: Ctx, + lastState: LinkedListDerivedState, LLNode>, + ) => void + } = {}, + ): LinkedListDerivedAtom, LLNode> => { + const { name = __count(`${_name}.reatomMap`), ...hooks } = + typeof options === 'string' ? { name: options } : options + + type State = LinkedListDerivedState, LLNode> + + const mapList = atom((ctx, mapList?: State): State => { + throwReatomError( + STATE, + `Can't compute the map of the linked list inside the batching.`, + ) + + const ll = ctx.spy(linkedList) + + if ( + !mapList || + /* some update was missed */ ll.version - 1 > mapList.version + ) { + if (mapList) hooks.onClear?.(ctx, mapList) + + mapList = { + size: ll.size, + version: ll.version, + changes: [], + head: null, + tail: null, + map: new WeakMap(), + } + + for (let head = ll.head; head; head = head[LL_NEXT]) { + const node = cb(ctx, head) as LLNode + addLL(mapList, node, mapList.tail) + mapList.map.set(head, node) + hooks.onCreate?.(ctx, node) + } + // cover extra size changes from `addLL` + mapList.size = ll.size + } else { + mapList = { + head: mapList.head, + tail: mapList.tail, + size: mapList.size, + version: ll.version, + changes: [], + map: mapList.map, + } + + for (const change of ll.changes) { + switch (change.kind) { + case 'create': { + const node = cb(ctx, change.node) as LLNode + addLL(mapList, node, mapList.tail) + mapList.map.set(change.node, node) + mapList.changes.push({ kind: 'create', node }) + hooks.onCreate?.(ctx, node) + break + } + case 'remove': { + const node = mapList.map.get(change.node)! + removeLL(mapList, node) + mapList.map.delete(change.node) + mapList.changes.push({ kind: 'remove', node }) + hooks.onRemove?.(ctx, node, change.node) + break + } + case 'swap': { + const a = mapList.map.get(change.a)! + const b = mapList.map.get(change.b)! + swapLL(mapList, a, b) + mapList.changes.push({ kind: 'swap', a, b }) + hooks.onSwap?.(ctx, { a, b }) + break + } + case 'move': { + const node = mapList.map.get(change.node)! + const after = change.after ? mapList.map.get(change.after)! : null + moveLL(mapList, node, after) + mapList.changes.push({ kind: 'move', node, after }) + hooks.onMove?.(ctx, node) + break + } + case 'clear': { + hooks.onClear?.(ctx, mapList) + clearLL(mapList) + mapList.changes.push({ kind: 'clear' }) + break + } + default: { + const kind: never = change + const error = new Error(`Unhandled linked list change "${kind}"`) + throw error + } + } + } + } + + throwReatomError( + mapList.size !== ll.size, + "Inconsistent linked list, is's a bug, please report an issue", + ) + + return mapList + }, name) + + const array: LinkedListDerivedAtom, LLNode>['array'] = atom( + (ctx, state: Array> = []) => + toArray(ctx.spy(mapList).head, state), + `${name}.array`, + ) + + return Object.assign(mapList, { array, __reatomLinkedList: true as const }) + } + + // TODO there is a bug with `del` logic + // const reatomReduce = ( + // { + // init, + // add, + // del, + // }: { + // init: T + // add: (ctx: CtxSpy, acc: T, node: LLNode) => T + // del: (ctx: Ctx, acc: T, node: LLNode) => T + // }, + // name = __count(`${_name}.reatomReduce`), + // ): Atom => { + // const acc = atom(init, `${name}._acc`) + // const controllers = reatomMap( + // (ctx, node) => + // atom( + // (ctx) => { + // acc(ctx, (state) => + // add( + // ctx, + // /* is the first calc */ ctx.cause.listeners.size + // ? del(ctx, state, node) + // : state, + // node, + // ), + // ) + // }, + // __count(`${name}._controllers`), + // ).pipe( + // withAssign((target) => ({ + // unsubscribe: ctx.subscribe(target, noop), + // })), + // ), + // { + // name: `${name}._controllers`, + // onRemove(ctx, node, origin) { + // acc(ctx, (state) => del(ctx, state, origin)) + // node.unsubscribe() + // }, + // onClear(ctx, mapList) { + // for (let head = mapList.head; head; head = head[LL_NEXT]) { + // head.unsubscribe() + // } + // acc(ctx, () => init) + // }, + // }, + // ) + + // onDisconnect(controllers, (ctx) => { + // for (let head = ctx.get(controllers).head; head; head = head[LL_NEXT]) { + // head.unsubscribe() + // } + // }) + + // return atom((ctx) => { + // ctx.spy(controllers) + // return ctx.spy(acc) + // }, name) + // } + + return Object.assign(linkedList, { + batch, + create, + remove, + swap, + move, + clear, + + find, + + array, + + reatomMap, + // reatomFilter, + // reatomReduce, + + __reatomLinkedList: true as const, + }).pipe(readonly) +} + +export const isLinkedListAtom = (thing: any): thing is LinkedListLikeAtom => + thing?.__reatomLinkedList === true diff --git a/packages/primitives/src/reatomRecord.test.ts b/packages/primitives/src/reatomRecord.test.ts index e3b57bee..ed88d911 100644 --- a/packages/primitives/src/reatomRecord.test.ts +++ b/packages/primitives/src/reatomRecord.test.ts @@ -1,7 +1,9 @@ import { createCtx } from '@reatom/core' -import { test } from 'uvu' +import { suite } from 'uvu' import * as assert from 'uvu/assert' -import { reatomRecord } from '../build' +import { reatomRecord } from './reatomRecord' + +const test = suite('reatomRecord') test('reatomRecord', () => { const ctx = createCtx() diff --git a/packages/primitives/src/reatomString.test.ts b/packages/primitives/src/reatomString.test.ts index 7ed15b7d..7250772f 100644 --- a/packages/primitives/src/reatomString.test.ts +++ b/packages/primitives/src/reatomString.test.ts @@ -1,9 +1,11 @@ import { createCtx } from '@reatom/core' -import { test } from 'uvu' +import { suite } from 'uvu' import * as assert from 'uvu/assert' import { reatomString } from './reatomString' +const test = suite('reatomString') + test('reatomString.reset', () => { const ctx = createCtx() const a = reatomString(`string`) diff --git a/packages/primitives/src/withComputed.test.ts b/packages/primitives/src/withComputed.test.ts index 0efbbc2e..aed2f2c1 100644 --- a/packages/primitives/src/withComputed.test.ts +++ b/packages/primitives/src/withComputed.test.ts @@ -1,9 +1,11 @@ import { atom, createCtx } from '@reatom/core' -import { test } from 'uvu' +import { suite } from 'uvu' import * as assert from 'uvu/assert' import { withComputed } from './withComputed' +const test = suite('withComputed') + test('withComputed', () => { const a = atom(0) const b = atom(0).pipe(withComputed((ctx) => ctx.spy(a)))