diff --git a/client/lib/gumnut/data.ts b/client/lib/gumnut/data.ts new file mode 100644 index 0000000..766394c --- /dev/null +++ b/client/lib/gumnut/data.ts @@ -0,0 +1,174 @@ +import { SimpleCache, todoSignal } from 'thorish'; +import type { FloorReadApi, GumnutFloor } from './floor.ts'; +import { emptySymbol, type GumnutData, InoPosRef } from './types.ts'; + +const leadingRef = '^'; + +export type DataTypeInterface = DataMap | DataArray | DataString; + +export type DataType = + | DataTypeInterface + // native types + | null + | undefined + | number + | boolean + | string + | bigint + // not-regular-JS types + | InoPosRef + | typeof emptySymbol; + +export function buildDataApi(gf: GumnutFloor): DataApi { + return new DataApiImpl(gf); +} + +export interface DataApi { + root(): DataMap; +} + +class DataApiImpl implements DataApi { + public readonly byId: (id: string) => DataTypeInterface; + + constructor(public readonly gf: GumnutFloor) { + this.byId = this.cache.get.bind(this.cache); + } + + private readonly cache = new SimpleCache((name) => { + if (name.endsWith(':m')) { + return new DataMapImpl(this, name); + } + throw new Error(`TODO: unhandled ${name}`); + }); + + root(): DataMap { + return this.cache.get('root:m') as DataMap; + } + + /** + * Exists to steal/ref to other "...data" types. + */ + convertToDataType(data: GumnutData): DataType { + if (typeof data === 'string') { + if (data[0] === leadingRef) { + if (data[1] !== leadingRef) { + return this.byId(data.slice(1)); + } + data = data.slice(1); // unescape leading ref + } + return data; // intern string + } + + return data; + } + + fromDataType(data: DataType): GumnutData { + if (typeof data === 'string' && data[0] === leadingRef) { + return leadingRef + data; // escape leading ref + } + + if (data instanceof DataMap) { + return leadingRef + (data as DataMapImpl).name; + } else if (data instanceof DataArray) { + throw 'TODO DataArray'; + } else if (data instanceof DataString) { + throw 'TODO DataString'; + } + + return data; + } +} + +export abstract class DataMap { + abstract length(): number; + abstract get(key: DataType): DataType; + abstract set(key: DataType, value: DataType): void; +} + +class DataMapImpl extends DataMap { + private readonly ino: FloorReadApi; + private readonly cache = new Map(); + + constructor( + private readonly api: DataApiImpl, + public readonly name: string, + ) { + super(); + this.ino = this.api.gf.byIno(this.name); + this.refreshCache(); + + // FIXME: too much refreshing + // FIXME: when do we "stop" refreshing? + this.api.gf.addListener(todoSignal, (c) => { + if (c.has(this.name)) { + this.refreshCache(); + } + }); + } + + private refreshCache() { + const d = this.ino.data(); + this.cache.clear(); + + for (let i = 0; i < d.length; i += 2) { + const k = d[i]; + const v = d[i + 1]; + + if (k instanceof InoPosRef) { + // TODO: some other way to avoid this? + throw new Error(`can't use InoPosRef as key`); + } + + this.cache.set(k, v); + } + } + + length(): number { + // datamap is stored as k,v pairs + return this.ino.length() >> 1; + } + + get(key: DataType): DataType { + const res = this.cache.get(this.api.fromDataType(key)); + return this.api.convertToDataType(res); + } + + set(key: DataType, value: DataType): void { + const outer = this; + + this.api.gf.perform(function* (t) { + const k = outer.api.fromDataType(key); + const v = outer.api.fromDataType(value); + + const ino = t.byIno(outer.name); + for (;;) { + const d = ino.data(); + + for (let i = 0; i < d.length; i += 2) { + if (k === d[i]) { + // found it + ino.set(i + 2, v); + outer.refreshCache(); // TODO: we can be more surgical? + return; + } + } + + // not found, insert + ino.dataUpdate(d.length, 0, k, v); + outer.refreshCache(); // TODO: we can be more surgical? + yield [outer.name]; + } + }); + } +} + +export abstract class DataArray { + abstract length(): number; + abstract get(index: number): DataType; + abstract set(index: number, value: DataType): void; +} + +export abstract class DataString { + abstract length(): number; + abstract slice(start?: number, end?: number): string; +} diff --git a/tests/integration/data.test.ts b/tests/integration/data.test.ts new file mode 100644 index 0000000..5e5e2d5 --- /dev/null +++ b/tests/integration/data.test.ts @@ -0,0 +1,24 @@ +import { test } from 'node:test'; +import * as assert from 'node:assert'; +import { connectToGumnutLow } from './util.ts'; +import { buildDataApi } from '../../client/lib/gumnut/data.ts'; +import { checkCond } from 'thorish'; + +test('data', async (t) => { + const doc1 = await connectToGumnutLow(t.signal); + const doc2 = await connectToGumnutLow(t.signal, { docId: doc1.docId }); + + const data1 = buildDataApi(doc1.floor); + const data2 = buildDataApi(doc2.floor); + + data1.root().set('abc', 'hello'); + data1.root().set('abc', 'hello123'); + // data1.root().set('def', data1.root()); // FIXME: this is failing to apply over itself - seq3 + + await checkCond(() => { + const root = data2.root(); + + assert.strictEqual(root.get('abc'), 'hello123'); + // assert.strictEqual(root.get('def'), root); + }); +});