feat(nbstore): improve nbstore (#9512)

This commit is contained in:
EYHN
2025-01-06 09:38:03 +00:00
parent a2563d2180
commit 46c8c4a408
103 changed files with 3337 additions and 3423 deletions

View File

@@ -22,13 +22,27 @@ type ChannelMessage =
collectId: string;
};
interface BroadcastChannelAwarenessStorageOptions {
id: string;
}
export class BroadcastChannelAwarenessStorage extends AwarenessStorageBase {
static readonly identifier = 'BroadcastChannelAwarenessStorage';
override readonly storageType = 'awareness';
override readonly connection = new BroadcastChannelConnection(this.options);
override readonly connection = new BroadcastChannelConnection({
id: this.options.id,
});
get channel() {
return this.connection.inner;
}
constructor(
private readonly options: BroadcastChannelAwarenessStorageOptions
) {
super();
}
private readonly subscriptions = new Map<
string,
Set<{

View File

@@ -1,10 +1,13 @@
import { AutoReconnectConnection } from '../../connection';
import type { StorageOptions } from '../../storage';
export interface BroadcastChannelConnectionOptions {
id: string;
}
export class BroadcastChannelConnection extends AutoReconnectConnection<BroadcastChannel> {
readonly channelName = `channel:${this.opts.peer}:${this.opts.type}:${this.opts.id}`;
readonly channelName = `channel:${this.opts.id}`;
constructor(private readonly opts: StorageOptions) {
constructor(private readonly opts: BroadcastChannelConnectionOptions) {
super();
}

View File

@@ -0,0 +1,6 @@
import type { StorageConstructor } from '..';
import { BroadcastChannelAwarenessStorage } from './awareness';
export const broadcastChannelStorages = [
BroadcastChannelAwarenessStorage,
] satisfies StorageConstructor[];

View File

@@ -4,21 +4,33 @@ import { share } from '../../connection';
import {
type AwarenessRecord,
AwarenessStorageBase,
type AwarenessStorageOptions,
} from '../../storage/awareness';
import type { SpaceType } from '../../utils/universal-id';
import {
base64ToUint8Array,
SocketConnection,
uint8ArrayToBase64,
} from './socket';
interface CloudAwarenessStorageOptions extends AwarenessStorageOptions {
socketOptions: SocketOptions;
interface CloudAwarenessStorageOptions {
socketOptions?: SocketOptions;
serverBaseUrl: string;
type: SpaceType;
id: string;
}
export class CloudAwarenessStorage extends AwarenessStorageBase<CloudAwarenessStorageOptions> {
export class CloudAwarenessStorage extends AwarenessStorageBase {
static readonly identifier = 'CloudAwarenessStorage';
constructor(private readonly options: CloudAwarenessStorageOptions) {
super();
}
connection = share(
new SocketConnection(this.peer, this.options.socketOptions)
new SocketConnection(
`${this.options.serverBaseUrl}/`,
this.options.socketOptions
)
);
private get socket() {
@@ -28,8 +40,8 @@ export class CloudAwarenessStorage extends AwarenessStorageBase<CloudAwarenessSt
override async update(record: AwarenessRecord): Promise<void> {
const encodedUpdate = await uint8ArrayToBase64(record.bin);
this.socket.emit('space:update-awareness', {
spaceType: this.spaceType,
spaceId: this.spaceId,
spaceType: this.options.type,
spaceId: this.options.id,
docId: record.docId,
awarenessUpdate: encodedUpdate,
});
@@ -44,8 +56,8 @@ export class CloudAwarenessStorage extends AwarenessStorageBase<CloudAwarenessSt
// leave awareness
const leave = () => {
this.socket.emit('space:leave-awareness', {
spaceType: this.spaceType,
spaceId: this.spaceId,
spaceType: this.options.type,
spaceId: this.options.id,
docId: id,
});
};
@@ -53,14 +65,14 @@ export class CloudAwarenessStorage extends AwarenessStorageBase<CloudAwarenessSt
// join awareness, and collect awareness from others
const joinAndCollect = async () => {
await this.socket.emitWithAck('space:join-awareness', {
spaceType: this.spaceType,
spaceId: this.spaceId,
spaceType: this.options.type,
spaceId: this.options.id,
docId: id,
clientVersion: BUILD_CONFIG.appVersion,
});
this.socket.emit('space:load-awarenesses', {
spaceType: this.spaceType,
spaceId: this.spaceId,
spaceType: this.options.type,
spaceId: this.options.id,
docId: id,
});
};
@@ -87,8 +99,8 @@ export class CloudAwarenessStorage extends AwarenessStorageBase<CloudAwarenessSt
docId: string;
}) => {
if (
spaceId === this.spaceId &&
spaceType === this.spaceType &&
spaceId === this.options.id &&
spaceType === this.options.type &&
docId === id
) {
(async () => {
@@ -96,8 +108,8 @@ export class CloudAwarenessStorage extends AwarenessStorageBase<CloudAwarenessSt
if (record) {
const encodedUpdate = await uint8ArrayToBase64(record.bin);
this.socket.emit('space:update-awareness', {
spaceType: this.spaceType,
spaceId: this.spaceId,
spaceType: this.options.type,
spaceId: this.options.id,
docId: record.docId,
awarenessUpdate: encodedUpdate,
});
@@ -118,8 +130,8 @@ export class CloudAwarenessStorage extends AwarenessStorageBase<CloudAwarenessSt
awarenessUpdate: string;
}) => {
if (
spaceId === this.spaceId &&
spaceType === this.spaceType &&
spaceId === this.options.id &&
spaceType === this.options.type &&
docId === id
) {
onUpdate({

View File

@@ -1,35 +1,30 @@
import {
deleteBlobMutation,
gqlFetcherFactory,
listBlobsQuery,
releaseDeletedBlobsMutation,
setBlobMutation,
} from '@affine/graphql';
import { DummyConnection } from '../../connection';
import {
type BlobRecord,
BlobStorageBase,
type BlobStorageOptions,
} from '../../storage';
import { type BlobRecord, BlobStorageBase } from '../../storage';
import { HttpConnection } from './http';
interface CloudBlobStorageOptions extends BlobStorageOptions {
apiBaseUrl: string;
interface CloudBlobStorageOptions {
serverBaseUrl: string;
id: string;
}
export class CloudBlobStorage extends BlobStorageBase<CloudBlobStorageOptions> {
private readonly gql = gqlFetcherFactory(
this.options.apiBaseUrl + '/graphql'
);
override connection = new DummyConnection();
export class CloudBlobStorage extends BlobStorageBase {
static readonly identifier = 'CloudBlobStorage';
constructor(private readonly options: CloudBlobStorageOptions) {
super();
}
readonly connection = new HttpConnection(this.options.serverBaseUrl);
override async get(key: string) {
const res = await fetch(
this.options.apiBaseUrl +
'/api/workspaces/' +
this.spaceId +
'/blobs/' +
key,
const res = await this.connection.fetch(
'/api/workspaces/' + this.options.id + '/blobs/' + key,
{
cache: 'default',
headers: {
@@ -38,49 +33,53 @@ export class CloudBlobStorage extends BlobStorageBase<CloudBlobStorageOptions> {
}
);
if (!res.ok) {
if (res.status === 404) {
return null;
}
const data = await res.arrayBuffer();
try {
const blob = await res.blob();
return {
key,
data: new Uint8Array(data),
mime: res.headers.get('content-type') || '',
size: data.byteLength,
createdAt: new Date(res.headers.get('last-modified') || Date.now()),
};
return {
key,
data: new Uint8Array(await blob.arrayBuffer()),
mime: blob.type,
size: blob.size,
createdAt: new Date(res.headers.get('last-modified') || Date.now()),
};
} catch (err) {
throw new Error('blob download error: ' + err);
}
}
override async set(blob: BlobRecord) {
await this.gql({
await this.connection.gql({
query: setBlobMutation,
variables: {
workspaceId: this.spaceId,
workspaceId: this.options.id,
blob: new File([blob.data], blob.key, { type: blob.mime }),
},
});
}
override async delete(key: string, permanently: boolean) {
await this.gql({
await this.connection.gql({
query: deleteBlobMutation,
variables: { workspaceId: this.spaceId, key, permanently },
variables: { workspaceId: this.options.id, key, permanently },
});
}
override async release() {
await this.gql({
await this.connection.gql({
query: releaseDeletedBlobsMutation,
variables: { workspaceId: this.spaceId },
variables: { workspaceId: this.options.id },
});
}
override async list() {
const res = await this.gql({
const res = await this.connection.gql({
query: listBlobsQuery,
variables: { workspaceId: this.spaceId },
variables: { workspaceId: this.options.id },
});
return res.workspace.blobs.map(blob => ({

View File

@@ -0,0 +1,82 @@
import {
type DocClock,
type DocClocks,
type DocRecord,
DocStorageBase,
type DocStorageOptions,
type DocUpdate,
} from '../../storage';
import { HttpConnection } from './http';
interface CloudDocStorageOptions extends DocStorageOptions {
serverBaseUrl: string;
}
export class StaticCloudDocStorage extends DocStorageBase<CloudDocStorageOptions> {
static readonly identifier = 'StaticCloudDocStorage';
constructor(options: CloudDocStorageOptions) {
super({ ...options, readonlyMode: true });
}
override connection = new HttpConnection(this.options.serverBaseUrl);
override async pushDocUpdate(
update: DocUpdate,
_origin?: string
): Promise<DocClock> {
// http is readonly
return { docId: update.docId, timestamp: new Date() };
}
override async getDocTimestamp(docId: string): Promise<DocClock | null> {
// http doesn't support this, so we just return a new timestamp
return {
docId,
timestamp: new Date(),
};
}
override async getDocTimestamps(): Promise<DocClocks> {
// http doesn't support this
return {};
}
override deleteDoc(_docId: string): Promise<void> {
// http is readonly
return Promise.resolve();
}
protected override async getDocSnapshot(
docId: string
): Promise<DocRecord | null> {
const arrayBuffer = await this.connection.fetchArrayBuffer(
`/api/workspaces/${this.spaceId}/docs/${docId}`,
{
priority: 'high',
headers: {
Accept: 'application/octet-stream', // this is necessary for ios native fetch to return arraybuffer
},
}
);
if (!arrayBuffer) {
return null;
}
return {
docId: docId,
bin: new Uint8Array(arrayBuffer),
timestamp: new Date(),
};
}
protected override setDocSnapshot(
_snapshot: DocRecord,
_prevSnapshot: DocRecord | null
): Promise<boolean> {
// http is readonly
return Promise.resolve(false);
}
protected override getDocUpdates(_docId: string): Promise<DocRecord[]> {
return Promise.resolve([]);
}
protected override markUpdatesMerged(
_docId: string,
_updates: DocRecord[]
): Promise<number> {
return Promise.resolve(0);
}
}

View File

@@ -12,6 +12,7 @@ import {
type DocStorageOptions,
type DocUpdate,
} from '../../storage';
import type { SpaceType } from '../../utils/universal-id';
import {
base64ToUint8Array,
type ServerEventsMap,
@@ -20,15 +21,20 @@ import {
} from './socket';
interface CloudDocStorageOptions extends DocStorageOptions {
socketOptions: SocketOptions;
socketOptions?: SocketOptions;
serverBaseUrl: string;
type: SpaceType;
}
export class CloudDocStorage extends DocStorageBase<CloudDocStorageOptions> {
static readonly identifier = 'CloudDocStorage';
get socket() {
return this.connection.inner;
}
readonly spaceType = this.options.type;
onServerUpdate: ServerEventsMap['space:broadcast-doc-update'] = message => {
if (
this.spaceType === message.spaceType &&

View File

@@ -0,0 +1,69 @@
import { gqlFetcherFactory } from '@affine/graphql';
import { DummyConnection } from '../../connection';
export class HttpConnection extends DummyConnection {
readonly fetch = async (input: string, init?: RequestInit) => {
const externalSignal = init?.signal;
if (externalSignal?.aborted) {
throw externalSignal.reason;
}
const abortController = new AbortController();
externalSignal?.addEventListener('abort', reason => {
abortController.abort(reason);
});
const timeout = 15000;
const timeoutId = setTimeout(() => {
abortController.abort('timeout');
}, timeout);
const res = await globalThis
.fetch(new URL(input, this.serverBaseUrl), {
...init,
signal: abortController.signal,
headers: {
...init?.headers,
'x-affine-version': BUILD_CONFIG.appVersion,
},
})
.catch(err => {
throw new Error('fetch error: ' + err);
});
clearTimeout(timeoutId);
if (!res.ok && res.status !== 404) {
let reason: string | any = '';
if (res.headers.get('Content-Type')?.includes('application/json')) {
try {
reason = await res.json();
} catch {
// ignore
}
}
throw new Error('fetch error status: ' + res.status + ' ' + reason);
}
return res;
};
readonly fetchArrayBuffer = async (input: string, init?: RequestInit) => {
const res = await this.fetch(input, init);
if (res.status === 404) {
// 404
return null;
}
try {
return await res.arrayBuffer();
} catch (err) {
throw new Error('fetch download error: ' + err);
}
};
readonly gql = gqlFetcherFactory(
new URL('/graphql', this.serverBaseUrl).href,
this.fetch
);
constructor(private readonly serverBaseUrl: string) {
super();
}
}

View File

@@ -1,3 +1,17 @@
import type { StorageConstructor } from '..';
import { CloudAwarenessStorage } from './awareness';
import { CloudBlobStorage } from './blob';
import { CloudDocStorage } from './doc';
import { StaticCloudDocStorage } from './doc-static';
export * from './awareness';
export * from './blob';
export * from './doc';
export * from './doc-static';
export const cloudStorages = [
CloudDocStorage,
StaticCloudDocStorage,
CloudBlobStorage,
CloudAwarenessStorage,
] satisfies StorageConstructor[];

View File

@@ -162,7 +162,7 @@ export class SocketConnection extends AutoReconnectConnection<Socket> {
constructor(
private readonly endpoint: string,
private readonly socketOptions: SocketOptions
private readonly socketOptions?: SocketOptions
) {
super();
}

View File

@@ -4,11 +4,17 @@ import {
BlobStorageBase,
type ListedBlobRecord,
} from '../../storage';
import { IDBConnection } from './db';
import { IDBConnection, type IDBConnectionOptions } from './db';
export class IndexedDBBlobStorage extends BlobStorageBase {
static readonly identifier = 'IndexedDBBlobStorage';
readonly connection = share(new IDBConnection(this.options));
constructor(private readonly options: IDBConnectionOptions) {
super();
}
get db() {
return this.connection.inner.db;
}

View File

@@ -1,20 +1,26 @@
import { type IDBPDatabase, openDB } from 'idb';
import { AutoReconnectConnection } from '../../connection';
import type { StorageOptions } from '../../storage';
import type { SpaceType } from '../../utils/universal-id';
import { type DocStorageSchema, migrator } from './schema';
export interface IDBConnectionOptions {
flavour: string;
type: SpaceType;
id: string;
}
export class IDBConnection extends AutoReconnectConnection<{
db: IDBPDatabase<DocStorageSchema>;
channel: BroadcastChannel;
}> {
readonly dbName = `${this.opts.peer}:${this.opts.type}:${this.opts.id}`;
readonly dbName = `${this.opts.flavour}:${this.opts.type}:${this.opts.id}`;
override get shareId() {
return `idb(${migrator.version}):${this.dbName}`;
}
constructor(private readonly opts: StorageOptions) {
constructor(private readonly opts: IDBConnectionOptions) {
super();
}

View File

@@ -3,10 +3,9 @@ import {
type DocClocks,
type DocRecord,
DocStorageBase,
type DocStorageOptions,
type DocUpdate,
} from '../../storage';
import { IDBConnection } from './db';
import { IDBConnection, type IDBConnectionOptions } from './db';
import { IndexedDBLocker } from './lock';
interface ChannelMessage {
@@ -15,7 +14,9 @@ interface ChannelMessage {
origin?: string;
}
export class IndexedDBDocStorage extends DocStorageBase {
export class IndexedDBDocStorage extends DocStorageBase<IDBConnectionOptions> {
static readonly identifier = 'IndexedDBDocStorage';
readonly connection = new IDBConnection(this.options);
get db() {
@@ -30,10 +31,6 @@ export class IndexedDBDocStorage extends DocStorageBase {
private _lastTimestamp = new Date(0);
constructor(options: DocStorageOptions) {
super(options);
}
private generateTimestamp() {
const timestamp = new Date();
if (timestamp.getTime() <= this._lastTimestamp.getTime()) {

View File

@@ -1,3 +1,20 @@
import type { StorageConstructor } from '..';
import { IndexedDBBlobStorage } from './blob';
import { IndexedDBDocStorage } from './doc';
import { IndexedDBSyncStorage } from './sync';
import { IndexedDBV1BlobStorage, IndexedDBV1DocStorage } from './v1';
export * from './blob';
export * from './doc';
export * from './sync';
export const idbStorages = [
IndexedDBDocStorage,
IndexedDBBlobStorage,
IndexedDBSyncStorage,
] satisfies StorageConstructor[];
export const idbv1Storages = [
IndexedDBV1DocStorage,
IndexedDBV1BlobStorage,
] satisfies StorageConstructor[];

View File

@@ -1,7 +1,14 @@
import { share } from '../../connection';
import { BasicSyncStorage, type DocClock, type DocClocks } from '../../storage';
import { IDBConnection } from './db';
export class IndexedDBSyncStorage extends BasicSyncStorage {
import { type DocClock, type DocClocks, SyncStorageBase } from '../../storage';
import { IDBConnection, type IDBConnectionOptions } from './db';
export class IndexedDBSyncStorage extends SyncStorageBase {
static readonly identifier = 'IndexedDBSyncStorage';
constructor(private readonly options: IDBConnectionOptions) {
super();
}
readonly connection = share(new IDBConnection(this.options));
get db() {

View File

@@ -1,12 +1,18 @@
import { share } from '../../../connection';
import { BlobStorageBase, type ListedBlobRecord } from '../../../storage';
import { BlobIDBConnection } from './db';
import { BlobIDBConnection, type BlobIDBConnectionOptions } from './db';
/**
* @deprecated readonly
*/
export class IndexedDBV1BlobStorage extends BlobStorageBase {
readonly connection = share(new BlobIDBConnection(this.spaceId));
static readonly identifier = 'IndexedDBV1BlobStorage';
constructor(private readonly options: BlobIDBConnectionOptions) {
super();
}
readonly connection = share(new BlobIDBConnection(this.options));
get db() {
return this.connection.inner;

View File

@@ -42,19 +42,23 @@ export interface BlobDBSchema extends DBSchema {
};
}
export interface BlobIDBConnectionOptions {
id: string;
}
export class BlobIDBConnection extends AutoReconnectConnection<
IDBPDatabase<BlobDBSchema>
> {
constructor(private readonly workspaceId: string) {
constructor(private readonly options: BlobIDBConnectionOptions) {
super();
}
override get shareId() {
return `idb(old-blob):${this.workspaceId}`;
return `idb(old-blob):${this.options.id}`;
}
override async doConnect() {
return openDB<BlobDBSchema>(`${this.workspaceId}_blob`, 1, {
return openDB<BlobDBSchema>(`${this.options.id}_blob`, 1, {
upgrade: db => {
db.createObjectStore('blob');
},

View File

@@ -10,6 +10,8 @@ import { DocIDBConnection } from './db';
* @deprecated readonly
*/
export class IndexedDBV1DocStorage extends DocStorageBase {
static readonly identifier = 'IndexedDBV1DocStorage';
readonly connection = share(new DocIDBConnection());
get db() {

View File

@@ -1,47 +1,24 @@
import type { Storage } from '../storage';
import { BroadcastChannelAwarenessStorage } from './broadcast-channel/awareness';
import {
CloudAwarenessStorage,
CloudBlobStorage,
CloudDocStorage,
} from './cloud';
import {
IndexedDBBlobStorage,
IndexedDBDocStorage,
IndexedDBSyncStorage,
} from './idb';
import { IndexedDBV1BlobStorage, IndexedDBV1DocStorage } from './idb/v1';
import type { broadcastChannelStorages } from './broadcast-channel';
import type { cloudStorages } from './cloud';
import type { idbStorages, idbv1Storages } from './idb';
import type { sqliteStorages } from './sqlite';
type StorageConstructor = new (...args: any[]) => Storage;
const idb: StorageConstructor[] = [
IndexedDBDocStorage,
IndexedDBBlobStorage,
IndexedDBSyncStorage,
BroadcastChannelAwarenessStorage,
];
const idbv1: StorageConstructor[] = [
IndexedDBV1DocStorage,
IndexedDBV1BlobStorage,
];
const cloud: StorageConstructor[] = [
CloudDocStorage,
CloudBlobStorage,
CloudAwarenessStorage,
];
export const storages: StorageConstructor[] = cloud.concat(idbv1, idb);
const AvailableStorageImplementations = storages.reduce(
(acc, curr) => {
acc[curr.name] = curr;
return acc;
},
{} as Record<string, StorageConstructor>
);
export const getAvailableStorageImplementations = (name: string) => {
return AvailableStorageImplementations[name];
export type StorageConstructor = {
new (...args: any[]): Storage;
readonly identifier: string;
};
type Storages =
| typeof cloudStorages
| typeof idbv1Storages
| typeof idbStorages
| typeof sqliteStorages
| typeof broadcastChannelStorages;
// oxlint-disable-next-line no-redeclare
export type AvailableStorageImplementations = {
[key in Storages[number]['identifier']]: Storages[number] & {
identifier: key;
};
};

View File

@@ -1,11 +1,15 @@
import { share } from '../../connection';
import { type BlobRecord, BlobStorageBase } from '../../storage';
import { NativeDBConnection } from './db';
import { NativeDBConnection, type SqliteNativeDBOptions } from './db';
export class SqliteBlobStorage extends BlobStorageBase {
override connection = share(
new NativeDBConnection(this.peer, this.spaceType, this.spaceId)
);
static readonly identifier = 'SqliteBlobStorage';
override connection = share(new NativeDBConnection(this.options));
constructor(private readonly options: SqliteNativeDBOptions) {
super();
}
get db() {
return this.connection.apis;

View File

@@ -1,9 +1,81 @@
import { apis } from '@affine/electron-api';
import { AutoReconnectConnection } from '../../connection';
import { type SpaceType, universalId } from '../../storage';
import type {
BlobRecord,
DocClock,
DocRecord,
ListedBlobRecord,
} from '../../storage';
import { type SpaceType, universalId } from '../../utils/universal-id';
type NativeDBApis = NonNullable<typeof apis>['nbstore'] extends infer APIs
export interface SqliteNativeDBOptions {
readonly flavour: string;
readonly type: SpaceType;
readonly id: string;
}
export type NativeDBApis = {
connect(id: string): Promise<void>;
disconnect(id: string): Promise<void>;
pushUpdate(id: string, docId: string, update: Uint8Array): Promise<Date>;
getDocSnapshot(id: string, docId: string): Promise<DocRecord | null>;
setDocSnapshot(id: string, snapshot: DocRecord): Promise<boolean>;
getDocUpdates(id: string, docId: string): Promise<DocRecord[]>;
markUpdatesMerged(
id: string,
docId: string,
updates: Date[]
): Promise<number>;
deleteDoc(id: string, docId: string): Promise<void>;
getDocClocks(
id: string,
after?: Date | undefined | null
): Promise<DocClock[]>;
getDocClock(id: string, docId: string): Promise<DocClock | null>;
getBlob(id: string, key: string): Promise<BlobRecord | null>;
setBlob(id: string, blob: BlobRecord): Promise<void>;
deleteBlob(id: string, key: string, permanently: boolean): Promise<void>;
releaseBlobs(id: string): Promise<void>;
listBlobs(id: string): Promise<ListedBlobRecord[]>;
getPeerRemoteClocks(id: string, peer: string): Promise<DocClock[]>;
getPeerRemoteClock(
id: string,
peer: string,
docId: string
): Promise<DocClock>;
setPeerRemoteClock(
id: string,
peer: string,
docId: string,
clock: Date
): Promise<void>;
getPeerPulledRemoteClocks(id: string, peer: string): Promise<DocClock[]>;
getPeerPulledRemoteClock(
id: string,
peer: string,
docId: string
): Promise<DocClock>;
setPeerPulledRemoteClock(
id: string,
peer: string,
docId: string,
clock: Date
): Promise<void>;
getPeerPushedClocks(id: string, peer: string): Promise<DocClock[]>;
getPeerPushedClock(
id: string,
peer: string,
docId: string
): Promise<DocClock>;
setPeerPushedClock(
id: string,
peer: string,
docId: string,
clock: Date
): Promise<void>;
clearClocks(id: string): Promise<void>;
};
type NativeDBApisWrapper = NativeDBApis extends infer APIs
? {
[K in keyof APIs]: APIs[K] extends (...args: any[]) => any
? Parameters<APIs[K]> extends [string, ...infer Rest]
@@ -13,49 +85,56 @@ type NativeDBApis = NonNullable<typeof apis>['nbstore'] extends infer APIs
}
: never;
export class NativeDBConnection extends AutoReconnectConnection<void> {
readonly apis: NativeDBApis;
let apis: NativeDBApis | null = null;
constructor(
private readonly peer: string,
private readonly type: SpaceType,
private readonly id: string
) {
export function bindNativeDBApis(a: NativeDBApis) {
apis = a;
}
export class NativeDBConnection extends AutoReconnectConnection<void> {
readonly apis: NativeDBApisWrapper;
readonly flavour = this.options.flavour;
readonly type = this.options.type;
readonly id = this.options.id;
constructor(private readonly options: SqliteNativeDBOptions) {
super();
if (!apis) {
throw new Error('Not in electron context.');
throw new Error('Not in native context.');
}
this.apis = this.bindApis(apis.nbstore);
this.apis = this.warpApis(apis);
}
override get shareId(): string {
return `sqlite:${this.peer}:${this.type}:${this.id}`;
return `sqlite:${this.flavour}:${this.type}:${this.id}`;
}
bindApis(originalApis: NonNullable<typeof apis>['nbstore']): NativeDBApis {
warpApis(originalApis: NativeDBApis): NativeDBApisWrapper {
const id = universalId({
peer: this.peer,
peer: this.flavour,
type: this.type,
id: this.id,
});
return new Proxy(originalApis, {
get: (target, key: keyof NativeDBApis) => {
const v = target[key];
if (typeof v !== 'function') {
return v;
}
return new Proxy(
{},
{
get: (_target, key: keyof NativeDBApisWrapper) => {
const v = originalApis[key];
return async (...args: any[]) => {
return v.call(
originalApis,
id,
// @ts-expect-error I don't know why it complains ts(2556)
...args
);
};
},
}) as unknown as NativeDBApis;
return async (...args: any[]) => {
return v.call(
originalApis,
id,
// @ts-expect-error I don't know why it complains ts(2556)
...args
);
};
},
}
) as unknown as NativeDBApisWrapper;
}
override async doConnect() {
@@ -63,7 +142,7 @@ export class NativeDBConnection extends AutoReconnectConnection<void> {
}
override doDisconnect() {
this.apis.close().catch(err => {
this.apis.disconnect().catch(err => {
console.error('NativeDBConnection close failed', err);
});
}

View File

@@ -1,54 +1,82 @@
import { share } from '../../connection';
import { type DocClock, DocStorageBase, type DocUpdate } from '../../storage';
import { NativeDBConnection } from './db';
import {
type DocClocks,
type DocRecord,
DocStorageBase,
type DocUpdate,
} from '../../storage';
import { NativeDBConnection, type SqliteNativeDBOptions } from './db';
export class SqliteDocStorage extends DocStorageBase {
override connection = share(
new NativeDBConnection(this.peer, this.spaceType, this.spaceId)
);
export class SqliteDocStorage extends DocStorageBase<SqliteNativeDBOptions> {
static readonly identifier = 'SqliteDocStorage';
override connection = share(new NativeDBConnection(this.options));
get db() {
return this.connection.apis;
}
override async getDoc(docId: string) {
return this.db.getDoc(docId);
}
override async pushDocUpdate(update: DocUpdate) {
return this.db.pushDocUpdate(update);
const timestamp = await this.db.pushUpdate(update.docId, update.bin);
this.emit(
'update',
{
docId: update.docId,
bin: update.bin,
timestamp,
editor: update.editor,
},
origin
);
return { docId: update.docId, timestamp };
}
override async deleteDoc(docId: string) {
return this.db.deleteDoc(docId);
await this.db.deleteDoc(docId);
}
override async getDocTimestamps(after?: Date) {
return this.db.getDocTimestamps(after ? new Date(after) : undefined);
const clocks = await this.db.getDocClocks(after);
return clocks.reduce((ret, cur) => {
ret[cur.docId] = cur.timestamp;
return ret;
}, {} as DocClocks);
}
override getDocTimestamp(docId: string): Promise<DocClock | null> {
return this.db.getDocTimestamp(docId);
override async getDocTimestamp(docId: string) {
return this.db.getDocClock(docId);
}
protected override async getDocSnapshot() {
// handled in db
// see electron/src/helper/nbstore/doc.ts
return null;
protected override async getDocSnapshot(docId: string) {
const snapshot = await this.db.getDocSnapshot(docId);
if (!snapshot) {
return null;
}
return snapshot;
}
protected override async setDocSnapshot(): Promise<boolean> {
// handled in db
return true;
protected override async setDocSnapshot(
snapshot: DocRecord
): Promise<boolean> {
return this.db.setDocSnapshot({
docId: snapshot.docId,
bin: snapshot.bin,
timestamp: snapshot.timestamp,
});
}
protected override async getDocUpdates() {
// handled in db
return [];
protected override async getDocUpdates(docId: string) {
return this.db.getDocUpdates(docId);
}
protected override markUpdatesMerged() {
// handled in db
return Promise.resolve(0);
protected override markUpdatesMerged(docId: string, updates: DocRecord[]) {
return this.db.markUpdatesMerged(
docId,
updates.map(update => update.timestamp)
);
}
}

View File

@@ -1,3 +1,16 @@
import type { StorageConstructor } from '..';
import { SqliteBlobStorage } from './blob';
import { SqliteDocStorage } from './doc';
import { SqliteSyncStorage } from './sync';
export * from './blob';
export { bindNativeDBApis, type NativeDBApis } from './db';
export * from './doc';
export * from './sync';
export * from './v1';
export const sqliteStorages = [
SqliteDocStorage,
SqliteBlobStorage,
SqliteSyncStorage,
] satisfies StorageConstructor[];

View File

@@ -1,18 +1,26 @@
import { share } from '../../connection';
import { BasicSyncStorage, type DocClock } from '../../storage';
import { NativeDBConnection } from './db';
import { type DocClock, SyncStorageBase } from '../../storage';
import { NativeDBConnection, type SqliteNativeDBOptions } from './db';
export class SqliteSyncStorage extends BasicSyncStorage {
override connection = share(
new NativeDBConnection(this.peer, this.spaceType, this.spaceId)
);
export class SqliteSyncStorage extends SyncStorageBase {
static readonly identifier = 'SqliteSyncStorage';
override connection = share(new NativeDBConnection(this.options));
constructor(private readonly options: SqliteNativeDBOptions) {
super();
}
get db() {
return this.connection.apis;
}
override async getPeerRemoteClocks(peer: string) {
return this.db.getPeerRemoteClocks(peer);
return this.db
.getPeerRemoteClocks(peer)
.then(clocks =>
Object.fromEntries(clocks.map(clock => [clock.docId, clock.timestamp]))
);
}
override async getPeerRemoteClock(peer: string, docId: string) {
@@ -20,11 +28,15 @@ export class SqliteSyncStorage extends BasicSyncStorage {
}
override async setPeerRemoteClock(peer: string, clock: DocClock) {
await this.db.setPeerRemoteClock(peer, clock);
await this.db.setPeerRemoteClock(peer, clock.docId, clock.timestamp);
}
override async getPeerPulledRemoteClocks(peer: string) {
return this.db.getPeerPulledRemoteClocks(peer);
return this.db
.getPeerPulledRemoteClocks(peer)
.then(clocks =>
Object.fromEntries(clocks.map(clock => [clock.docId, clock.timestamp]))
);
}
override async getPeerPulledRemoteClock(peer: string, docId: string) {
@@ -32,11 +44,15 @@ export class SqliteSyncStorage extends BasicSyncStorage {
}
override async setPeerPulledRemoteClock(peer: string, clock: DocClock) {
await this.db.setPeerPulledRemoteClock(peer, clock);
await this.db.setPeerPulledRemoteClock(peer, clock.docId, clock.timestamp);
}
override async getPeerPushedClocks(peer: string) {
return this.db.getPeerPushedClocks(peer);
return this.db
.getPeerPushedClocks(peer)
.then(clocks =>
Object.fromEntries(clocks.map(clock => [clock.docId, clock.timestamp]))
);
}
override async getPeerPushedClock(peer: string, docId: string) {
@@ -44,7 +60,7 @@ export class SqliteSyncStorage extends BasicSyncStorage {
}
override async setPeerPushedClock(peer: string, clock: DocClock) {
await this.db.setPeerPushedClock(peer, clock);
await this.db.setPeerPushedClock(peer, clock.docId, clock.timestamp);
}
override async clearClocks() {

View File

@@ -1,7 +1,7 @@
import { apis } from '@affine/electron-api';
import { DummyConnection } from '../../../connection';
import { BlobStorageBase } from '../../../storage';
import type { SpaceType } from '../../../utils/universal-id';
import { apis } from './db';
/**
* @deprecated readonly
@@ -9,18 +9,22 @@ import { BlobStorageBase } from '../../../storage';
export class SqliteV1BlobStorage extends BlobStorageBase {
override connection = new DummyConnection();
get db() {
constructor(private readonly options: { type: SpaceType; id: string }) {
super();
}
private get db() {
if (!apis) {
throw new Error('Not in electron context.');
}
return apis.db;
return apis;
}
override async get(key: string) {
const data: Uint8Array | null = await this.db.getBlob(
this.spaceType,
this.spaceId,
this.options.type,
this.options.id,
key
);
@@ -38,12 +42,12 @@ export class SqliteV1BlobStorage extends BlobStorageBase {
override async delete(key: string, permanently: boolean) {
if (permanently) {
await this.db.deleteBlob(this.spaceType, this.spaceId, key);
await this.db.deleteBlob(this.options.type, this.options.id, key);
}
}
override async list() {
const keys = await this.db.getBlobKeys(this.spaceType, this.spaceId);
const keys = await this.db.getBlobKeys(this.options.type, this.options.id);
return keys.map(key => ({
key,

View File

@@ -0,0 +1,26 @@
import type { SpaceType } from '../../../utils/universal-id';
interface NativeDBV1Apis {
getBlob: (
spaceType: SpaceType,
workspaceId: string,
key: string
) => Promise<Buffer | null>;
deleteBlob: (
spaceType: SpaceType,
workspaceId: string,
key: string
) => Promise<void>;
getBlobKeys: (spaceType: SpaceType, workspaceId: string) => Promise<string[]>;
getDocAsUpdates: (
spaceType: SpaceType,
workspaceId: string,
subdocId: string
) => Promise<Uint8Array>;
}
export let apis: NativeDBV1Apis | null = null;
export function bindNativeDBV1Apis(a: NativeDBV1Apis) {
apis = a;
}

View File

@@ -1,24 +1,27 @@
import { apis } from '@affine/electron-api';
import { DummyConnection } from '../../../connection';
import {
type DocRecord,
DocStorageBase,
type DocUpdate,
} from '../../../storage';
import type { SpaceType } from '../../../utils/universal-id';
import { apis } from './db';
/**
* @deprecated readonly
*/
export class SqliteV1DocStorage extends DocStorageBase {
export class SqliteV1DocStorage extends DocStorageBase<{
type: SpaceType;
id: string;
}> {
override connection = new DummyConnection();
get db() {
private get db() {
if (!apis) {
throw new Error('Not in electron context.');
}
return apis.db;
return apis;
}
override async pushDocUpdate(update: DocUpdate) {
@@ -29,8 +32,8 @@ export class SqliteV1DocStorage extends DocStorageBase {
override async getDoc(docId: string) {
const bin = await this.db.getDocAsUpdates(
this.spaceType,
this.spaceId,
this.options.type,
this.options.id,
docId
);
@@ -41,8 +44,8 @@ export class SqliteV1DocStorage extends DocStorageBase {
};
}
override async deleteDoc(docId: string) {
await this.db.deleteDoc(this.spaceType, this.spaceId, docId);
override async deleteDoc() {
return;
}
protected override async getDocSnapshot() {

View File

@@ -1,2 +1,3 @@
export * from './blob';
export { bindNativeDBV1Apis } from './db';
export * from './doc';