Integration of Dexie.js and Y.js
Install
npm install dexie
npm install yjs
npm install y-dexie
Database Declaration
import { Dexie, EntityTable } from 'dexie';
import yDexie from 'y-dexie';
import type * as Y from 'yjs';
interface Friend {
id: number;
name: string;
age: number;
notes: Y.Doc;
}
const db = new Dexie('myDB', { addons: [yDexie] }) as Dexie & {
friends: EntityTable<Friend, 'id'>
}
db.version(1).stores({
friends: `
++id,
name,
age,
notes: Y.Doc`,
});
When an Y.Doc property has been declared, every object will contain that property. It
will be there using a property at the prototype level. The physical notes property
is persisted in its own indexedDB table, unlike name, id and age which are
stored physically on the object.
Every time you retrieve a database object, its Y.Doc properties will be available. Y.Doc
properties can never be null or undefined. However, actual storage of the document data will not
be created until someone accesses the document and manipulates it.
Basic Document Access
import { db } from './db.js';
import { DexieYProvider } from 'y-dexie';
const friend = await db.friends.get(friendId);
const doc = friend.notes;
const provider = DexieYProvider.load(doc);
await provider.whenLoaded;
doc.getText().insert(0, 'hello world');
doc.getMap('myMap').set('key', 'value');
...
const rootText = doc.getText();
const subMap = doc.getMap('myMap').get('key');
DexieYProvider.release(doc);
Using the using keyword (ES2023)
DexieYProvider supports the new using keyword available in Typescript and the most modern web frameworks:
using provider = DexieYProvider.load(doc);
await provider.whenLoaded;
...
The above line is equivalent to the following:
const provider = DexieYProvider.load(doc);
try {
await provider.whenLoaded;
...
} finally {
DexieYProvider.release(doc);
}
Notices:
DexieYProvider.load() and DexieYProvider.release() maintains a reference counter
on the document it loads and releases. When the reference count reaches zero, doc.destroy() is called on the Y.Doc instance.
Y.Doc properties are declared at prototype level - they are not own properties and not physically located on their host object.
Calling friend.notes twice will return the same Y.Doc instance unless the first one has been destroyed, then the second access will return a new Y.Doc instance representing the same document.
Rules for Y properties on objects
- Y properties are never nullish. If declared in the dexie schema, they'll exist on all objects from all queries (toArray(), get() etc).
- Y properties are not
own properties. They are set on the prototype of the returned object.
- Y properties are readonly, except when adding new objects to a table (using table.add() or bulkAdd(). Only then, it's possible to create a new Y.Doc() instance - empty or with content - and put it on the property of the object being inserted to the database)
- If providing a custom Y.Doc to add() or bulkAdd() its udates will be cloned when added.
- If not providing the Y.Doc or setting the Y property to null when adding objects, there will still be an Y.Doc property on query results of the object, since Y props are defined by the schema and cannot be null or undefined.
- Y properties on dexie objects are readonly. You can not replace them with another document or update them using table.update() or collection.modify(). The only way to update Y.Docs is via the Y.Doc methods on the document instance.
- Y properties are not loaded until using DexieYProvider.load() or the new react hook
useDocument()
- Y.Doc instances are kept in a global cache integrated with FinalizationRegistry. First time you access the getter, it will be created, and will stay in cache until it's garbage collected. This means that you'll always get the same Y.Doc instance when querying the same Y property of a the same object. This holds true even if the there are multiple obj instances representing the same ID in the database. All of these will hold one single instance of the Y.Doc because the cache is connected to the primary key of the parent object.
How it works
Internally, every declared Y property generates a dedicated table for Y.js updates tied to the parent table and the property name. Whenever a document is updated, a new entry in this table is added.
DexieYProvider is responsible of loading and observing updates in both directions.
Integrations
Y.js allows multiple providers on the same document. It is possible to combine DexieYProvider with other providers, but it is also possible for dexie addons to extend the provider behavior - such as adding awareness and sync.
Adding sync and awareness
The dexie-cloud-addon integrates with y-dexie and extends the existing DexieYProvider to become a provider also for sync and awareness. Just like other data, Y.Docs
will sync to Dexie Cloud Server. A websocket connection will propagate awareness
and updates between clients.
Using with React
New hook useDocument() makes use of DexieYProvider as a hook rather than loading and releasing imperatively.
import { useLiveQuery, useDocument } from 'dexie-react-hooks';
function MyComponent(friendId: number) {
const friend = useLiveQuery(() => db.friends.get(friendId));
const provider = useDocument(friend?.notes);
return provider
? <NotesEditor doc={friend.notes} provider={provider} />
: null;
}
In the sample above, the NotesEditor component could represent any react component backed
by the ecosystem of text editors supporting Y.js, such as TipTap or Prosemirror.
Example Applications
This application showcases the following:
- Collaborative text editing with y-dexie and tiptap
- Sync and awareness with dexie cloud
- Full-text search with lunr