added last-write-wins resolver
This commit is contained in:
parent
870c1a62b6
commit
2e0672e04c
|
@ -0,0 +1,4 @@
|
|||
describe.skip('Query', () => {
|
||||
it('can use a json logic expression to filter the queries', () => {});
|
||||
it('can use a json logic expression to implement a lossy resolver', () => {});
|
||||
});
|
|
@ -1,7 +1,7 @@
|
|||
import Debug from 'debug';
|
||||
import {RhizomeNode} from "../src/node";
|
||||
import {Entity} from "../src/entity";
|
||||
import {TypedCollection} from "../src/typed-collection";
|
||||
import {Collection} from "../src/collection";
|
||||
const debug = Debug('example-app');
|
||||
|
||||
// As an app we want to be able to write and read data.
|
||||
|
@ -19,7 +19,7 @@ type User = {
|
|||
|
||||
(async () => {
|
||||
const rhizomeNode = new RhizomeNode();
|
||||
const users = new TypedCollection<User>("user");
|
||||
const users = new Collection("user");
|
||||
users.rhizomeConnect(rhizomeNode);
|
||||
|
||||
users.onUpdate((u: Entity) => {
|
||||
|
@ -37,27 +37,63 @@ type User = {
|
|||
// - Logging
|
||||
// - Chat
|
||||
//
|
||||
|
||||
const taliesin = await users.put(undefined, {
|
||||
const taliesinData: User = {
|
||||
id: 'taliesin-1',
|
||||
name: 'Taliesin',
|
||||
nameLong: 'Taliesin (Ladd)',
|
||||
age: Math.floor(Math.random() * 1000)
|
||||
});
|
||||
};
|
||||
|
||||
const taliesinPutResult = await users.put(undefined, taliesinData);
|
||||
|
||||
{
|
||||
const result = JSON.stringify(taliesinPutResult);
|
||||
const expected = JSON.stringify(taliesinData);
|
||||
|
||||
if (result === expected) {
|
||||
debug('Put result matches expected: ' + expected);
|
||||
} else {
|
||||
debug(`Put result does not match expected.` +
|
||||
`\n\nExpected \n${expected}` +
|
||||
`\nReceived\n${result}`);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Allow configuration regarding read/write concern i.e.
|
||||
// if we perform a read immediately do we see the value we wrote?
|
||||
// Intuition says yes, we want that-- but how do we expose the propagation status?
|
||||
|
||||
const result = users.get(taliesin.id);
|
||||
const matches: boolean = JSON.stringify(result) === JSON.stringify(taliesin);
|
||||
if (matches) {
|
||||
debug('Result matches expected: ' + JSON.stringify(taliesin));
|
||||
} else {
|
||||
debug(`Result does not match expected.` +
|
||||
`\n\nExpected \n${JSON.stringify(taliesin)}` +
|
||||
`\nReceived\n${JSON.stringify(result)}`);
|
||||
const resolved = users.resolve('taliesin-1');
|
||||
if (!resolved) throw new Error('unable to resolve entity we just created');
|
||||
|
||||
const resolvedUser = {
|
||||
id: resolved.id,
|
||||
...resolved.properties
|
||||
} as User;
|
||||
|
||||
/*
|
||||
function sortKeys (o: {[key: string]: unknown}): {[key: string]: unknown} {
|
||||
const r: {[key: string]: unknown} = {};
|
||||
r.id = o.id;
|
||||
Object.keys(o).sort().forEach((key) => {
|
||||
if (key === "id") return;
|
||||
r[key] = o[key];
|
||||
})
|
||||
return r;
|
||||
}
|
||||
*/
|
||||
|
||||
const result = JSON.stringify(resolvedUser);
|
||||
const expected = JSON.stringify(taliesinData);
|
||||
|
||||
if (result === expected) {
|
||||
debug('Get result matches expected: ' + expected);
|
||||
} else {
|
||||
debug(`Get result does not match expected.` +
|
||||
`\n\nExpected \n${expected}` +
|
||||
`\nReceived\n${result}`);
|
||||
}
|
||||
|
||||
|
||||
})();
|
||||
|
||||
|
|
|
@ -1,31 +1,34 @@
|
|||
{
|
||||
"name": "rhizome-node",
|
||||
"version": "1.0.0",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "rhizome-node",
|
||||
"version": "1.0.0",
|
||||
"version": "0.1.0",
|
||||
"license": "Unlicense",
|
||||
"dependencies": {
|
||||
"@types/bluebird": "^3.5.42",
|
||||
"@types/debug": "^4.1.12",
|
||||
"@types/json-logic-js": "^2.0.8",
|
||||
"@types/object-hash": "^3.0.6",
|
||||
"debug": "^4.4.0",
|
||||
"express": "^4.21.2",
|
||||
"json-logic-js": "^2.0.5",
|
||||
"level": "^9.0.0",
|
||||
"microtime": "^3.1.1",
|
||||
"object-hash": "^3.0.0",
|
||||
"showdown": "^2.1.0",
|
||||
"util": "./util/",
|
||||
"zeromq": "^6.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.17.0",
|
||||
"@types/bluebird": "^3.5.42",
|
||||
"@types/debug": "^4.1.12",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/json-logic-js": "^2.0.8",
|
||||
"@types/microtime": "^2.1.2",
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/object-hash": "^3.0.6",
|
||||
"@types/showdown": "^2.0.6",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-config-airbnb-base-typescript": "^1.1.0",
|
||||
|
@ -1438,6 +1441,7 @@
|
|||
"version": "3.5.42",
|
||||
"resolved": "https://registry.npmjs.org/@types/bluebird/-/bluebird-3.5.42.tgz",
|
||||
"integrity": "sha512-Jhy+MWRlro6UjVi578V/4ZGNfeCOcNCp0YaFNIUGFKlImowqwb1O/22wDVk3FDGMLqxdpOV3qQHD5fPEH4hK6A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/body-parser": {
|
||||
|
@ -1465,6 +1469,7 @@
|
|||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
|
||||
"integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/ms": "*"
|
||||
|
@ -1562,6 +1567,7 @@
|
|||
"version": "2.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-logic-js/-/json-logic-js-2.0.8.tgz",
|
||||
"integrity": "sha512-WgNsDPuTPKYXl0Jh0IfoCoJoAGGYZt5qzpmjuLSEg7r0cKp/kWtWp0HAsVepyPSPyXiHo6uXp/B/kW/2J1fa2Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/json-schema": {
|
||||
|
@ -1579,6 +1585,13 @@
|
|||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@types/microtime": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/microtime/-/microtime-2.1.2.tgz",
|
||||
"integrity": "sha512-d5odaV/0jPwfehN1t+y7+TcbGxECQLtl7mVETpMaYA0SnlhyKQKgWPCRetbSJVP7i2Kzx8CuTDgDs2kjS1MCOw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/mime": {
|
||||
"version": "1.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
|
||||
|
@ -1590,6 +1603,7 @@
|
|||
"version": "0.7.34",
|
||||
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.34.tgz",
|
||||
"integrity": "sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
|
@ -1605,6 +1619,7 @@
|
|||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/object-hash/-/object-hash-3.0.6.tgz",
|
||||
"integrity": "sha512-fOBV8C1FIu2ELinoILQ+ApxcUKz4ngq+IWUYrxSGjXzzjUALijilampwkMgEtJ+h2njAW3pi853QpzNVCHB73w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/qs": {
|
||||
|
@ -6907,6 +6922,26 @@
|
|||
"node": ">=8.6"
|
||||
}
|
||||
},
|
||||
"node_modules/microtime": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/microtime/-/microtime-3.1.1.tgz",
|
||||
"integrity": "sha512-to1r7o24cDsud9IhN6/8wGmMx5R2kT0w2Xwm5okbYI3d1dk6Xv0m+Z+jg2vS9pt+ocgQHTCtgs/YuyJhySzxNg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"node-addon-api": "^5.0.0",
|
||||
"node-gyp-build": "^4.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/microtime/node_modules/node-addon-api": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz",
|
||||
"integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mime": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
|
||||
|
@ -8974,6 +9009,10 @@
|
|||
"integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/util": {
|
||||
"resolved": "util",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
|
@ -9280,6 +9319,7 @@
|
|||
"node": ">= 10",
|
||||
"pnpm": ">= 9"
|
||||
}
|
||||
}
|
||||
},
|
||||
"util": {}
|
||||
}
|
||||
}
|
||||
|
|
21
package.json
21
package.json
|
@ -13,25 +13,32 @@
|
|||
"jest": {
|
||||
"testEnvironment": "node",
|
||||
"preset": "ts-jest",
|
||||
"roots": ["__tests__/"]
|
||||
"roots": [
|
||||
"./__tests__/"
|
||||
],
|
||||
"testMatch": [
|
||||
"**/__tests__/**/*"
|
||||
]
|
||||
},
|
||||
"author": "Taliesin (Ladd) <ladd@dgov.io>",
|
||||
"license": "Unlicense",
|
||||
"dependencies": {
|
||||
"@types/bluebird": "^3.5.42",
|
||||
"@types/debug": "^4.1.12",
|
||||
"@types/json-logic-js": "^2.0.8",
|
||||
"@types/object-hash": "^3.0.6",
|
||||
"debug": "^4.4.0",
|
||||
"express": "^4.21.2",
|
||||
"json-logic-js": "^2.0.5",
|
||||
"level": "^9.0.0",
|
||||
"microtime": "^3.1.1",
|
||||
"object-hash": "^3.0.0",
|
||||
"showdown": "^2.1.0",
|
||||
"zeromq": "^6.1.2",
|
||||
"util": "./util/"
|
||||
"util": "./util/",
|
||||
"zeromq": "^6.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bluebird": "^3.5.42",
|
||||
"@types/debug": "^4.1.12",
|
||||
"@types/json-logic-js": "^2.0.8",
|
||||
"@types/microtime": "^2.1.2",
|
||||
"@types/object-hash": "^3.0.6",
|
||||
"@eslint/js": "^9.17.0",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^29.5.14",
|
||||
|
|
|
@ -0,0 +1,12 @@
|
|||
import { apply } from 'json-logic-js';
|
||||
|
||||
console.log(apply({"map":[
|
||||
{"var":"integers"},
|
||||
{"*":[{"var":""},2]}
|
||||
]}, {"integers":[1,2,3,4,5]}));
|
||||
|
||||
console.log(apply({"reduce":[
|
||||
{"var":"integers"},
|
||||
{"+":[{"var":"current"}, {"var":"accumulator"}]},
|
||||
0
|
||||
]}, {"integers":[1,2,3,4,5]}));
|
|
@ -9,7 +9,7 @@ import EventEmitter from "node:events";
|
|||
import {Delta, DeltaID} from "./delta";
|
||||
import {Entity, EntityProperties} from "./entity";
|
||||
import {LosslessViewMany} from "./lossless";
|
||||
import {firstValueFromLosslessViewOne, Lossy, LossyViewMany, LossyViewOne} from "./lossy";
|
||||
import {lastValueFromLosslessViewOne, Lossy, ResolvedViewMany, ResolvedViewOne, Resolver} from "./lossy";
|
||||
import {RhizomeNode} from "./node";
|
||||
import {DomainEntityID} from "./types";
|
||||
const debug = Debug('collection');
|
||||
|
@ -17,22 +17,12 @@ const debug = Debug('collection');
|
|||
export class Collection {
|
||||
rhizomeNode?: RhizomeNode;
|
||||
name: string;
|
||||
entities = new Map<string, Entity>();
|
||||
eventStream = new EventEmitter();
|
||||
|
||||
constructor(name: string) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
ingestDelta(delta: Delta) {
|
||||
if (!this.rhizomeNode) return;
|
||||
|
||||
const updated = this.rhizomeNode.lossless.ingestDelta(delta);
|
||||
|
||||
this.eventStream.emit('ingested', delta);
|
||||
this.eventStream.emit('updated', updated);
|
||||
}
|
||||
|
||||
// Instead of trying to update our final view of the entity with every incoming delta,
|
||||
// let's try this:
|
||||
// - keep a lossless view (of everything)
|
||||
|
@ -53,39 +43,24 @@ export class Collection {
|
|||
debug(`connected ${this.name} to rhizome`);
|
||||
}
|
||||
|
||||
onCreate(cb: (entity: Entity) => void) {
|
||||
// TODO: Trigger for changes received from peers
|
||||
this.eventStream.on('create', (entity: Entity) => {
|
||||
cb(entity);
|
||||
});
|
||||
}
|
||||
ingestDelta(delta: Delta) {
|
||||
if (!this.rhizomeNode) return;
|
||||
|
||||
onUpdate(cb: (entity: Entity) => void) {
|
||||
// TODO: Trigger for changes received from peers
|
||||
this.eventStream.on('update', (entity: Entity) => {
|
||||
cb(entity);
|
||||
});
|
||||
}
|
||||
const updated = this.rhizomeNode.lossless.ingestDelta(delta);
|
||||
|
||||
defaultResolver(losslessView: LosslessViewMany): LossyViewMany {
|
||||
const resolved: LossyViewMany = {};
|
||||
debug('default resolver, lossless view', JSON.stringify(losslessView));
|
||||
for (const [id, ent] of Object.entries(losslessView)) {
|
||||
resolved[id] = {id, properties: {}};
|
||||
for (const key of Object.keys(ent.properties)) {
|
||||
const {value} = firstValueFromLosslessViewOne(ent, key) || {};
|
||||
debug(`[ ${key} ] = ${value}`);
|
||||
resolved[id].properties[key] = value;
|
||||
}
|
||||
}
|
||||
return resolved;
|
||||
this.eventStream.emit('ingested', delta);
|
||||
this.eventStream.emit('updated', updated);
|
||||
}
|
||||
|
||||
// Applies the javascript rules for updating object values,
|
||||
// e.g. set to `undefined` to delete a property
|
||||
// e.g. set to `undefined` to delete a property.
|
||||
// This function is here instead of Entity so that it can:
|
||||
// - read the current state in order to build its delta
|
||||
// - include the collection name in the delta it produces
|
||||
generateDeltas(
|
||||
entityId: DomainEntityID,
|
||||
newProperties: EntityProperties,
|
||||
resolver?: Resolver,
|
||||
creator?: string,
|
||||
host?: string
|
||||
): Delta[] {
|
||||
|
@ -93,7 +68,7 @@ export class Collection {
|
|||
let oldProperties: EntityProperties = {};
|
||||
|
||||
if (entityId) {
|
||||
const entity = this.get(entityId);
|
||||
const entity = this.resolve(entityId, resolver);
|
||||
if (entity) {
|
||||
oldProperties = entity.properties;
|
||||
}
|
||||
|
@ -123,17 +98,39 @@ export class Collection {
|
|||
return deltas;
|
||||
}
|
||||
|
||||
onCreate(cb: (entity: Entity) => void) {
|
||||
// TODO: Trigger for changes received from peers
|
||||
this.eventStream.on('create', (entity: Entity) => {
|
||||
cb(entity);
|
||||
});
|
||||
}
|
||||
|
||||
onUpdate(cb: (entity: Entity) => void) {
|
||||
// TODO: Trigger for changes received from peers
|
||||
this.eventStream.on('update', (entity: Entity) => {
|
||||
cb(entity);
|
||||
});
|
||||
}
|
||||
|
||||
getIds(): string[] {
|
||||
if (!this.rhizomeNode) return [];
|
||||
return Array.from(this.rhizomeNode.lossless.domainEntities.keys());
|
||||
}
|
||||
|
||||
// THIS PUT SHOULD CORRESOND TO A PARTICULAR MATERIALIZED VIEW...
|
||||
// How can we encode that?
|
||||
// Well, we have a way to do that, we just need the same particular inputs.
|
||||
// We take a resolver as an optional argument.
|
||||
async put(
|
||||
entityId: DomainEntityID | undefined,
|
||||
properties: EntityProperties
|
||||
): Promise<LossyViewOne> {
|
||||
// const deltas: Delta[] = [];
|
||||
// const entity = this.updateEntity(entityId, properties, true, deltas);
|
||||
|
||||
// THIS PUT SHOULD CORRESOND TO A PARTICULAR MATERIALIZED VIEW...
|
||||
// How can we encode that?
|
||||
// Well, we have a way to do that, we just need the same particular inputs
|
||||
|
||||
properties: EntityProperties,
|
||||
resolver?: Resolver
|
||||
): Promise<ResolvedViewOne> {
|
||||
// For convenience, we allow setting id via properties.id
|
||||
if (!entityId && !!properties.id && typeof properties.id === 'string') {
|
||||
entityId = properties.id;
|
||||
}
|
||||
// Generate an ID if none is provided
|
||||
if (!entityId) {
|
||||
entityId = randomUUID();
|
||||
}
|
||||
|
@ -141,12 +138,17 @@ export class Collection {
|
|||
const deltas = this.generateDeltas(
|
||||
entityId,
|
||||
properties,
|
||||
resolver,
|
||||
this.rhizomeNode?.config.creator,
|
||||
this.rhizomeNode?.config.peerId,
|
||||
);
|
||||
|
||||
debug(`put ${entityId} generated deltas:`, JSON.stringify(deltas));
|
||||
|
||||
// Here we set up a listener so we can wait for all our deltas to be
|
||||
// ingested into our lossless view before proceeding.
|
||||
// TODO: Hoist this into a more generic transaction mechanism.
|
||||
|
||||
const allIngested = new Promise<boolean>((resolve) => {
|
||||
const ingestedIds = new Set<DeltaID>();
|
||||
this.eventStream.on('ingested', (delta: Delta) => {
|
||||
|
@ -160,7 +162,6 @@ export class Collection {
|
|||
})
|
||||
});
|
||||
|
||||
// updateEntity may have generated some deltas for us to store and publish
|
||||
deltas.forEach(async (delta: Delta) => {
|
||||
|
||||
// record this delta just as if we had received it from a peer
|
||||
|
@ -181,7 +182,7 @@ export class Collection {
|
|||
|
||||
await allIngested;
|
||||
|
||||
const res = this.get(entityId);
|
||||
const res = this.resolve(entityId, resolver);
|
||||
if (!res) throw new Error("could not get what we just put!");
|
||||
|
||||
this.eventStream.emit("update", res);
|
||||
|
@ -189,18 +190,41 @@ export class Collection {
|
|||
return res;
|
||||
}
|
||||
|
||||
get(id: string): LossyViewOne | undefined {
|
||||
// Now with lossy view approach, instead of just returning what we already have,
|
||||
// let's compute our view now.
|
||||
// return this.entities.get(id);
|
||||
if (!this.rhizomeNode) return undefined;
|
||||
const lossy = new Lossy(this.rhizomeNode.lossless);
|
||||
const res = lossy.resolve((view) => this.defaultResolver(view), [id]);
|
||||
return res[id];
|
||||
// TODO: default should probably be last write wins
|
||||
defaultResolver(losslessView: LosslessViewMany): ResolvedViewMany {
|
||||
const resolved: ResolvedViewMany = {};
|
||||
|
||||
// debug('default resolver, lossless view', JSON.stringify(losslessView));
|
||||
for (const [id, ent] of Object.entries(losslessView)) {
|
||||
resolved[id] = {id, properties: {}};
|
||||
|
||||
for (const key of Object.keys(ent.properties)) {
|
||||
const {value} = lastValueFromLosslessViewOne(ent, key) || {};
|
||||
|
||||
// debug(`[ ${key} ] = ${value}`);
|
||||
resolved[id].properties[key] = value;
|
||||
}
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
getIds(): string[] {
|
||||
if (!this.rhizomeNode) return [];
|
||||
return Array.from(this.rhizomeNode.lossless.domainEntities.keys());
|
||||
resolve(id: string, resolver?: Resolver): ResolvedViewOne | undefined {
|
||||
// Now with lossy view approach, instead of just returning what we
|
||||
// already have, let's compute our view now.
|
||||
// return this.entities.resolve(id);
|
||||
// TODO: Caching
|
||||
|
||||
if (!this.rhizomeNode) return undefined;
|
||||
|
||||
if (!resolver) {
|
||||
debug('using default resolver');
|
||||
resolver = (view) => this.defaultResolver(view);
|
||||
}
|
||||
|
||||
const lossy = new Lossy(this.rhizomeNode.lossless);
|
||||
const res = lossy.resolve(resolver, [id]);
|
||||
debug('lossy view', res);
|
||||
|
||||
return res[id];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import microtime from 'microtime';
|
||||
import {randomUUID} from "crypto";
|
||||
import {PeerAddress} from "./types";
|
||||
import {PeerAddress, Timestamp} from "./types";
|
||||
|
||||
export type DeltaID = string;
|
||||
|
||||
|
@ -14,11 +15,15 @@ export type Pointer = {
|
|||
export class Delta {
|
||||
id: DeltaID;
|
||||
receivedFrom?: PeerAddress;
|
||||
timeReceived: Timestamp;
|
||||
timeCreated: Timestamp;
|
||||
creator: string;
|
||||
host: string;
|
||||
pointers: Pointer[] = [];
|
||||
constructor(delta: Omit<Delta, "id">) {
|
||||
constructor(delta: Omit<Delta, "id" | "timeReceived" | "timeCreated">) {
|
||||
this.id = randomUUID();
|
||||
this.timeCreated = microtime.now();
|
||||
this.timeReceived = this.timeCreated;
|
||||
this.creator = delta.creator;
|
||||
this.host = delta.host;
|
||||
this.receivedFrom = delta.receivedFrom;
|
||||
|
|
|
@ -42,7 +42,7 @@ export class DeltaStream {
|
|||
switch (decision) {
|
||||
case Decision.Accept:
|
||||
this.deltasAccepted.push(delta);
|
||||
this.deltaStream.emit('delta', {delta});
|
||||
this.deltaStream.emit('delta', delta);
|
||||
break;
|
||||
case Decision.Reject:
|
||||
this.deltasRejected.push(delta);
|
||||
|
@ -80,7 +80,7 @@ export class DeltaStream {
|
|||
}
|
||||
|
||||
subscribeDeltas(fn: (delta: Delta) => void) {
|
||||
this.deltaStream.on('delta', ({delta}) => {
|
||||
this.deltaStream.on('delta', (delta) => {
|
||||
fn(delta);
|
||||
});
|
||||
}
|
||||
|
@ -90,11 +90,12 @@ export class DeltaStream {
|
|||
await this.rhizomeNode.pubSub.publish("deltas", this.serializeDelta(delta));
|
||||
}
|
||||
|
||||
serializeDelta(delta: Delta) {
|
||||
serializeDelta(delta: Delta): string {
|
||||
return JSON.stringify(delta);
|
||||
}
|
||||
|
||||
deserializeDelta(input: string) {
|
||||
deserializeDelta(input: string): Delta {
|
||||
// TODO: Input validation
|
||||
return JSON.parse(input);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
// - As typescript interfaces?
|
||||
// - As typescript classes?
|
||||
|
||||
import {Collection} from "./collection";
|
||||
import {PropertyTypes} from "./types";
|
||||
|
||||
export type EntityProperties = {
|
||||
|
@ -19,6 +20,11 @@ export class Entity {
|
|||
|
||||
constructor(
|
||||
readonly id: string,
|
||||
readonly collection?: Collection
|
||||
) {}
|
||||
}
|
||||
|
||||
async save() {
|
||||
if (!this.collection) throw new Error('to save this entity you must specify the collection');
|
||||
return this.collection.put(this.id, this.properties);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -61,7 +61,7 @@ export class HttpApi {
|
|||
// Get a single domain entity by ID
|
||||
this.router.get(`/${name}/:id`, (req: express.Request, res: express.Response) => {
|
||||
const {params: {id}} = req;
|
||||
const ent = collection.get(id);
|
||||
const ent = collection.resolve(id);
|
||||
if (!ent) {
|
||||
res.status(404).send({error: "Not Found"});
|
||||
return;
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
import Debug from 'debug';
|
||||
import {Delta, DeltaFilter} from './delta';
|
||||
import {DomainEntityID, PropertyID, PropertyTypes} from "./types";
|
||||
import {DomainEntityID, PropertyID, PropertyTypes, ViewMany} from "./types";
|
||||
const debug = Debug('lossless');
|
||||
|
||||
export type CollapsedPointer = {[key: string]: PropertyTypes};
|
||||
|
@ -19,9 +19,7 @@ export type LosslessViewOne = {
|
|||
}
|
||||
};
|
||||
|
||||
export type LosslessViewMany = {
|
||||
[key: DomainEntityID]: LosslessViewOne;
|
||||
};
|
||||
export type LosslessViewMany = ViewMany<LosslessViewOne>;
|
||||
|
||||
class DomainEntityMap extends Map<DomainEntityID, DomainEntity> {};
|
||||
|
||||
|
@ -66,7 +64,7 @@ class DomainEntity {
|
|||
export class Lossless {
|
||||
domainEntities = new DomainEntityMap();
|
||||
|
||||
ingestDelta(delta: Delta): LosslessViewMany {
|
||||
ingestDelta(delta: Delta) {
|
||||
const targets = delta.pointers
|
||||
.filter(({targetContext}) => !!targetContext)
|
||||
.map(({target}) => target)
|
||||
|
@ -86,8 +84,6 @@ export class Lossless {
|
|||
|
||||
debug('after add, domain entity:', JSON.stringify(ent));
|
||||
}
|
||||
|
||||
return this.view(targets);
|
||||
}
|
||||
|
||||
//TODO: json logic -- view(deltaFilter?: FilterExpr) {
|
||||
|
|
56
src/lossy.ts
56
src/lossy.ts
|
@ -6,21 +6,30 @@
|
|||
// Fields in the output can be described as transformations
|
||||
|
||||
import Debug from 'debug';
|
||||
import {CollapsedDelta, Lossless, LosslessViewMany, LosslessViewOne} from "./lossless";
|
||||
import {DomainEntityID, Properties} from "./types";
|
||||
import {DeltaFilter} from "./delta";
|
||||
import {CollapsedDelta, Lossless, LosslessViewMany, LosslessViewOne} from "./lossless";
|
||||
import {DomainEntityID, PropertyID, PropertyTypes, Timestamp, ViewMany} from "./types";
|
||||
const debug = Debug('lossy');
|
||||
|
||||
export type LossyViewOne<T = Properties> = {
|
||||
type TimestampedProperty = {
|
||||
value: PropertyTypes,
|
||||
timeUpdated: Timestamp
|
||||
};
|
||||
|
||||
export type LossyViewOne<T = TimestampedProperty> = {
|
||||
id: DomainEntityID;
|
||||
properties: T;
|
||||
properties: {
|
||||
[key: PropertyID]: T
|
||||
};
|
||||
};
|
||||
|
||||
export type LossyViewMany = {
|
||||
[key: DomainEntityID]: LossyViewOne;
|
||||
};
|
||||
export type LossyViewMany<T> = ViewMany<LossyViewOne<T>>;
|
||||
|
||||
type Resolver<T = LosslessViewMany> = (losslessView: LosslessViewMany) => T;
|
||||
export type ResolvedViewOne = LossyViewOne<PropertyTypes>;
|
||||
export type ResolvedViewMany = ViewMany<ResolvedViewOne>;
|
||||
|
||||
export type Resolver<T = ResolvedViewMany> =
|
||||
(losslessView: LosslessViewMany) => T;
|
||||
|
||||
// Extract a particular value from a delta's pointers
|
||||
export function valueFromCollapsedDelta(
|
||||
|
@ -44,13 +53,42 @@ export function firstValueFromLosslessViewOne(
|
|||
delta: CollapsedDelta,
|
||||
value: string | number
|
||||
} | undefined {
|
||||
debug(`trying to get value for ${key} from ${JSON.stringify(ent.properties[key])}`);
|
||||
debug(`trying to get first value for ${key} from ${JSON.stringify(ent.properties[key])}`);
|
||||
for (const delta of ent.properties[key] || []) {
|
||||
const value = valueFromCollapsedDelta(delta, key);
|
||||
if (value) return {delta, value};
|
||||
}
|
||||
}
|
||||
|
||||
// Function for resolving a value for an entity by last write wins
|
||||
export function lastValueFromLosslessViewOne(
|
||||
ent: LosslessViewOne,
|
||||
key: string
|
||||
): {
|
||||
delta?: CollapsedDelta,
|
||||
value?: string | number,
|
||||
timeUpdated?: number
|
||||
} | undefined {
|
||||
const res: {
|
||||
delta?: CollapsedDelta,
|
||||
value?: string | number,
|
||||
timeUpdated?: number
|
||||
} = {};
|
||||
debug(`trying to get last value for ${key} from ${JSON.stringify(ent.properties[key])}`);
|
||||
res.timeUpdated = 0;
|
||||
|
||||
for (const delta of ent.properties[key] || []) {
|
||||
const value = valueFromCollapsedDelta(delta, key);
|
||||
if (value === undefined) continue;
|
||||
if (delta.timeCreated < res.timeUpdated) continue;
|
||||
res.delta = delta;
|
||||
res.value = value;
|
||||
res.timeUpdated = delta.timeCreated;
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
export class Lossy {
|
||||
lossless: Lossless;
|
||||
|
||||
|
|
|
@ -1,10 +0,0 @@
|
|||
import {Collection} from './collection';
|
||||
import {EntityProperties} from './entity';
|
||||
import {LossyViewOne} from './lossy';
|
||||
import {DomainEntityID} from './types';
|
||||
|
||||
export class TypedCollection<T extends EntityProperties> extends Collection {
|
||||
async put(id: DomainEntityID | undefined, properties: T): Promise<LossyViewOne> {
|
||||
return super.put(id, properties);
|
||||
}
|
||||
}
|
|
@ -9,8 +9,13 @@ export type PropertyTypes = string | number | undefined;
|
|||
export type DomainEntityID = string;
|
||||
export type PropertyID = string;
|
||||
|
||||
export type Properties = {[key: PropertyID]: PropertyTypes};
|
||||
export type Timestamp = number;
|
||||
|
||||
export type ViewMany<T> = {
|
||||
[key: DomainEntityID]: T;
|
||||
};
|
||||
|
||||
// TODO: Move to ./peers.ts
|
||||
export class PeerAddress {
|
||||
addr: string;
|
||||
port: number;
|
||||
|
|
|
@ -4,15 +4,22 @@
|
|||
"module": "CommonJS",
|
||||
"esModuleInterop": true,
|
||||
"moduleResolution": "Node",
|
||||
"sourceMap": true,
|
||||
"sourceMap": false,
|
||||
"baseUrl": ".",
|
||||
"outDir": "dist",
|
||||
"importsNotUsedAsValues": "remove",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src/**/*", "examples/**/*", "__tests__/**/*"],
|
||||
"exclude": ["node_modules"]
|
||||
"include": [
|
||||
"src/**/*",
|
||||
"util/**/*",
|
||||
"examples/**/*",
|
||||
"scratch/**/*",
|
||||
"__tests__/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
|
|
12
util/app.ts
12
util/app.ts
|
@ -1,13 +1,5 @@
|
|||
import {RhizomeNode, RhizomeNodeConfig} from "../src/node";
|
||||
import {TypedCollection} from "../src/typed-collection";
|
||||
|
||||
type User = {
|
||||
id?: string;
|
||||
name: string;
|
||||
nameLong?: string;
|
||||
email?: string;
|
||||
age: number;
|
||||
};
|
||||
import {Collection} from "../src/collection";
|
||||
|
||||
const start = 5000;
|
||||
const range = 5000;
|
||||
|
@ -25,7 +17,7 @@ export class App extends RhizomeNode {
|
|||
...config,
|
||||
});
|
||||
|
||||
const users = new TypedCollection<User>("user");
|
||||
const users = new Collection("user");
|
||||
users.rhizomeConnect(this);
|
||||
|
||||
const {httpAddr, httpPort} = this.config;
|
||||
|
|
Loading…
Reference in New Issue