Skip to content

Commit

Permalink
CRDT for add/remove of:
Browse files Browse the repository at this point in the history
* number or bigint properties (add/subtract)
* array properties (treating array as a set)
  • Loading branch information
dfahlander committed Mar 29, 2024
1 parent f7d3708 commit 16cd48a
Show file tree
Hide file tree
Showing 11 changed files with 131 additions and 11 deletions.
7 changes: 5 additions & 2 deletions import-wrapper-prod.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ const Dexie = globalThis[DexieSymbol] || (globalThis[DexieSymbol] = _Dexie);
if (_Dexie.semVer !== Dexie.semVer) {
throw new Error(`Two different versions of Dexie loaded in the same app: ${_Dexie.semVer} and ${Dexie.semVer}`);
}
const { liveQuery, mergeRanges, rangesOverlap, RangeSet, cmp, Entity, PropModSymbol, PropModification, replacePrefix } = Dexie;
export { liveQuery, mergeRanges, rangesOverlap, RangeSet, cmp, Dexie, Entity, PropModSymbol, PropModification, replacePrefix };
const {
liveQuery, mergeRanges, rangesOverlap, RangeSet, cmp, Entity,
PropModSymbol, PropModification, replacePrefix, add, remove } = Dexie;
export { liveQuery, mergeRanges, rangesOverlap, RangeSet, cmp, Dexie, Entity,
PropModSymbol, PropModification, replacePrefix, add, remove };
export default Dexie;
6 changes: 4 additions & 2 deletions import-wrapper.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ const Dexie = globalThis[DexieSymbol] || (globalThis[DexieSymbol] = _Dexie);
if (_Dexie.semVer !== Dexie.semVer) {
throw new Error(`Two different versions of Dexie loaded in the same app: ${_Dexie.semVer} and ${Dexie.semVer}`);
}
const { liveQuery, mergeRanges, rangesOverlap, RangeSet, cmp, Entity, PropModSymbol, PropModification, replacePrefix } = Dexie;
export { liveQuery, mergeRanges, rangesOverlap, RangeSet, cmp, Dexie, Entity, PropModSymbol, PropModification, replacePrefix };
const { liveQuery, mergeRanges, rangesOverlap, RangeSet, cmp, Entity,
PropModSymbol, PropModification, replacePrefix, add, remove } = Dexie;
export { liveQuery, mergeRanges, rangesOverlap, RangeSet, cmp, Dexie, Entity,
PropModSymbol, PropModification, replacePrefix, add, remove };
export default Dexie;
5 changes: 5 additions & 0 deletions src/functions/propmods/add.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { PropModification } from "../../helpers/prop-modification";

export function add(value: number | BigInt | Array<string | number>) {
return new PropModification({add: value});
}
3 changes: 3 additions & 0 deletions src/functions/propmods/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from "./add";
export * from "./remove";
export * from "./replace-prefix";
5 changes: 5 additions & 0 deletions src/functions/propmods/remove.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { PropModification } from "../../helpers/prop-modification";

export function remove(value: number | BigInt | Array<string | number>) {
return new PropModification({remove: value});
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { PropModification } from "../helpers/prop-modification";
import { PropModification } from "../../helpers/prop-modification";

export function replacePrefix(a: string, b:string) {
return new PropModification({replacePrefix: [a, b]});
Expand Down
52 changes: 51 additions & 1 deletion src/helpers/prop-modification.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,62 @@
import { isArray } from "../functions/utils";
import { PropModSpec } from "../public/types/prop-modification";

export const PropModSymbol: unique symbol = Symbol();

/** Consistent change propagation across offline synced data.
*
* This class is executed client- and server side on sync, making
* an operation consistent across sync for full consistency and accuracy.
*
* Example: An object represents a bank account with a balance.
* One offline user adds $ 1.00 to the balance.
* Another user (online) adds $ 2.00 to the balance.
* When first user syncs, the balance becomes the sum of every operation (3.00).
*
* -- initial: balance is 0
* 1. db.bankAccounts.update(1, { balance: new ProdModification({add: 100})}) // user 1 (offline)
* 2. db.bankAccounts.update(1, { balance: new ProdModification({add: 200})}) // user 2 (online)
* -- before user 1 syncs, balance is 200 (representing money with integers * 100 to avoid rounding issues)
* <user 1 syncs>
* -- balance is 300
*
* When new operations are added, they need to be added to:
* 1. PropModSpec interface
* 2. Here in PropModification with the logic they represent
* 3. (Optionally) a sugar function for it, such as const mathAdd = (amount: number | BigInt) => new PropModification({mathAdd: amount})
*/
export class PropModification implements PropModSpec {
[PropModSymbol]?: true;
replacePrefix?: [string, string];
add?: number | BigInt | Array<string | number>;
remove?: number | BigInt | Array<string | number>;

execute(value: any) {
execute(value: any): any {
// add (mathematical or set-wise)
if (this.add !== undefined) {
const term = this.add;
// Set-addition on array representing a set of primitive types (strings, numbers)
if (isArray(term)) {
return [...(isArray(value) ? value : []), ...term].sort();
}
// Mathematical addition:
if (typeof term === 'number') return Number(value) + term;
if (typeof term === 'bigint') return BigInt(value) + term;
}

// remove (mathematical or set-wise)
if (this.remove !== undefined) {
const subtrahend = this.remove;
// Set-addition on array representing a set of primitive types (strings, numbers)
if (isArray(subtrahend)) {
return isArray(value) ? value.filter(item => !subtrahend.includes(item)).sort() : [];
}
// Mathematical addition:
if (typeof subtrahend === 'number') return Number(value) - subtrahend;
if (typeof subtrahend === 'bigint') return BigInt(value) - subtrahend;
}

// Replace a prefix:
const prefixToReplace = this.replacePrefix?.[0];
if (prefixToReplace && typeof value === 'string' && value.startsWith(prefixToReplace)) {
return this.replacePrefix[1] + value.substring(prefixToReplace.length);
Expand Down
6 changes: 3 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { liveQuery } from './live-query/live-query';
import { Entity } from './classes/entity/Entity';
import { cmp } from './functions/cmp';
import { PropModification, PropModSymbol } from './helpers/prop-modification';
import { replacePrefix } from './functions/replace-prefix';
import { replacePrefix, add, remove } from './functions/propmods';


// Set rejectionMapper of DexiePromise so that it generally tries to map
Expand All @@ -29,6 +29,6 @@ Debug.setDebug(Debug.debug, dexieStackFrameFilter);
export { RangeSet, mergeRanges, rangesOverlap } from "./helpers/rangeset";
export { Dexie, liveQuery }; // Comply with public/index.d.ts.
export { Entity };
export { cmp };
export { PropModSymbol, PropModification, replacePrefix };
export { cmp };
export { PropModSymbol, PropModification, replacePrefix, add, remove };
export default Dexie;
2 changes: 2 additions & 0 deletions src/public/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ export function rangesOverlap(
declare var RangeSet: RangeSetConstructor;
export function cmp(a: any, b: any): number;
export function replacePrefix(a: string, b: string): PropModification;
export function add(num: number | bigint | any[]): PropModification;
export function remove(num: number | bigint | any[]): PropModification;

/** Exporting 'Dexie' as the default export.
**/
Expand Down
4 changes: 4 additions & 0 deletions src/public/types/prop-modification.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,15 @@ export declare const PropModSymbol: unique symbol;

export type PropModSpec = {
replacePrefix?: [string, string];
add?: number | BigInt | Array<string | number>;
remove?: number | BigInt | Array<string | number>;
}

export class PropModification implements PropModSpec {
[PropModSymbol]?: true;
replacePrefix?: [string, string];
add?: number | BigInt | Array<string | number>;
remove?: number | BigInt | Array<string | number>;

execute<T>(value: T): T;

Expand Down
50 changes: 48 additions & 2 deletions test/tests-collection.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import Dexie, {replacePrefix} from 'dexie';
import Dexie, {replacePrefix, add, remove} from 'dexie';
import {module, stop, start, test, asyncTest, equal, ok} from 'QUnit';
import {resetDatabase, supports, spawnedTest, promisedTest} from './dexie-unittest-utils';
import { deepEqual } from './deepEqual';
Expand Down Expand Up @@ -669,4 +669,50 @@ promisedTest("Collection.modify() with replace", async () => {
{id: 1001, foo: "B", parentPath: "Y"},
{id: 1002, foo: "C", parentPath: "Z"}
], "Omitting where-criteria will still check the prefix before replacing");
});
});

promisedTest("Collection.modify() with add / remove", async () => {
await db.users.bulkAdd([
{id: 1100, foo: "A", age: 18, pets: ["dog", "cat"], balance: 100},
{id: 1101, foo: "A", age: 18, pets: ["cat", "dog"], balance: 100},
]);

// Mathematical add:
db.users.update(1100, {
age: add(1)
});
await db.users.update(1100, {
age: add(1)
});
let user = await db.users.get(1100);
equal(user.age, 20, "Both add operations have taken place");

// Money transaction
await db.transaction('rw', db.users, ()=> {
db.users.update(1100, {balance: add(100)});
db.users.update(1101, {balance: add(-100)});
});
let [user1, user2] = await db.users.bulkGet([1100, 1101]);
equal(user1.balance, 200, "User 1 has 200 after transaction");
equal(user2.balance, 0, "User 2 has 0 after transaction");

// Add to set
await db.users.update(1100, { pets: add(["elephant"])});
user = await db.users.get(1100);
deepEqual(user.pets, ["cat", "dog", "elephant"], "Elephant was added and pets where sorted");
await db.users.update(1100, { pets: remove(["dog"])});
user = await db.users.get(1100);
deepEqual(user.pets, ["cat", "elephant"], "Dog was removed");

// Multiple operations
await db.users.update(1100, {
pets: add(["rabbit", "cow"]),
age: remove(1),
balance: add(1_000_000)
});
user = await db.users.get(1100);
deepEqual(user.pets, ["cat", "cow", "elephant", "rabbit"], "rabbit and cow where added");
equal(user.age, 19, "Age was decremented from 20 to 19");
equal(user.balance, 1_000_200, "Balance was increased with a million");
});

0 comments on commit 16cd48a

Please sign in to comment.