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
13 changes: 10 additions & 3 deletions client/bin/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ import { InoPosRef } from '../lib/gumnut/types.ts';
import { connectToPulse } from '../lib/pulse/conn.ts';
import type { InPacket, OutPacket } from '../lib/ymodel/types.ts';

const FIXED_STRING = 'fixed-string';
const FIXED_CURSORS = 'cursors';
const FIXED_NUM = 'num';
const FIXED_STRING = 'n:100';
const FIXED_CURSORS = 'n:101';
const FIXED_NUM = 'n:102';

const c = new AbortController();
const e = new TermEditor(c.signal);
Expand Down Expand Up @@ -135,6 +135,13 @@ conn.ready.catch((err) => {
const sess = conn.joinSession(c.signal, 'fake-doc-id', 'bar');
let version = 0;

// TODO: we can write data to whatever "local" negative ID we want
// this shows up with our actual clientID to other users
floor.perform(function* (t) {
const whatever = t.byIno('c:-1:whatever');
whatever.dataUpdate(0, 0, 'hello', 'there');
});

for (;;) {
await Promise.race([sess.in.wait(), floor.wait()]);
if (sess.in.closed) {
Expand Down
39 changes: 18 additions & 21 deletions client/lib/gumnut/data.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import { SimpleCache, todoSignal } from 'thorish';
import type { FloorReadApi, GumnutFloor } from './floor.ts';
import { emptySymbol, type GumnutData, InoPosRef } from './types.ts';

const leadingRef = '^';
import { emptySymbol, type GumnutData, InoPosRef, InoRef, InoRefImpl } from './types.ts';

export type DataTypeInterface = DataMap | DataArray | DataString;

Expand All @@ -16,7 +14,7 @@ export type DataType =
| string
| bigint
// not-regular-JS types
| InoPosRef
| InoPosRef // TODO: special, we should do... something?
| typeof emptySymbol;

export function buildDataApi(gf: GumnutFloor): DataApi {
Expand All @@ -25,10 +23,12 @@ export function buildDataApi(gf: GumnutFloor): DataApi {

export interface DataApi {
root(): DataMap;
newMap(): DataMap;
}

class DataApiImpl implements DataApi {
public readonly byId: (id: string) => DataTypeInterface;
private internalId = 0;

constructor(public readonly gf: GumnutFloor) {
this.byId = this.cache.get.bind(this.cache);
Expand All @@ -38,37 +38,32 @@ class DataApiImpl implements DataApi {
if (name.endsWith(':m')) {
return new DataMapImpl(this, name);
}
throw new Error(`TODO: unhandled ${name}`);
throw new Error(`TODO: unhandled ${JSON.stringify(name)}`);
});

root(): DataMap {
return this.cache.get('root:m') as DataMap;
return this.cache.get('n:0:m') as DataMap;
}

newMap(): DataMap {
const id = ++this.internalId;
return new DataMapImpl(this, `n:-${id.toString(16)}:m`);
}

/**
* 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
if (data instanceof InoRef) {
return this.byId(data.target);
}

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;
const name = (data as DataMapImpl).name;
return new InoRefImpl(name); // FIXME: should this be == comparable?
} else if (data instanceof DataArray) {
throw 'TODO DataArray';
} else if (data instanceof DataString) {
Expand Down Expand Up @@ -154,9 +149,11 @@ class DataMapImpl extends DataMap {
}

// not found, insert
ino.dataUpdate(d.length, 0, k, v);
const res = ino.dataUpdate(d.length, 0, k, v);
outer.refreshCache(); // TODO: we can be more surgical?
yield [outer.name];

// FIXME: if we retry txn, we need to abort `res` - currently void!
}
});
}
Expand Down
30 changes: 28 additions & 2 deletions client/lib/gumnut/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,46 @@ export type GumnutData =
| number
| bigint
| string // intern string
| InoRef
| InoPosRef
| typeof emptySymbol; // explicit void, when op is run without set

export const emptySymbol = Symbol('zero');

export abstract class InoPosRef {
const ctorPrivateSymbol = Symbol('private');

/**
* Describes a reference to another inode.
*/
export abstract class InoRef {
constructor(privateSymbol: Symbol) {
if (ctorPrivateSymbol !== privateSymbol) {
throw new Error(`cannot directly create InoRef`);
}
}

/**
* The other inode being referenced.
*/
public abstract readonly target: string;
}

/**
* Describes a reference to another inode and an explicit position within that inode, which will change over time.
*/
export abstract class InoPosRef extends InoRef {}

export class InoRefImpl extends InoRef {
constructor(public readonly target: string) {
super(ctorPrivateSymbol);
}
}

export class InoPosRefImpl extends InoPosRef {
constructor(
public readonly target: string,
public readonly id: Id,
) {
super();
super(ctorPrivateSymbol);
}
}
18 changes: 13 additions & 5 deletions client/lib/gumnut/wire.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { stringToArray } from '../shared/array.ts';
import type { Id } from '../ymodel/internal/shared.ts';
import { emptySymbol, InoPosRefImpl, type GumnutData } from './types.ts';
import { emptySymbol, InoPosRefImpl, InoRefImpl, type GumnutData } from './types.ts';

/**
* Encodes a single {@link GumnutData}.
Expand Down Expand Up @@ -50,6 +50,9 @@ export function encodeGumnutData(lookupId: (inode: string, id: Id) => number, da
// we need to deref the ID here - on the way up to the server
const pos = lookupId(data.target, data.id);
return { i: data.target, p: pos };
} else if (data instanceof InoRefImpl) {
// other inode ref
return { i: data.target };
} else if (data === emptySymbol) {
throw new Error(`can't encode emptySymbol`);
}
Expand Down Expand Up @@ -111,10 +114,15 @@ export function decodeGumnutData(
return undefined;
}
throw new Error(`unhandled known: ${raw.x}`);
} else if ('i' in raw && 'p' in raw) {
// we got a real-space pos, convert to ID
const id = resolvePos(raw.i, raw.p);
return new InoPosRefImpl(raw.i, id);
} else if ('i' in raw) {
if ('p' in raw) {
// we got a real-space pos, convert to ID
const id = resolvePos(raw.i, raw.p);
return new InoPosRefImpl(raw.i, id);
}

// otherwise this is just another inode ref
return new InoRefImpl(raw.i);
} else if ('s' in raw) {
return raw.s;
}
Expand Down
12 changes: 10 additions & 2 deletions server/pkg/model/doc/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -187,9 +187,17 @@ func (d *docImpl) moveForward(handle, fromVersion int, gd *raw.GumnutData) (err
}

pos := int(gd.Data) // stores as uint64 but we change to int64 - user might have given -ve
node := d.byIno[gd.String]

var node *node.Node
if gd.String != "" {
node = d.byIno[gd.String]
}
if node == nil {
return // bad node
if pos < 0 {
// we targeted invalid/zero node? - maybe user targeted mapped away node
return ErrSetOp
}
return // bad or zero node
}

update, _ := node.TransformPos(handle, fromVersion, pos)
Expand Down
32 changes: 32 additions & 0 deletions server/pkg/model/node/map.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package node

// Map calls the mapper for all found inodes here (in patch or in data).
// If any changes are found, returns new data.
func Map(src map[string]Patch, mapper func(inode string) (out string, allowSet bool)) (out map[string]Patch) {
out = make(map[string]Patch, len(src))

// remap keyed inodes
for id, patch := range src {
if id == "" {
continue // invalid
}

update, allowSet := mapper(id)
if update == "" || !allowSet {
continue // deleted OR cannot be targeted
}

id = update

// TODO: can't compare slices :shrug: - just replace always
patch.Set = patch.Set.MapInode(func(inode string) (out string) {
out, _ = mapper(inode)
return out
})

// TODO: we might clobber other items _if_ mapper returns something we have
out[id] = patch
}

return out
}
18 changes: 13 additions & 5 deletions server/pkg/model/raw/gumnut.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,14 @@ func (gd *GumnutData) JSRune() (r uint16, ok bool) {
return r, true
}

// MatchInode returns the inode string here, or blank (invalid/none).
func (gd *GumnutData) MatchInode() (s string) {
if gd.Type == DataTypeInode || gd.Type == DataTypePosRef {
return gd.String
}
return
}

// IsZero returns whether this is the explicitly uninitialized data.
func (gd *GumnutData) IsZero() (is bool) {
return gd.Type == DataTypeEmpty
Expand Down Expand Up @@ -148,7 +156,7 @@ func (g *GumnutData) UnmarshalJSON(b []byte) (err error) {
Known *string `json:"x"`
BigInt string `json:"b"` // bigint value
Inf *int `json:"inf"`
Inode *string `json:"i"`
Inode string `json:"i"` // must be non-zero
Pos *int64 `json:"p"`
}
err = json.Unmarshal(b, &tmp)
Expand Down Expand Up @@ -181,14 +189,14 @@ func (g *GumnutData) UnmarshalJSON(b []byte) (err error) {
g.Data = math.Float64bits(math.Inf(int(*tmp.Inf)))
}

case tmp.Pos != nil && tmp.Inode != nil:
case tmp.Pos != nil && tmp.Inode != "":
g.Type = DataTypePosRef
g.String = *tmp.Inode
g.String = tmp.Inode
g.Data = uint64(*tmp.Pos)

case tmp.Inode != nil:
case tmp.Inode != "":
g.Type = DataTypeInode
g.String = *tmp.Inode
g.String = tmp.Inode

default:
return ErrEncoding
Expand Down
45 changes: 45 additions & 0 deletions server/pkg/model/raw/set.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,51 @@ func (s SetOp) IterData() (i iter.Seq[*GumnutData]) {
}
}

// MapInode maps all inode references here.
// It returns the same (if no changes) or copied (if changes) SetOp.
func (s SetOp) MapInode(mapper func(inode string) (out string)) (out SetOp) {
var change bool

// find IF there is a chance
for gd := range s.IterData() {
inode := gd.MatchInode()
if inode == "" {
continue
}

update := mapper(inode)
if update != inode {
change = true
break
}
}
if !change {
return s
}

// now clone
// we need to do this to avoid modifying arrays inline (multiple users)
out = make(SetOp, len(s))
for i, x := range s {
out[i].Skip = x.Skip
out[i].Data = make([]GumnutData, len(x.Data))

for j, gd := range x.Data {
inode := gd.MatchInode()
if inode != "" {
update := mapper(inode)
if update != inode {
gd.String = update
}
}

out[i].Data[j] = gd
}
}

return out
}

// Length returns the +ve length here.
// If this targets -ve ops, these are ignored.
func (arr SetOp) Length() (length int) {
Expand Down
Loading
Loading