rxdb
Version:
A local-first realtime NoSQL Database for JavaScript applications - https://rxdb.info/
455 lines (378 loc) • 19.9 kB
text/typescript
/**
* Base test suite for replication plugins.
* All replication plugins should run these tests
* to ensure consistent behavior across different backends.
*
* The tests use HumanDocumentType (passportId, firstName, lastName, age)
* with humansCollection.create() for collection creation.
*/
import assert from 'assert';
import { wait } from 'async-test-util';
import type { RxCollection } from '../../types/index.d.ts';
import { ensureNotFalsy } from '../utils/index.ts';
import * as humansCollection from './humans-collection.ts';
import * as schemaObjects from './schema-objects.ts';
import {
awaitCollectionsHaveEqualState,
ensureReplicationHasNoErrors
} from './test-util.ts';
export type ReplicationBaseTestSuiteConfig = {
/**
* Start a live replication for a collection.
* Must target a shared server endpoint so that multiple
* collections can replicate to the same backend.
* May return a Promise to support plugins where the replication factory is async.
*/
startReplication(collection: RxCollection<any>): any;
/**
* Run a one-shot (non-live) sync and wait for completion.
* Must target the same server endpoint as startReplication.
*/
syncOnce(collection: RxCollection<any>): Promise<void>;
/**
* Get all documents from the server,
* including soft-deleted ones when the backend uses soft deletes.
*/
getAllServerDocs(): Promise<any[]>;
/**
* Remove all documents from the server.
*/
cleanUpServer(): Promise<void>;
/**
* Whether the server uses soft deletes
* (marks docs as deleted instead of removing them).
*/
softDeletes?: boolean;
/**
* Check if a server document is marked as deleted.
* Required when softDeletes is true.
*/
isDeleted?(serverDoc: any): boolean;
/**
* Get the client primary key (passportId) from a server document.
* Different backends store the primary key in different fields.
*/
getPrimaryOfServerDoc(serverDoc: any): string;
/**
* Additional wait time for eventually consistent backends (ms).
*/
waitTime?: number;
/**
* When provided, attachment replication tests are run.
* The callback should perform a one-shot sync for a collection whose
* schema has `attachments: {}` defined, targeting the same server endpoint.
*/
syncOnceWithAttachments?(collection: RxCollection<any>): Promise<void>;
/**
* When provided, a test is run that verifies attachment binary data is NOT
* replicated when attachments are disabled (e.g. `attachments: false`).
* The callback should perform a one-shot sync for a collection whose schema
* has `attachments: {}` defined, but with attachment replication turned off.
*/
syncOnceWithAttachmentsDisabled?(collection: RxCollection<any>): Promise<void>;
};
/**
* Runs the base test suite for a replication plugin.
* Call this inside a describe() block in your replication test file.
*
* The config callbacks should target a shared server endpoint.
* Each test calls cleanUpServer() at the start to ensure a clean state.
*
* @example
* ```ts
* describe('replication-nats.test.ts', () => {
* runReplicationBaseTestSuite({
* startReplication: (collection) => syncNats(collection, natsName),
* syncOnce: (collection) => syncOnceNats(collection, natsName),
* getAllServerDocs: () => getAllDocsOfServer(natsName),
* cleanUpServer: async () => { ... },
* softDeletes: true,
* isDeleted: (doc) => doc._deleted,
* getPrimaryOfServerDoc: (doc) => doc.passportId,
* });
* });
* ```
*/
export function runReplicationBaseTestSuite(config: ReplicationBaseTestSuiteConfig): void {
const waitTime = config.waitTime || 0;
describe('base test suite', () => {
describe('live replication', () => {
it('push replication to client-server', async () => {
await config.cleanUpServer();
const collection = await humansCollection.create(2, undefined, false);
const replicationState = await Promise.resolve(config.startReplication(collection));
ensureReplicationHasNoErrors(replicationState);
await replicationState.awaitInitialReplication();
let docsOnServer = await config.getAllServerDocs();
assert.strictEqual(docsOnServer.length, 2);
// insert another one
await collection.insert(schemaObjects.humanData());
await replicationState.awaitInSync();
docsOnServer = await config.getAllServerDocs();
assert.strictEqual(docsOnServer.length, 3);
// update one
const doc = await collection.findOne().exec(true);
await doc.incrementalPatch({ age: 100 });
await replicationState.awaitInSync();
docsOnServer = await config.getAllServerDocs();
assert.strictEqual(docsOnServer.length, 3);
const serverDoc = ensureNotFalsy(
docsOnServer.find(d => config.getPrimaryOfServerDoc(d) === doc.primary)
);
assert.strictEqual(serverDoc.age, 100);
// delete one
await doc.getLatest().remove();
await replicationState.awaitInSync();
docsOnServer = await config.getAllServerDocs();
if (config.softDeletes) {
assert.strictEqual(docsOnServer.length, 3);
assert.ok(docsOnServer.find(d => config.isDeleted!(d)));
} else {
assert.strictEqual(docsOnServer.length, 2);
}
await collection.database.close();
});
it('two collections', async () => {
await config.cleanUpServer();
const collectionA = await humansCollection.create(0, undefined, false);
await collectionA.insert(schemaObjects.humanData('1aaa'));
const collectionB = await humansCollection.create(0, undefined, false);
await collectionB.insert(schemaObjects.humanData('1bbb'));
const replicationStateA = await Promise.resolve(config.startReplication(collectionA));
ensureReplicationHasNoErrors(replicationStateA);
await replicationStateA.awaitInitialReplication();
const replicationStateB = await Promise.resolve(config.startReplication(collectionB));
ensureReplicationHasNoErrors(replicationStateB);
await replicationStateB.awaitInitialReplication();
if (waitTime) { await wait(waitTime); }
/**
* Explicitly resync both sides before checking equality.
* This makes the test independent of realtime event delivery speed:
* even if realtime events were slow or missed, the explicit pull
* from the server ensures both sides have the latest data.
*/
replicationStateA.reSync();
replicationStateB.reSync();
await replicationStateA.awaitInSync();
await replicationStateB.awaitInSync();
await awaitCollectionsHaveEqualState(collectionA, collectionB, 'init sync');
// insert one
await collectionA.insert(schemaObjects.humanData('insert-a'));
await replicationStateA.awaitInSync();
await replicationStateB.awaitInSync();
if (waitTime) { await wait(waitTime); }
replicationStateA.reSync();
replicationStateB.reSync();
await replicationStateA.awaitInSync();
await replicationStateB.awaitInSync();
await awaitCollectionsHaveEqualState(collectionA, collectionB, 'after insert');
// delete one
await collectionB.findOne().remove();
await replicationStateB.awaitInSync();
await replicationStateA.awaitInSync();
if (waitTime) { await wait(waitTime); }
replicationStateA.reSync();
replicationStateB.reSync();
await replicationStateA.awaitInSync();
await replicationStateB.awaitInSync();
await awaitCollectionsHaveEqualState(collectionA, collectionB, 'after deletion');
// insert many
await collectionA.bulkInsert(
new Array(10)
.fill(0)
.map(() => schemaObjects.humanData(undefined, undefined, 'bulk-insert-A'))
);
await replicationStateA.awaitInSync();
await replicationStateB.awaitInSync();
if (waitTime) { await wait(waitTime); }
replicationStateA.reSync();
replicationStateB.reSync();
await replicationStateA.awaitInSync();
await replicationStateB.awaitInSync();
await awaitCollectionsHaveEqualState(collectionA, collectionB, 'after insert many');
// insert at both collections at the same time
await Promise.all([
collectionA.insert(schemaObjects.humanData('insert-parallel-a')),
collectionB.insert(schemaObjects.humanData('insert-parallel-b'))
]);
await replicationStateA.awaitInSync();
await replicationStateB.awaitInSync();
if (waitTime) { await wait(waitTime); }
replicationStateA.reSync();
replicationStateB.reSync();
await replicationStateA.awaitInSync();
await replicationStateB.awaitInSync();
await awaitCollectionsHaveEqualState(collectionA, collectionB, 'after insert both at same time');
await collectionA.database.close();
await collectionB.database.close();
});
});
describe('conflict handling', () => {
it('should keep the master state as default conflict handler', async () => {
await config.cleanUpServer();
const c1 = await humansCollection.create(1);
const c2 = await humansCollection.create(0);
await config.syncOnce(c1);
await config.syncOnce(c2);
const doc1 = await c1.findOne().exec(true);
const doc2 = await c2.findOne().exec(true);
// make update on both sides
await doc1.incrementalPatch({ firstName: 'c1' });
await doc2.incrementalPatch({ firstName: 'c2' });
await config.syncOnce(c2);
// cause conflict
await config.syncOnce(c1);
/**
* Must have kept the master state c2
*/
assert.strictEqual(doc1.getLatest().firstName, 'c2');
await c1.database.close();
await c2.database.close();
});
});
if (config.syncOnceWithAttachments) {
describe('attachment replication', () => {
it('should replicate an inserted attachment', async () => {
await config.cleanUpServer();
const c1 = await humansCollection.createAttachments(0);
const c2 = await humansCollection.createAttachments(0);
const doc1 = await c1.insert(schemaObjects.humanData('att-insert-base'));
await doc1.putAttachment({
id: 'test.txt',
data: new Blob(['hello replication'], { type: 'text/plain' }),
type: 'text/plain'
});
await config.syncOnceWithAttachments!(c1);
await config.syncOnceWithAttachments!(c2);
const doc2 = await c2.findOne('att-insert-base').exec(true);
const att2 = doc2.getAttachment('test.txt');
assert.ok(att2, 'attachment must exist on second collection');
assert.strictEqual(await att2.getStringData(), 'hello replication');
await c1.database.close();
await c2.database.close();
});
it('should replicate an updated attachment', async () => {
await config.cleanUpServer();
const c1 = await humansCollection.createAttachments(0);
const c2 = await humansCollection.createAttachments(0);
const doc1 = await c1.insert(schemaObjects.humanData('att-update-base'));
await doc1.putAttachment({
id: 'file.txt',
data: new Blob(['v1'], { type: 'text/plain' }),
type: 'text/plain'
});
await config.syncOnceWithAttachments!(c1);
await config.syncOnceWithAttachments!(c2);
// Update on c1
const doc1v2 = await c1.findOne('att-update-base').exec(true);
await doc1v2.putAttachment({
id: 'file.txt',
data: new Blob(['v2'], { type: 'text/plain' }),
type: 'text/plain'
});
await config.syncOnceWithAttachments!(c1);
await config.syncOnceWithAttachments!(c2);
const doc2 = await c2.findOne('att-update-base').exec(true);
const att2 = doc2.getAttachment('file.txt');
assert.ok(att2, 'updated attachment must exist');
assert.strictEqual(await att2.getStringData(), 'v2');
await c1.database.close();
await c2.database.close();
});
it('should replicate a deleted attachment', async () => {
await config.cleanUpServer();
const c1 = await humansCollection.createAttachments(0);
const c2 = await humansCollection.createAttachments(0);
const doc1 = await c1.insert(schemaObjects.humanData('att-delete-base'));
await doc1.putAttachment({
id: 'remove.txt',
data: new Blob(['bye'], { type: 'text/plain' }),
type: 'text/plain'
});
await config.syncOnceWithAttachments!(c1);
await config.syncOnceWithAttachments!(c2);
// Delete on c1
const doc1v2 = await c1.findOne('att-delete-base').exec(true);
await doc1v2.getAttachment('remove.txt')!.remove();
await config.syncOnceWithAttachments!(c1);
await config.syncOnceWithAttachments!(c2);
const doc2 = await c2.findOne('att-delete-base').exec(true);
assert.strictEqual(
doc2.getAttachment('remove.txt'),
null,
'deleted attachment must not exist on second collection'
);
await c1.database.close();
await c2.database.close();
});
it('should keep the master attachment state on conflict', async () => {
await config.cleanUpServer();
const c1 = await humansCollection.createAttachments(0);
const c2 = await humansCollection.createAttachments(0);
const doc1 = await c1.insert(schemaObjects.humanData('att-conflict-base'));
await doc1.putAttachment({
id: 'shared.txt',
data: new Blob(['initial'], { type: 'text/plain' }),
type: 'text/plain'
});
// Both collections get the initial state
await config.syncOnceWithAttachments!(c1);
await config.syncOnceWithAttachments!(c2);
// c2 updates the attachment and pushes first (becomes master)
const doc2 = await c2.findOne('att-conflict-base').exec(true);
await doc2.putAttachment({
id: 'shared.txt',
data: new Blob(['from c2 - master'], { type: 'text/plain' }),
type: 'text/plain'
});
await config.syncOnceWithAttachments!(c2);
// c1 also updates but pushes later → conflict → master (c2) wins
const doc1v2 = await c1.findOne('att-conflict-base').exec(true);
await doc1v2.putAttachment({
id: 'shared.txt',
data: new Blob(['from c1 - should lose'], { type: 'text/plain' }),
type: 'text/plain'
});
await config.syncOnceWithAttachments!(c1);
// Extra rounds to converge: some backends need additional sync
// cycles for the conflict-resolved attachment state to propagate.
await config.syncOnceWithAttachments!(c2);
await config.syncOnceWithAttachments!(c1);
const doc1Final = await c1.findOne('att-conflict-base').exec(true);
const att1Final = doc1Final.getAttachment('shared.txt');
assert.ok(att1Final, 'attachment must still exist after conflict resolution');
const content = await att1Final.getStringData();
assert.strictEqual(content, 'from c2 - master', 'master state (c2) must win the conflict');
await c1.database.close();
await c2.database.close();
});
});
}
if (config.syncOnceWithAttachmentsDisabled) {
describe('attachment replication disabled', () => {
it('should not replicate attachment data when attachments are disabled', async () => {
await config.cleanUpServer();
const c1 = await humansCollection.createAttachments(0);
const c2 = await humansCollection.createAttachments(0);
const doc1 = await c1.insert(schemaObjects.humanData('att-disabled'));
await doc1.putAttachment({
id: 'no-sync.txt',
data: new Blob(['secret'], { type: 'text/plain' }),
type: 'text/plain'
});
await config.syncOnceWithAttachmentsDisabled!(c1);
await config.syncOnceWithAttachmentsDisabled!(c2);
const doc2 = await c2.findOne('att-disabled').exec(true);
assert.ok(doc2, 'document should be replicated even with attachments disabled');
const att2 = doc2.getAttachment('no-sync.txt');
if (att2) {
const content = await att2.getStringData().catch(() => '');
assert.notStrictEqual(content, 'secret', 'attachment binary data must not have been replicated');
}
await c1.database.close();
await c2.database.close();
});
});
}
});
}