|
import Dexie, {TrackedChange} from 'dexie'; |
|
import {TrackedTransaction} from './trackedTransaction'; |
|
|
|
|
|
describe(`trackedTransaction`, () => { |
|
class DexieTest extends Dexie { |
|
table1: Dexie.Table<object, number>; |
|
table2: Dexie.Table<object>; |
|
log: Dexie.Table<object, number>; |
|
|
|
constructor() { |
|
super('test', {addons: [TrackedTransaction]}); |
|
|
|
this.version(1) |
|
.stores({ |
|
table1: 'id', // inbound primary key |
|
table2: '', // non-inbound primary key |
|
log: '++id', // autoIncrement primary key |
|
}); |
|
|
|
this.table1 = this.table('table1'); |
|
this.table2 = this.table('table2'); |
|
this.log = this.table('log'); |
|
} |
|
} |
|
|
|
let db: DexieTest; |
|
beforeEach(() => { |
|
db = new DexieTest(); |
|
}); |
|
|
|
// Tracking basics |
|
|
|
describe(`tracking`, () => { |
|
|
|
it(`tracks changes in a transaction`, async (done) => { |
|
await db.trackedTransaction('rw', [db.table1, db.table2], |
|
async () => { |
|
await db.table1.add({id: 1, a: '1'}); |
|
await db.table1.put({id: 1, a: '2'}); |
|
await db.table2.put({b: '3'}, 'theKey'); // external key |
|
await db.table1.delete(1); |
|
}, |
|
(changes) => { |
|
expect(changes).toEqual<TrackedChange[]>([ |
|
{tableName: 'table1', type: 'add', keys: [1], before: [undefined], after: [{id: 1, a: '1'}]}, |
|
{tableName: 'table1', type: 'put', keys: [1], before: [{id: 1, a: '1'}], after: [{id: 1, a: '2'}]}, |
|
{tableName: 'table2', type: 'put', keys: ['theKey'], before: [undefined], after: [{b: '3'}]}, |
|
{tableName: 'table1', type: 'delete', keys: [1], before: [{id: 1, a: '2'}], after: [undefined]}, |
|
]); |
|
done(); |
|
}); |
|
}); |
|
|
|
it(`tracks changes in bulk operations`, async(done) => { |
|
await db.trackedTransaction('rw', [db.table1, db.table2], |
|
async () => { |
|
await db.table1.bulkAdd([{id: 1}, {id: 2}, {id: 3}]); |
|
await db.table1.bulkPut([{id: 2, a: '2'}, {id: 4, a: '4'}]); |
|
await db.table2.bulkPut([{b: '1'}, {b: '3'}], ['key1', 'key3']); // external key |
|
await db.table1.bulkDelete([1, 3]); |
|
}, |
|
async (changes) => { |
|
expect(changes).toEqual<TrackedChange[]>([ |
|
{tableName: 'table1', type: 'add', keys: [1, 2, 3], |
|
before: [undefined, undefined, undefined], after: [{id: 1}, {id: 2}, {id: 3}]}, |
|
{tableName: 'table1', type: 'put', keys: [2, 4], |
|
before: [{id: 2}, undefined], after: [{id: 2, a: '2'}, {id: 4, a: '4'}]}, |
|
{tableName: 'table2', type: 'put', keys: ['key1', 'key3'], |
|
before: [undefined, undefined], after: [{b: '1'}, {b: '3'}]}, |
|
{tableName: 'table1', type: 'delete', keys: [1, 3], |
|
before: [{id: 1}, {id: 3}], after: [undefined, undefined]}, |
|
]); |
|
await expect(db.table1.toArray()).resolves |
|
.toEqual([{id: 2, a: '2'}, {id: 4, a: '4'}]); |
|
done(); |
|
}); |
|
}); |
|
|
|
it(`allows additional (untracked) operations in report`, async () => { |
|
await db.trackedTransaction('rw', [db.table1, db.log], |
|
() => db.table1.add({id: 1}), |
|
async (changes) => { |
|
expect(changes).toBeArrayOfSize(1); |
|
// Add to log table. Note that this table must be included in the transaction. |
|
// Also, note that although this table has an autoIncrement primary key, it's |
|
// OK to add to it here (because changes are no longer being tracked). |
|
await db.log.add({time: 'now', ...changes[0]}); |
|
expect(changes).toBeArrayOfSize(1); // previous line didn't push any change |
|
}); |
|
await expect(await db.log.toArray()).toEqual([{ |
|
id: 1, time: 'now', tableName: 'table1', type: 'add', |
|
keys: [1], before: [undefined], after: [{id: 1}] |
|
}]); |
|
}); |
|
|
|
it(`correctly handles Collection.modify`, async (done) => { |
|
// This is really just checking that Collection.modify(fn) |
|
// turns into one of the DBCore mutations we already handle. |
|
await db.table1.bulkAdd([{id: 1, a: 1}, {id: 2, a: 2}]); |
|
await db.trackedTransaction('rw', [db.table1], |
|
() => db.table1.toCollection().modify( |
|
(obj: {a: number}) => obj.a += 1), |
|
(changes) => { |
|
expect(changes).toEqual<TrackedChange[]>([{ |
|
tableName: 'table1', type: 'put', keys: [1, 2], |
|
before: [{id: 1, a: 1}, {id: 2, a: 2}], |
|
after: [{id: 1, a: 2}, {id: 2, a: 3}] |
|
}]); |
|
done(); |
|
}); |
|
}); |
|
|
|
it(`handles autoIncrement primary keys`, async (done) => { |
|
await db.trackedTransaction('rw', [db.log], |
|
async () => { |
|
const key1 = await db.log.add({a: 1}); |
|
await db.log.put({id: key1, a: 2}); |
|
await db.log.bulkPut([{id: key1, a: 3}, {b: 1}]); |
|
await db.log.delete(key1); |
|
}, |
|
(changes) => { |
|
expect(changes).toEqual<TrackedChange[]>([ |
|
{tableName: 'log', type: 'add', keys: [1], before: [undefined], after: [{id: 1, a: 1}]}, |
|
{tableName: 'log', type: 'put', keys: [1], before: [{id: 1, a: 1}], after: [{id: 1, a: 2}]}, |
|
{tableName: 'log', type: 'put', keys: [1, 2], |
|
before: [{id: 1, a: 2}, undefined], after: [{id: 1, a: 3}, {id: 2, b: 1}]}, |
|
{tableName: 'log', type: 'delete', keys: [1], before: [{id: 1, a: 3}], after: [undefined]}, |
|
]); |
|
done(); |
|
}); |
|
}); |
|
|
|
}); |
|
|
|
// Failures and rollbacks |
|
describe(`failures and rollbacks`, () => { |
|
|
|
it(`doesn't report failures handled within the transaction scope`, async (done) => { |
|
await db.trackedTransaction('rw', [db.table1], |
|
async () => { |
|
await db.table1.add({id: 1}); |
|
try { |
|
await db.table1.add({id: 1, error: true}); // this causes error... |
|
} catch (err) { // ... but catching it allows transaction to continue |
|
await db.table1.put({id: 1, caught: true}); |
|
} |
|
try { |
|
// Only the middle of these three items causes a failure... |
|
await db.table1.bulkAdd([{id: 2}, {id: 1, bulkError: true}, {id: 3}]); |
|
} catch (err) { |
|
// ... and if you catch it, the other two adds will succeed |
|
} |
|
}, |
|
async (changes) => { |
|
await expect(await db.table1.toArray()).toEqual([{id: 1, caught: true}, {id: 2}, {id: 3}]); |
|
expect(changes).toEqual<TrackedChange[]>([ |
|
{tableName: 'table1', type: 'add', keys: [1], before: [undefined], after: [{id: 1}]}, |
|
{tableName: 'table1', type: 'put', keys: [1], before: [{id: 1}], after: [{id: 1, caught: true}]}, |
|
{tableName: 'table1', type: 'add', keys: [2, 3], before: [undefined, undefined], after: [{id: 2}, {id: 3}]}, |
|
]); |
|
done(); |
|
}); |
|
}); |
|
|
|
it(`doesn't report any changes when scope fails`, async () => { |
|
const report = jest.fn(); |
|
await db.table1.add({id: 1}); |
|
const op = db.trackedTransaction('rw', [db.table1], |
|
() => db.table1.add({id: 1}), // add duplicate should fail |
|
report); |
|
await expect(op).rejects.toThrow(Dexie.ConstraintError); |
|
expect(report).not.toHaveBeenCalled(); |
|
}); |
|
|
|
it(`rolls back scope changes when report errors`, async () => { |
|
await db.table1.add({id: 1, updated: 'no'}); |
|
const op = db.trackedTransaction('rw', [db.table1], |
|
() => db.table1.put({id: 1, updated: 'yes'}), |
|
async () => { |
|
// Updated within the transaction |
|
await expect(db.table1.get(1)).resolves.toEqual({id: 1, updated: 'yes'}); |
|
throw new Error("Fail in report"); |
|
}); |
|
await expect(op).rejects.toThrow("Fail in report"); |
|
// Update from scope rolled back: |
|
await expect(db.table1.get(1)).resolves.toEqual({id: 1, updated: 'no'}); |
|
}); |
|
|
|
it(`rolls back scope changes when report fails`, async () => { |
|
// This is a slight variation on the previous test, |
|
// causing an IDB violation in report, rather than explicitly throwing. |
|
await db.table1.add({id: 1, updated: 'no'}); |
|
const op = db.trackedTransaction('rw', [db.table1], |
|
() => db.table1.put({id: 1, updated: 'yes'}), |
|
() => db.table1.add({id: 1})); // constraint error |
|
await expect(op).rejects.toThrow(Dexie.ConstraintError); |
|
// Update from scope rolled back: |
|
await expect(db.table1.get(1)).resolves.toEqual({id: 1, updated: 'no'}); |
|
}); |
|
|
|
}); |
|
|
|
// Nesting |
|
describe(`nesting`, () => { |
|
|
|
it(`tracks nested transactions`, async (done) => { |
|
await db.trackedTransaction('rw', [db.table1], |
|
async () => { |
|
await db.table1.add({id: 1}); |
|
await db.transaction('rw', db.table1, async() => { |
|
await db.table1.put({id: 1, a: 'A'}); |
|
await db.table1.put({id: 2}); |
|
}); |
|
await db.table1.delete(1); |
|
}, |
|
(changes) => { |
|
expect(changes).toEqual<TrackedChange[]>([ |
|
{tableName: 'table1', type: 'add', keys: [1], before: [undefined], after: [{id: 1}]}, |
|
{tableName: 'table1', type: 'put', keys: [1], before: [{id: 1}], after: [{id: 1, a: 'A'}]}, |
|
{tableName: 'table1', type: 'put', keys: [2], before: [undefined], after: [{id: 2}]}, |
|
{tableName: 'table1', type: 'delete', keys: [1], before: [{id: 1, a: 'A'}], after: [undefined]}, |
|
]); |
|
done(); |
|
}); |
|
}); |
|
|
|
it(`can be nested within a regular transaction`, async (done) => { |
|
await db.transaction('rw', [db.table1, db.log], async () => { |
|
let changes: ReadonlyArray<TrackedChange> = []; |
|
await db.trackedTransaction('rw', [db.table1], |
|
async () => db.table1.add({id: 1}), |
|
(chg) => { changes = chg; }); |
|
await db.log.add({log: true}); |
|
expect(changes).toEqual<TrackedChange[]>([ |
|
{tableName: 'table1', type: 'add', keys: [1], before: [undefined], after: [{id: 1}]}, |
|
]); |
|
done(); |
|
}); |
|
}); |
|
|
|
it(`cannot be nested within itself`, async () => { |
|
const op = db.trackedTransaction('rw', [db.table1], |
|
async () => { |
|
await db.table1.add({id: 1}); |
|
await db.trackedTransaction('rw', [db.table1], |
|
() => db.table1.add({id: 2}), |
|
(_changes) => null); |
|
}, |
|
(_changes) => null); |
|
await expect(op).rejects.toThrow("trackedTransaction may not be nested"); |
|
}); |
|
|
|
}); |
|
|
|
// Limitations (most of these could be fixed if needed) |
|
describe(`known limitations`, () => { |
|
|
|
it(`doesn't currently apply mapToClass to reported values`, async (done) => { |
|
// You can use mapToClass with trackedTransaction, but the before/after values |
|
// in your changes callback won't be mapped |
|
class FancyObject { |
|
constructor(readonly id: number) {} |
|
} |
|
db.table1.mapToClass(FancyObject); |
|
|
|
await db.trackedTransaction('rw', [db.table1], |
|
async () => { |
|
await db.table1.add(new FancyObject(22)); |
|
await expect(await db.table1.get(22)).toBeInstanceOf(FancyObject); // mapToClass is working |
|
await db.table1.put({id: 22, modified: true}); |
|
await db.table1.put(new FancyObject(15)); |
|
await db.table1.delete(22); |
|
}, |
|
async (changes) => { |
|
expect(changes).toBeArrayOfSize(4); |
|
|
|
expect(changes[0].after[0]).toEqual({id: 22}); |
|
expect(changes[0].after[0]).not.toBeInstanceOf(FancyObject); |
|
|
|
expect(changes[1].before[0]).toEqual({id: 22}); |
|
expect(changes[1].before[0]).not.toBeInstanceOf(FancyObject); |
|
expect(changes[1].after[0]).toEqual({id: 22, modified: true}); |
|
expect(changes[1].after[0]).not.toBeInstanceOf(FancyObject); |
|
|
|
expect(changes[2].after[0]).toEqual({id: 15}); |
|
expect(changes[2].after[0]).not.toBeInstanceOf(FancyObject); |
|
|
|
expect(changes[3].before[0]).toEqual({id: 22, modified: true}); |
|
expect(changes[3].before[0]).not.toBeInstanceOf(FancyObject); |
|
|
|
done(); |
|
}); |
|
}); |
|
|
|
it(`doesn't currently support deleteRange`, async () => { |
|
// deleteRange is the underlying operation used for Table.clear() and |
|
// Table.where(primaryKey).FILTER(...).delete() for the FILTERs above, |
|
// below, between, and inAnyRange. |
|
const op = db.trackedTransaction('rw', [db.table1], |
|
() => db.table1.where('id').above(0).delete(), |
|
(_changes) => null); |
|
await expect(op).rejects.toThrow( |
|
"trackedTransaction cannot handle deleteRange operation (on table 'table1')"); |
|
}); |
|
|
|
}); |
|
|
|
it(`doesn't cross the streams on concurrent transactions`, async () => { |
|
// Transactions in different tables (with "non-overlapping scopes") |
|
// may run concurrently. |
|
|
|
// https://dexie.org/docs/Dexie/Dexie.waitFor()#sample-1 |
|
function sleep(ms: number) { |
|
return new Dexie.Promise(resolve => setTimeout(resolve, ms)); |
|
} |
|
|
|
let changes1: ReadonlyArray<TrackedChange> = []; |
|
let changes2: ReadonlyArray<TrackedChange> = []; |
|
const sequence: string[] = []; // track operation order |
|
const tx1 = db.trackedTransaction('rw', [db.table1], |
|
async () => { |
|
sequence.push('tx1: adding 1'); |
|
await db.table1.add({id: 1}); |
|
sequence.push('tx1: added 1'); |
|
// await Dexie.waitFor(tx2); // doesn't work - deadlock |
|
await Dexie.waitFor(sleep(10)); |
|
sequence.push('tx1: adding 2'); |
|
await db.table1.add({id: 2}); |
|
sequence.push('tx1: added 2'); |
|
}, |
|
changes => { changes1 = changes; }); |
|
const tx2 = db.trackedTransaction('rw', [db.table2], |
|
async () => { |
|
sequence.push('tx2: start'); |
|
await Dexie.waitFor(sleep(5)); |
|
sequence.push('tx2: adding 3'); |
|
await db.table2.add({}, 3); |
|
sequence.push('tx2: added 3'); |
|
}, |
|
changes => { changes2 = changes; }); |
|
|
|
await Dexie.Promise.all([tx1, tx2]); |
|
// Make sure the changes were not scrambled between transactions: |
|
expect(changes1).toEqual([ |
|
{tableName: 'table1', type: 'add', keys: [1], before: [undefined], after: [{id: 1}]}, |
|
{tableName: 'table1', type: 'add', keys: [2], before: [undefined], after: [{id: 2}]}, |
|
]); |
|
expect(changes2).toEqual([ |
|
{tableName: 'table2', type: 'add', keys: [3], before: [undefined], after: [{}]}, |
|
]); |
|
|
|
// Check that the transactions ran concurrently: |
|
expect(sequence).toEqual([ |
|
"tx1: adding 1", |
|
"tx2: start", |
|
"tx1: added 1", |
|
// This is what we're really expecting, but fake-indexeddb |
|
// is for some reason delaying tx2 requests until tx1 finishes: |
|
// "tx2: adding 3", |
|
// "tx2: added 3", |
|
"tx1: adding 2", |
|
"tx1: added 2", |
|
"tx2: adding 3", |
|
"tx2: added 3", |
|
]); |
|
}); |
|
|
|
}); |