Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
174 changes: 174 additions & 0 deletions client/lib/gumnut/data.ts
Original file line number Diff line number Diff line change
@@ -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<string, DataTypeInterface>((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<GumnutData, GumnutData>();

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;
}
24 changes: 24 additions & 0 deletions tests/integration/data.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading