snapshot of https://github.com/toeverything/AFFiNE.git (canary, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Build & Test / Analyze (javascript, affine) (push) Failing after 4s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 4s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 3s
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 4s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 3s
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Release / Canary Gate (push) Has been skipped
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Copilot Test Automatically / Setup Test (push) Failing after 1s
Release / Prepare (push) Failing after 2s
Release / Release Cloud (push) Has been skipped
Release / Release Desktop (push) Has been skipped
Release / Release Docker Image (push) Has been skipped
Release / Release Mobile (push) Has been skipped

This commit is contained in:
upstream-mirror
2026-09-16 04:01:20 +05:00
commit 702cc698a7
9990 changed files with 1073618 additions and 0 deletions

View File

@@ -0,0 +1,152 @@
import { nanoid } from 'nanoid';
import { type AwarenessRecord, AwarenessStorageBase } from '../../storage';
import { BroadcastChannelConnection } from './channel';
type ChannelMessage =
| {
type: 'awareness-update';
docId: string;
bin: Uint8Array;
origin?: string;
}
| {
type: 'awareness-collect';
docId: string;
collectId: string;
}
| {
type: 'awareness-collect-feedback';
docId: string;
bin: Uint8Array;
collectId: string;
};
interface BroadcastChannelAwarenessStorageOptions {
id: string;
}
export class BroadcastChannelAwarenessStorage extends AwarenessStorageBase {
static readonly identifier = 'BroadcastChannelAwarenessStorage';
override readonly storageType = 'awareness';
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<{
onUpdate: (update: AwarenessRecord, origin?: string) => void;
onCollect: () => Promise<AwarenessRecord | null>;
}>
>();
override update(record: AwarenessRecord, origin?: string): Promise<void> {
const subscribers = this.subscriptions.get(record.docId);
if (subscribers) {
subscribers.forEach(subscriber => subscriber.onUpdate(record, origin));
}
this.channel.postMessage({
type: 'awareness-update',
docId: record.docId,
bin: record.bin,
origin,
} satisfies ChannelMessage);
return Promise.resolve();
}
override subscribeUpdate(
id: string,
onUpdate: (update: AwarenessRecord, origin?: string) => void,
onCollect: () => Promise<AwarenessRecord | null>
): () => void {
const subscribers = this.subscriptions.get(id) ?? new Set();
subscribers.forEach(subscriber => {
subscriber
.onCollect()
.then(awareness => {
if (awareness) {
onUpdate(awareness);
}
})
.catch(error => {
console.error('error in on collect awareness', error);
});
});
const collectUniqueId = nanoid();
const onChannelMessage = (message: MessageEvent<ChannelMessage>) => {
if (
message.data.type === 'awareness-update' &&
message.data.docId === id
) {
onUpdate(
{
docId: message.data.docId,
bin: message.data.bin,
},
message.data.origin
);
}
if (
message.data.type === 'awareness-collect' &&
message.data.docId === id
) {
onCollect()
.then(awareness => {
if (awareness) {
this.channel.postMessage({
type: 'awareness-collect-feedback',
docId: message.data.docId,
bin: awareness.bin,
collectId: collectUniqueId,
} satisfies ChannelMessage);
}
})
.catch(error => {
console.error('error in on collect awareness', error);
});
}
if (
message.data.type === 'awareness-collect-feedback' &&
message.data.docId === id &&
message.data.collectId === collectUniqueId
) {
onUpdate({
docId: message.data.docId,
bin: message.data.bin,
});
}
};
this.channel.addEventListener('message', onChannelMessage);
this.channel.postMessage({
type: 'awareness-collect',
docId: id,
collectId: collectUniqueId,
} satisfies ChannelMessage);
const subscriber = {
onUpdate,
onCollect,
};
subscribers.add(subscriber);
this.subscriptions.set(id, subscribers);
return () => {
subscribers.delete(subscriber);
this.channel.removeEventListener('message', onChannelMessage);
};
}
}

View File

@@ -0,0 +1,21 @@
import { AutoReconnectConnection } from '../../connection';
export interface BroadcastChannelConnectionOptions {
id: string;
}
export class BroadcastChannelConnection extends AutoReconnectConnection<BroadcastChannel> {
readonly channelName = `channel:${this.opts.id}`;
constructor(private readonly opts: BroadcastChannelConnectionOptions) {
super();
}
override async doConnect() {
return new BroadcastChannel(this.channelName);
}
override doDisconnect(channel: BroadcastChannel) {
channel.close();
}
}

View File

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

View File

@@ -0,0 +1,231 @@
import {
type AwarenessRecord,
AwarenessStorageBase,
} from '../../storage/awareness';
import type { SpaceType } from '../../utils/universal-id';
import {
base64ToUint8Array,
SocketConnection,
SPACE_JOIN_BATCH_LIMIT,
uint8ArrayToBase64,
} from './socket';
interface CloudAwarenessStorageOptions {
isSelfHosted: boolean;
serverBaseUrl: string;
type: SpaceType;
id: string;
}
export class CloudAwarenessStorage extends AwarenessStorageBase {
static readonly identifier = 'CloudAwarenessStorage';
constructor(private readonly options: CloudAwarenessStorageOptions) {
super();
}
connection = new SocketConnection(
this.options.serverBaseUrl,
this.options.isSelfHosted
);
private get socket() {
return this.connection.inner.socket;
}
private readonly activeAwarenessIds = new Set<string>();
private readonly joinedAwarenessIds = new Set<string>();
private joinPromise: Promise<void> | undefined;
private joinActiveAwareness(): Promise<void> {
if (
this.connection.status !== 'connected' ||
this.activeAwarenessIds.size === 0
) {
return Promise.resolve();
}
if (this.joinPromise) {
return this.joinPromise;
}
const batchPromise = (async () => {
while (this.connection.status === 'connected') {
await Promise.resolve();
const pendingIds = [...this.activeAwarenessIds].filter(
docId => !this.joinedAwarenessIds.has(docId)
);
if (pendingIds.length === 0) {
return;
}
for (
let index = 0;
index < pendingIds.length;
index += SPACE_JOIN_BATCH_LIMIT
) {
const spaces = pendingIds
.slice(index, index + SPACE_JOIN_BATCH_LIMIT)
.map(docId => ({
spaceType: this.options.type,
spaceId: this.options.id,
docId,
}));
const response = await this.socket.emitWithAck('space:join-batch', {
spaces,
clientVersion: BUILD_CONFIG.appVersion,
});
if ('error' in response) {
throw new Error(
`Awareness join failed: ${response.error.name}: ${response.error.message}`
);
}
if (!response.data.success) {
throw new Error('Awareness join was rejected');
}
}
for (const docId of pendingIds) {
if (this.activeAwarenessIds.has(docId)) {
this.joinedAwarenessIds.add(docId);
}
}
}
})();
const sharedPromise = batchPromise.finally(() => {
this.joinPromise = undefined;
});
this.joinPromise = sharedPromise;
return sharedPromise;
}
override async update(record: AwarenessRecord): Promise<void> {
const encodedUpdate = await uint8ArrayToBase64(record.bin);
this.socket.emit('space:update-awareness', {
spaceType: this.options.type,
spaceId: this.options.id,
docId: record.docId,
awarenessUpdate: encodedUpdate,
});
}
override subscribeUpdate(
id: string,
onUpdate: (update: AwarenessRecord, origin?: string) => void,
onCollect: () => Promise<AwarenessRecord | null>
): () => void {
this.activeAwarenessIds.add(id);
// leave awareness
const leave = () => {
this.activeAwarenessIds.delete(id);
this.joinedAwarenessIds.delete(id);
this.socket.off('space:collect-awareness', handleCollectAwareness);
this.socket.off(
'space:broadcast-awareness-update',
handleBroadcastAwarenessUpdate
);
if (this.connection.status !== 'connected') return;
this.socket.emit('space:leave-batch', {
spaceType: this.options.type,
spaceId: this.options.id,
docIds: [id],
});
};
// join awareness, and collect awareness from others
const joinAndCollect = async () => {
this.socket.on('space:collect-awareness', handleCollectAwareness);
this.socket.on(
'space:broadcast-awareness-update',
handleBroadcastAwarenessUpdate
);
await this.joinActiveAwareness();
if (this.connection.status !== 'connected') return;
this.socket.emit('space:load-awarenesses', {
spaceType: this.options.type,
spaceId: this.options.id,
docId: id,
});
};
const handleCollectAwareness = ({
spaceId,
spaceType,
docId,
}: {
spaceId: string;
spaceType: string;
docId: string;
}) => {
if (
spaceId === this.options.id &&
spaceType === this.options.type &&
docId === id
) {
(async () => {
const record = await onCollect();
if (record) {
const encodedUpdate = await uint8ArrayToBase64(record.bin);
this.socket.emit('space:update-awareness', {
spaceType: this.options.type,
spaceId: this.options.id,
docId: record.docId,
awarenessUpdate: encodedUpdate,
});
}
})().catch(err => console.error('awareness upload failed', err));
}
};
const handleBroadcastAwarenessUpdate = ({
spaceType,
spaceId,
docId,
awarenessUpdate,
}: {
spaceType: string;
spaceId: string;
docId: string;
awarenessUpdate: string;
}) => {
if (
spaceId === this.options.id &&
spaceType === this.options.type &&
docId === id
) {
onUpdate({
bin: base64ToUint8Array(awarenessUpdate),
docId: id,
});
}
};
if (this.connection.status === 'connected') {
joinAndCollect().catch(err =>
console.error('awareness join failed', err)
);
}
const unsubscribeConnectionStatusChanged = this.connection.onStatusChanged(
status => {
if (status !== 'connected') {
this.joinedAwarenessIds.clear();
}
if (status === 'connected') {
joinAndCollect().catch(err =>
console.error('awareness join failed', err)
);
}
}
);
return () => {
leave();
unsubscribeConnectionStatusChanged();
};
}
}

View File

@@ -0,0 +1,104 @@
import type { BlobSource, SourceBlobRecord } from '../../storage';
export interface BlobSourceCandidate {
context: 'owned' | 'workspace';
source: BlobSource;
token: object;
}
export class BlobSourceRegistry {
private readonly sourcesByKey = new Map<
string,
Map<string, BlobSourceCandidate>
>();
private readonly ownedEntries = new Map<
string,
{ source: BlobSource; entries: SourceBlobRecord[]; token: object }
>();
private workspaceEntries: {
entry: SourceBlobRecord;
token: object;
}[] = [];
sourceId(source: BlobSource) {
return source.type === 'currentDoc'
? `currentDoc\0${source.workspaceId}\0${source.docId}`
: `history\0${source.workspaceId}\0${source.docId}\0${source.timestampMs}`;
}
candidates(key: string) {
return [...(this.sourcesByKey.get(key)?.values() ?? [])];
}
hasCandidate(key: string, candidate: BlobSourceCandidate) {
const found =
this.sourcesByKey.get(key)?.get(this.candidateId(candidate))?.token ===
candidate.token;
return found;
}
replaceOwned(source: BlobSource, entries: SourceBlobRecord[], token: object) {
const id = this.sourceId(source);
this.ownedEntries.set(id, { source, entries, token });
this.rebuild();
}
replaceWorkspace(entries: SourceBlobRecord[], token: object) {
this.workspaceEntries = entries.map(entry => ({ entry, token }));
this.rebuild();
}
removeKeyCandidate(key: string, candidate: BlobSourceCandidate) {
const id = this.sourceId(candidate.source);
if (candidate.context === 'workspace') {
this.workspaceEntries = this.workspaceEntries.filter(
workspace =>
workspace.token !== candidate.token ||
workspace.entry.key !== key ||
this.sourceId(workspace.entry.source) !== id
);
} else {
const owned = this.ownedEntries.get(id);
if (owned?.token === candidate.token) {
owned.entries = owned.entries.filter(entry => entry.key !== key);
}
}
this.rebuild();
}
removeOwned(source: BlobSource) {
const id = this.sourceId(source);
this.ownedEntries.delete(id);
this.rebuild();
}
private rebuild() {
this.sourcesByKey.clear();
for (const workspace of this.workspaceEntries) {
this.merge(workspace.entry, 'workspace', workspace.token);
}
for (const registration of this.ownedEntries.values()) {
for (const entry of registration.entries) {
this.merge(entry, 'owned', registration.token);
}
}
}
private merge(
entry: SourceBlobRecord,
context: BlobSourceCandidate['context'],
token: object
) {
let sources = this.sourcesByKey.get(entry.key);
if (!sources) {
sources = new Map();
this.sourcesByKey.set(entry.key, sources);
}
const candidate = { context, source: entry.source, token };
sources.set(this.candidateId(candidate), candidate);
}
private candidateId(candidate: BlobSourceCandidate) {
return `${candidate.context}\0${this.sourceId(candidate.source)}`;
}
}

View File

@@ -0,0 +1,325 @@
import { UserFriendlyError } from '@affine/error';
import {
abortBlobUploadMutation,
BlobUploadMethod,
completeBlobUploadMutation,
createBlobUploadMutation,
deleteBlobMutation,
getBlobUploadPartUrlQuery,
listBlobsQuery,
releaseDeletedBlobsMutation,
setBlobMutation,
workspaceBlobQuotaQuery,
} from '@affine/graphql';
import {
type BlobRecord,
OverCapacityError,
OverSizeError,
} from '../../storage';
import type { HttpConnection } from './http';
const UPLOAD_REQUEST_TIMEOUT = 0;
function toStrictArrayBuffer(
data: ArrayBuffer | ArrayBufferLike | ArrayBufferView
): ArrayBuffer {
if (data instanceof ArrayBuffer) {
return data;
}
if (ArrayBuffer.isView(data)) {
if (data.buffer instanceof ArrayBuffer) {
if (data.byteOffset === 0 && data.byteLength === data.buffer.byteLength) {
return data.buffer;
}
return data.buffer.slice(
data.byteOffset,
data.byteOffset + data.byteLength
);
}
const bytes = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
const copy = new Uint8Array(bytes.byteLength);
copy.set(bytes);
return copy.buffer;
}
const bytes = new Uint8Array(data);
const copy = new Uint8Array(bytes.byteLength);
copy.set(bytes);
return copy.buffer;
}
export class CloudBlobWriter {
private humanReadableBlobSizeLimit: string | null = null;
private blobSizeLimit: number | null = null;
private blobSizeLimitTime = 0;
constructor(
private readonly connection: HttpConnection,
private readonly workspaceId: string,
private readonly serverBaseUrl: string
) {}
async set(blob: BlobRecord, signal?: AbortSignal) {
try {
signal?.throwIfAborted();
const blobSizeLimit = await this.getBlobSizeLimit(signal);
if (blob.data.byteLength > blobSizeLimit) {
throw new OverSizeError(this.humanReadableBlobSizeLimit);
}
const init = await this.connection.gql({
query: createBlobUploadMutation,
variables: {
workspaceId: this.workspaceId,
key: blob.key,
size: blob.data.byteLength,
mime: blob.mime,
},
context: { signal },
});
const upload = init.createBlobUpload;
if (upload.alreadyUploaded) {
return;
}
try {
if (upload.method === BlobUploadMethod.PRESIGNED) {
if (!upload.uploadUrl) {
throw new Error('Missing upload URL for presigned upload.');
}
await this.uploadViaPresigned(
upload.uploadUrl,
upload.headers,
blob.data,
signal
);
await this.completeUpload(blob.key, undefined, undefined, signal);
return;
}
if (upload.method === BlobUploadMethod.MULTIPART) {
if (!upload.uploadId || !upload.partSize) {
throw new Error(
'Missing upload ID or part size for multipart upload.'
);
}
const parts = await this.uploadViaMultipart(
blob.key,
upload.uploadId,
upload.partSize,
blob.data,
upload.uploadedParts,
signal
);
await this.completeUpload(blob.key, upload.uploadId, parts, signal);
return;
}
} catch (error) {
if (upload.method === BlobUploadMethod.MULTIPART && upload.uploadId) {
await this.tryAbortMultipartUpload(blob.key, upload.uploadId);
}
signal?.throwIfAborted();
if (UserFriendlyError.fromAny(error).is('CONTENT_TOO_LARGE')) {
throw error;
}
}
await this.uploadViaGraphql(blob, signal);
} catch (err) {
const userFriendlyError = UserFriendlyError.fromAny(err);
if (userFriendlyError.is('STORAGE_QUOTA_EXCEEDED')) {
throw new OverCapacityError();
}
if (userFriendlyError.is('BLOB_QUOTA_EXCEEDED')) {
throw new OverSizeError(this.humanReadableBlobSizeLimit);
}
if (userFriendlyError.is('CONTENT_TOO_LARGE')) {
throw new OverSizeError(
null,
'Upload stopped by network proxy: file size exceeds the set limit.'
);
}
throw err;
}
}
async delete(key: string, permanently: boolean) {
await this.connection.gql({
query: deleteBlobMutation,
variables: { workspaceId: this.workspaceId, key, permanently },
});
}
async release() {
await this.connection.gql({
query: releaseDeletedBlobsMutation,
variables: { workspaceId: this.workspaceId },
});
}
async listManageable() {
const result = await this.connection.gql({
query: listBlobsQuery,
variables: { workspaceId: this.workspaceId },
});
return result.workspace.blobs.map(blob => ({
...blob,
createdAt: blob.createdAt ? new Date(blob.createdAt) : undefined,
}));
}
private async uploadViaGraphql(blob: BlobRecord, signal?: AbortSignal) {
await this.connection.gql({
query: setBlobMutation,
variables: {
workspaceId: this.workspaceId,
blob: new File([toStrictArrayBuffer(blob.data)], blob.key, {
type: blob.mime,
}),
},
context: { signal },
timeout: UPLOAD_REQUEST_TIMEOUT,
});
}
private async uploadViaPresigned(
uploadUrl: string,
headers: Record<string, string> | null | undefined,
data: Uint8Array,
signal?: AbortSignal
) {
const res = await this.fetchUpload(uploadUrl, {
method: 'PUT',
headers: headers ?? undefined,
body: toStrictArrayBuffer(data),
signal,
});
if (!res.ok) {
throw new Error(`Presigned upload failed with status ${res.status}`);
}
}
private async uploadViaMultipart(
key: string,
uploadId: string,
partSize: number,
data: Uint8Array,
uploadedParts: { partNumber: number; etag: string }[] | null | undefined,
signal?: AbortSignal
) {
const partsMap = new Map<number, string>();
for (const part of uploadedParts ?? []) {
partsMap.set(part.partNumber, part.etag);
}
const totalParts = Math.ceil(data.byteLength / partSize);
for (let partNumber = 1; partNumber <= totalParts; partNumber += 1) {
if (partsMap.has(partNumber)) {
continue;
}
const start = (partNumber - 1) * partSize;
const chunk = data.subarray(
start,
Math.min(start + partSize, data.byteLength)
);
const part = await this.connection.gql({
query: getBlobUploadPartUrlQuery,
variables: {
workspaceId: this.workspaceId,
key,
uploadId,
partNumber,
},
context: { signal },
});
const res = await this.fetchUpload(
part.workspace.blobUploadPartUrl.uploadUrl,
{
method: 'PUT',
headers: part.workspace.blobUploadPartUrl.headers ?? undefined,
body: toStrictArrayBuffer(chunk),
signal,
}
);
if (!res.ok) {
throw new Error(
`Multipart upload failed at part ${partNumber} with status ${res.status}`
);
}
const etag = res.headers.get('etag');
if (!etag) {
throw new Error(`Missing ETag for part ${partNumber}.`);
}
partsMap.set(partNumber, etag);
}
if (partsMap.size !== totalParts) {
throw new Error('Multipart upload has missing parts.');
}
return [...partsMap.entries()]
.sort((left, right) => left[0] - right[0])
.map(([partNumber, etag]) => ({ partNumber, etag }));
}
private async completeUpload(
key: string,
uploadId: string | undefined,
parts: { partNumber: number; etag: string }[] | undefined,
signal?: AbortSignal
) {
await this.connection.gql({
query: completeBlobUploadMutation,
variables: { workspaceId: this.workspaceId, key, uploadId, parts },
context: { signal },
timeout: UPLOAD_REQUEST_TIMEOUT,
});
}
private async tryAbortMultipartUpload(key: string, uploadId: string) {
try {
await this.connection.gql({
query: abortBlobUploadMutation,
variables: { workspaceId: this.workspaceId, key, uploadId },
});
} catch {}
}
private async fetchUpload(input: string, init: RequestInit) {
const res = await globalThis.fetch(
new URL(input, this.serverBaseUrl).toString(),
init
);
if (res.status === 413) {
throw new UserFriendlyError({
status: 413,
code: 'CONTENT_TOO_LARGE',
type: 'CONTENT_TOO_LARGE',
name: 'CONTENT_TOO_LARGE',
message: 'Content too large',
});
}
return res;
}
private async getBlobSizeLimit(signal?: AbortSignal) {
if (
this.blobSizeLimit !== null &&
Date.now() - this.blobSizeLimitTime < 120 * 1000
) {
return this.blobSizeLimit;
}
const res = await this.connection.gql({
query: workspaceBlobQuotaQuery,
variables: { id: this.workspaceId },
context: { signal },
});
this.humanReadableBlobSizeLimit =
res.workspace.quota.humanReadable.blobLimit;
this.blobSizeLimit = res.workspace.quota.blobLimit;
this.blobSizeLimitTime = Date.now();
return this.blobSizeLimit;
}
}

View File

@@ -0,0 +1,358 @@
import { UserFriendlyError } from '@affine/error';
import {
type BlobRecord,
type BlobSource,
BlobStorageBase,
type SourceBlobRecord,
} from '../../storage';
import {
type BlobSourceCandidate,
BlobSourceRegistry,
} from './blob-source-registry';
import { CloudBlobWriter } from './blob-writer';
import { HttpConnection } from './http';
interface CloudBlobStorageOptions {
serverBaseUrl: string;
id: string;
}
type SourceRegistration = {
controller: AbortController;
dirty: boolean;
promise?: Promise<void>;
source: BlobSource;
};
const MAX_PENDING_SOURCE_REGISTRATIONS = 32;
export function sourceScopedBlobUrl(
workspaceId: string,
key: string,
source: BlobSource
) {
const query = new URLSearchParams({
sourceType: source.type,
docId: source.docId,
});
if (source.type === 'history') {
query.set('timestampMs', String(source.timestampMs));
}
return `/api/workspaces/${encodeURIComponent(workspaceId)}/blobs/v1/${encodeURIComponent(key)}?${query}`;
}
export class CloudBlobStorage extends BlobStorageBase {
static readonly identifier = 'CloudBlobStorage';
override readonly isReadonly = false;
constructor(private readonly options: CloudBlobStorageOptions) {
super();
}
readonly connection = new HttpConnection(this.options.serverBaseUrl);
private readonly writer = new CloudBlobWriter(
this.connection,
this.options.id,
this.options.serverBaseUrl
);
private readonly sources = new BlobSourceRegistry();
private readonly sourceRegistrations = new Map<string, SourceRegistration>();
private readonly pendingSourceRegistrations = new Set<SourceRegistration>();
private sourceId(source: BlobSource) {
return this.sources.sourceId(source);
}
private sourceQuery(source: BlobSource) {
const query = new URLSearchParams({
sourceType: source.type,
docId: source.docId,
});
if (source.type === 'history') {
query.set('timestampMs', String(source.timestampMs));
}
return query;
}
private removeKeyCandidate(key: string, candidate: BlobSourceCandidate) {
this.sources.removeKeyCandidate(key, candidate);
}
private removeSource(source: BlobSource) {
this.sources.removeOwned(source);
}
private replaceSource(
source: BlobSource,
entries: SourceBlobRecord[],
registration: SourceRegistration
) {
this.sources.replaceOwned(source, entries, registration);
}
private isRegistrationCurrent(registration: SourceRegistration) {
return (
this.sourceRegistrations.get(this.sourceId(registration.source)) ===
registration
);
}
private isCandidateCurrent(key: string, candidate: BlobSourceCandidate) {
return this.sources.hasCandidate(key, candidate);
}
async registerSource(
source: BlobSource,
signal?: AbortSignal
): Promise<void> {
const id = this.sourceId(source);
const existing = this.sourceRegistrations.get(id);
if (existing) {
return this.refreshSource(existing, signal);
}
if (
this.pendingSourceRegistrations.size >= MAX_PENDING_SOURCE_REGISTRATIONS
) {
throw new Error('Blob source registration budget exceeded');
}
const registration = {
controller: new AbortController(),
dirty: false,
source,
} satisfies SourceRegistration;
this.sourceRegistrations.set(id, registration);
return this.refreshSource(registration, signal);
}
private refreshSource(
registration: SourceRegistration,
signal?: AbortSignal
): Promise<void> {
if (registration.promise) {
registration.dirty = true;
return registration.promise;
}
if (
this.pendingSourceRegistrations.size >= MAX_PENDING_SOURCE_REGISTRATIONS
) {
throw new Error('Blob source registration budget exceeded');
}
const registrationSignal = signal
? AbortSignal.any([signal, registration.controller.signal])
: registration.controller.signal;
registration.promise = this.loadSource(
registration.source,
registration,
registrationSignal
).finally(() => {
this.pendingSourceRegistrations.delete(registration);
registration.promise = undefined;
});
this.pendingSourceRegistrations.add(registration);
return registration.promise;
}
async unregisterSource(source: BlobSource) {
const id = this.sourceId(source);
const registration = this.sourceRegistrations.get(id);
if (registration) {
this.sourceRegistrations.delete(id);
registration.controller.abort();
}
this.removeSource(source);
await registration?.promise?.catch(() => {});
}
private async loadSource(
source: BlobSource,
registration: SourceRegistration,
signal?: AbortSignal
) {
do {
registration.dirty = false;
try {
const res = await this.connection.fetch(
`/api/workspaces/${encodeURIComponent(this.options.id)}/blob-manifest/v1?${this.sourceQuery(source)}`,
{ cache: 'no-store', signal }
);
if (!res.ok) {
throw new Error(
`Blob source manifest failed with status ${res.status}`
);
}
const manifest = (await res.json()) as {
version: 1;
entries: SourceBlobRecord[];
};
if (this.isRegistrationCurrent(registration)) {
this.replaceSource(source, manifest.entries, registration);
}
} catch (error) {
if (this.isRegistrationCurrent(registration)) {
this.removeSource(source);
}
throw error;
}
} while (registration.dirty && this.isRegistrationCurrent(registration));
}
async *readableSources(signal?: AbortSignal) {
let cursor: string | undefined;
try {
do {
const query = new URLSearchParams({ limit: '100' });
if (cursor) query.set('cursor', cursor);
const res = await this.connection.fetch(
`/api/workspaces/${encodeURIComponent(this.options.id)}/readable-blob-manifest/v1?${query}`,
{ cache: 'no-store', signal }
);
if (!res.ok) {
throw new Error(
`Readable blob manifest failed with status ${res.status}`
);
}
const manifest = (await res.json()) as {
version: 1;
entries: SourceBlobRecord[];
nextCursor?: string;
};
for (const entry of manifest.entries) {
this.sources.replaceWorkspace([entry], {});
yield entry;
}
cursor = manifest.nextCursor;
} while (cursor);
} finally {
this.sources.replaceWorkspace([], {});
}
}
override async get(key: string, signal?: AbortSignal, source?: BlobSource) {
signal?.throwIfAborted();
let candidates = this.sourceCandidates(key, source);
if (candidates.length === 0) {
const failed = await this.refreshRegisteredSources(source, signal);
signal?.throwIfAborted();
candidates = this.sourceCandidates(key, source);
if (candidates.length === 0) {
if (failed) throw failed.reason;
return null;
}
}
const attempt = async (candidates: BlobSourceCandidate[]) => {
for (const candidate of candidates) {
if (!this.isCandidateCurrent(key, candidate)) {
continue;
}
let res: Response;
try {
res = await this.connection.fetch(
sourceScopedBlobUrl(this.options.id, key, candidate.source),
{ cache: 'no-store', signal }
);
} catch (error) {
if (
error instanceof UserFriendlyError &&
(error.status === 403 || error.status === 404)
) {
if (this.isCandidateCurrent(key, candidate)) {
this.removeKeyCandidate(key, candidate);
}
continue;
}
throw error;
}
if (!this.isCandidateCurrent(key, candidate)) {
continue;
}
if (res.status === 403 || res.status === 404) {
if (this.isCandidateCurrent(key, candidate)) {
this.removeKeyCandidate(key, candidate);
}
continue;
}
if (!res.ok) {
throw new Error(`Blob download failed with status ${res.status}`);
}
const blob = await res.blob();
const data = new Uint8Array(await blob.arrayBuffer());
if (!this.isCandidateCurrent(key, candidate)) {
continue;
}
return {
key,
data,
mime: blob.type,
size: blob.size,
createdAt: new Date(res.headers.get('last-modified') || Date.now()),
};
}
return null;
};
return attempt(candidates);
}
private sourceCandidates(key: string, source?: BlobSource) {
return this.sources
.candidates(key)
.filter(
candidate =>
!source || this.sourceId(candidate.source) === this.sourceId(source)
);
}
private async refreshRegisteredSources(
source?: BlobSource,
signal?: AbortSignal
) {
const registrations = source
? [this.sourceRegistrations.get(this.sourceId(source))].filter(
(registration): registration is SourceRegistration => !!registration
)
: [...this.sourceRegistrations.values()];
if (registrations.length === 0) {
throw new Error('Blob source context is required');
}
let failed: PromiseRejectedResult | undefined;
for (
let offset = 0;
offset < registrations.length;
offset += MAX_PENDING_SOURCE_REGISTRATIONS
) {
const results = await Promise.allSettled(
registrations
.slice(offset, offset + MAX_PENDING_SOURCE_REGISTRATIONS)
.map(
async registration =>
registration.promise ?? this.refreshSource(registration, signal)
)
);
failed ??= results.find(result => result.status === 'rejected');
}
return failed;
}
override set(blob: BlobRecord, signal?: AbortSignal) {
return this.writer.set(blob, signal);
}
override delete(key: string, permanently: boolean) {
return this.writer.delete(key, permanently);
}
override release() {
return this.writer.release();
}
override async list(): Promise<never> {
throw new Error('Workspace blob inventory is unavailable');
}
async listManageable() {
return this.writer.listManageable();
}
}

View File

@@ -0,0 +1,102 @@
import {
type DocClock,
type DocClocks,
type DocRecord,
DocStorageBase,
type DocStorageOptions,
type DocUpdate,
} from '../../storage';
import { HttpConnection } from './http';
interface CloudDocStorageOptions extends DocStorageOptions {
serverBaseUrl: string;
publicRootDocId?: string;
}
const isShareModePrivateSystemDoc = (docId: string) =>
docId.startsWith('db$') || docId.startsWith('userdata$');
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> {
try {
if (
this.options.publicRootDocId &&
docId !== this.spaceId &&
isShareModePrivateSystemDoc(docId)
) {
return null;
}
const path =
docId === this.spaceId && this.options.publicRootDocId
? `/api/workspaces/${this.spaceId}/public-docs/${this.options.publicRootDocId}/root-doc`
: this.options.publicRootDocId
? `/api/workspaces/${this.spaceId}/public-docs/${docId}`
: `/api/workspaces/${this.spaceId}/docs/${docId}`;
const arrayBuffer = await this.connection.fetchArrayBuffer(path, {
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(),
};
} catch (error) {
console.error(error);
return null;
}
}
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

@@ -0,0 +1,388 @@
import type { Socket } from 'socket.io-client';
import {
type DocClock,
type DocClocks,
DocStorageBase,
type DocStorageOptions,
type DocUpdate,
} from '../../storage';
import { getIdConverter, type IdConverter } from '../../utils/id-converter';
import type { SpaceType } from '../../utils/universal-id';
import {
base64ToUint8Array,
type ServerEventsMap,
SocketConnection,
SPACE_JOIN_BATCH_LIMIT,
uint8ArrayToBase64,
} from './socket';
interface CloudDocStorageOptions extends DocStorageOptions {
serverBaseUrl: string;
isSelfHosted: boolean;
type: SpaceType;
}
function createWebsocketError(error: { name: string; message: string }) {
const err = new Error(error.message);
err.name = error.name;
return err;
}
export class CloudDocStorage extends DocStorageBase<CloudDocStorageOptions> {
static readonly identifier = 'CloudDocStorage';
get socket() {
return this.connection.inner.socket;
}
get idConverter() {
if (!this.connection.idConverter) {
throw new Error('Id converter not initialized');
}
return this.connection.idConverter;
}
readonly spaceType = this.options.type;
onServerUpdates: ServerEventsMap['space:broadcast-doc-updates'] = message => {
if (
this.spaceType !== message.spaceType ||
this.spaceId !== message.spaceId
) {
return;
}
const docId = this.idConverter.oldIdToNewId(message.docId);
this.serverDocTimestamps.set(docId, message.timestamp);
for (const update of message.updates) {
this.emit('update', {
docId,
bin: base64ToUint8Array(update),
timestamp: new Date(message.timestamp),
editor: message.editor,
});
}
};
onServerInvalidation: ServerEventsMap['space:broadcast-doc-invalidation'] =
message => {
if (
this.spaceType !== message.spaceType ||
this.spaceId !== message.spaceId
) {
return;
}
this.invalidationPending = true;
this.scheduleInvalidationRepair();
};
private readonly serverDocTimestamps = new Map<string, number>();
private invalidationTimer?: ReturnType<typeof setTimeout>;
private invalidationRepair?: Promise<void>;
private invalidationPending = false;
private scheduleInvalidationRepair() {
if (this.invalidationTimer) {
clearTimeout(this.invalidationTimer);
}
this.invalidationTimer = setTimeout(() => {
this.invalidationTimer = undefined;
if (this.invalidationRepair) {
return;
}
this.invalidationPending = false;
this.invalidationRepair = this.repairInvalidatedDocs()
.catch(error => {
console.error('failed to repair invalidated docs', error);
})
.finally(() => {
this.invalidationRepair = undefined;
if (this.invalidationPending) {
this.scheduleInvalidationRepair();
}
});
}, 50);
}
private async repairInvalidatedDocs() {
const timestamps = await this.getDocTimestamps();
for (const [docId, timestamp] of Object.entries(timestamps)) {
const newDocId = this.idConverter.oldIdToNewId(docId);
const timestampValue = timestamp.getTime();
const previous = this.serverDocTimestamps.get(newDocId);
if (previous !== undefined && previous >= timestampValue) {
continue;
}
this.serverDocTimestamps.set(newDocId, timestampValue);
this.emit('update', {
docId: newDocId,
bin: new Uint8Array(),
timestamp,
});
}
}
readonly connection = new CloudDocStorageConnection(
this.options,
this.onServerUpdates,
this.onServerInvalidation
);
override async getDocSnapshot(docId: string) {
const response = await this.socket.emitWithAck('space:load-doc', {
spaceType: this.spaceType,
spaceId: this.spaceId,
docId: await this.connection.joinDoc(
this.idConverter.newIdToOldId(docId)
),
});
if ('error' in response) {
if (response.error.name === 'DOC_NOT_FOUND') {
return null;
}
// TODO: use [UserFriendlyError]
throw createWebsocketError(response.error);
}
return {
docId,
bin: base64ToUint8Array(response.data.missing),
timestamp: new Date(response.data.timestamp),
};
}
override async getDocDiff(docId: string, state?: Uint8Array) {
const response = await this.socket.emitWithAck('space:load-doc', {
spaceType: this.spaceType,
spaceId: this.spaceId,
docId: await this.connection.joinDoc(
this.idConverter.newIdToOldId(docId)
),
stateVector: state ? await uint8ArrayToBase64(state) : void 0,
});
if ('error' in response) {
if (response.error.name === 'DOC_NOT_FOUND') {
return null;
}
// TODO: use [UserFriendlyError]
throw createWebsocketError(response.error);
}
return {
docId,
missing: base64ToUint8Array(response.data.missing),
state: base64ToUint8Array(response.data.state),
timestamp: new Date(response.data.timestamp),
};
}
override async pushDocUpdate(update: DocUpdate) {
const response = await this.socket.emitWithAck('space:push-doc-update', {
spaceType: this.spaceType,
spaceId: this.spaceId,
docId: await this.connection.joinDoc(
this.idConverter.newIdToOldId(update.docId)
),
update: await uint8ArrayToBase64(update.bin),
});
if ('error' in response) {
// TODO(@forehalo): use [UserFriendlyError]
throw createWebsocketError(response.error);
}
return {
docId: update.docId,
timestamp: new Date(response.data.timestamp),
};
}
/**
* Just a rough implementation, cloud doc storage should not need this method.
*/
override async getDocTimestamp(docId: string): Promise<DocClock | null> {
const response = await this.socket.emitWithAck('space:load-doc', {
spaceType: this.spaceType,
spaceId: this.spaceId,
docId: await this.connection.joinDoc(
this.idConverter.newIdToOldId(docId)
),
});
if ('error' in response) {
// TODO: use [UserFriendlyError]
throw createWebsocketError(response.error);
}
return {
docId,
timestamp: new Date(response.data.timestamp),
};
}
override async getDocTimestamps(after?: Date) {
const response = await this.socket.emitWithAck(
'space:load-doc-timestamps',
{
spaceType: this.spaceType,
spaceId: this.spaceId,
timestamp: after ? after.getTime() : undefined,
}
);
if ('error' in response) {
// TODO(@forehalo): use [UserFriendlyError]
throw createWebsocketError(response.error);
}
return Object.entries(response.data).reduce((ret, [docId, timestamp]) => {
ret[this.idConverter.oldIdToNewId(docId)] = new Date(timestamp);
return ret;
}, {} as DocClocks);
}
override async deleteDoc(docId: string) {
const response = await this.socket.emitWithAck('space:delete-doc', {
spaceType: this.spaceType,
spaceId: this.spaceId,
docId: this.idConverter.newIdToOldId(docId),
});
if ('error' in response) {
// TODO(@forehalo): use [UserFriendlyError]
throw createWebsocketError(response.error);
}
}
async applyDocLifecycle(
docId: string,
lifecycle: 'trash' | 'restore' | 'delete'
) {
const response = await this.socket.emitWithAck('space:doc-lifecycle', {
spaceType: this.spaceType,
spaceId: this.spaceId,
docId: this.idConverter.newIdToOldId(docId),
lifecycle,
});
if ('error' in response) {
throw createWebsocketError(response.error);
}
return {
rootUpdate: base64ToUint8Array(response.data.rootUpdate),
timestamp: new Date(response.data.timestamp),
};
}
protected async setDocSnapshot() {
return false;
}
protected async getDocUpdates() {
return [];
}
protected async markUpdatesMerged() {
return 0;
}
}
class CloudDocStorageConnection extends SocketConnection {
constructor(
private readonly options: CloudDocStorageOptions,
private readonly onServerUpdates: ServerEventsMap['space:broadcast-doc-updates'],
private readonly onServerInvalidation: ServerEventsMap['space:broadcast-doc-invalidation']
) {
super(options.serverBaseUrl, options.isSelfHosted);
}
idConverter: IdConverter | null = null;
private readonly joinedDocIds = new Set<string>();
async joinDoc(docId: string, socket: Socket = this.inner.socket) {
const res = await socket.emitWithAck('space:join-batch', {
spaces: [
{ spaceType: this.options.type, spaceId: this.options.id, docId },
],
clientVersion: BUILD_CONFIG.appVersion,
});
if ('error' in res) throw createWebsocketError(res.error);
if (!res.data.success) throw new Error('Space join was rejected');
this.joinedDocIds.add(docId);
return docId;
}
override async doConnect(signal?: AbortSignal) {
const { socket, disconnect } = await super.doConnect(signal);
try {
await this.joinDoc(this.options.id, socket);
if (!this.idConverter) {
this.idConverter = await this.getIdConverter(socket);
}
socket.on('space:broadcast-doc-updates', this.onServerUpdates);
socket.on('space:broadcast-doc-invalidation', this.onServerInvalidation);
return { socket, disconnect };
} catch (e) {
this.doDisconnect({ socket, disconnect });
throw e;
}
}
override doDisconnect({
socket,
disconnect,
}: {
socket: Socket;
disconnect: () => void;
}) {
const docIds = [...this.joinedDocIds];
this.joinedDocIds.clear();
for (
let offset = 0;
offset < docIds.length;
offset += SPACE_JOIN_BATCH_LIMIT
) {
socket.emit('space:leave-batch', {
spaceType: this.options.type,
spaceId: this.options.id,
docIds: docIds.slice(offset, offset + SPACE_JOIN_BATCH_LIMIT),
});
}
socket.emit('space:leave-batch', {
spaceType: this.options.type,
spaceId: this.options.id,
docIds: [],
});
socket.off('space:broadcast-doc-updates', this.onServerUpdates);
socket.off('space:broadcast-doc-invalidation', this.onServerInvalidation);
super.doDisconnect({ socket, disconnect });
}
async getIdConverter(socket: Socket) {
return getIdConverter(
{
getDocBuffer: async id => {
const response = await socket.emitWithAck('space:load-doc', {
spaceType: this.options.type,
spaceId: this.options.id,
docId: id,
});
if ('error' in response) {
if (response.error.name === 'DOC_NOT_FOUND') {
return null;
}
// TODO: use [UserFriendlyError]
throw createWebsocketError(response.error);
}
return base64ToUint8Array(response.data.missing);
},
},
this.options.id
);
}
}

View File

@@ -0,0 +1,98 @@
import { UserFriendlyError } from '@affine/error';
import { gqlFetcherFactory } from '@affine/graphql';
import { DummyConnection } from '../../connection';
export class HttpConnection extends DummyConnection {
readonly fetch = async (
input: string,
init?: RequestInit & { timeout?: number }
) => {
const externalSignal = init?.signal;
if (externalSignal?.aborted) {
throw externalSignal.reason;
}
const abortController = new AbortController();
const signal = externalSignal
? AbortSignal.any([externalSignal, abortController.signal])
: abortController.signal;
const timeout = init?.timeout ?? 15000;
const timeoutId =
timeout > 0
? setTimeout(() => {
abortController.abort(new Error('request timeout'));
}, timeout)
: undefined;
try {
const res = await globalThis
.fetch(new URL(input, this.serverBaseUrl), {
...init,
signal,
headers: {
...this.requestHeaders,
...init?.headers,
'x-affine-version': BUILD_CONFIG.appVersion,
},
})
.catch(err => {
externalSignal?.throwIfAborted();
throw new UserFriendlyError({
status: 504,
code: 'NETWORK_ERROR',
type: 'NETWORK_ERROR',
name: 'NETWORK_ERROR',
message: `Network error: ${err.message}`,
stacktrace: err.stack,
});
});
if (!res.ok && res.status !== 404) {
if (res.status === 413) {
throw new UserFriendlyError({
status: 413,
code: 'CONTENT_TOO_LARGE',
type: 'CONTENT_TOO_LARGE',
name: 'CONTENT_TOO_LARGE',
message: 'Content too large',
});
} else if (
res.headers.get('Content-Type')?.startsWith('application/json')
) {
throw UserFriendlyError.fromAny(await res.json());
} else {
throw UserFriendlyError.fromAny(await res.text());
}
}
return res;
} finally {
if (timeoutId) clearTimeout(timeoutId);
}
};
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,
private readonly requestHeaders?: Record<string, string>
) {
super();
}
}

View File

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

View File

@@ -0,0 +1,150 @@
import {
type AggregateInput,
indexerAggregateQuery,
indexerSearchQuery,
type SearchInput,
} from '@affine/graphql';
import { Observable } from 'rxjs';
import {
type AggregateOptions,
type AggregateResult,
type IndexerDocument,
type IndexerSchema,
IndexerStorageBase,
type Query,
type SearchOptions,
type SearchResult,
} from '../../storage/indexer';
import { HttpConnection } from './http';
interface CloudIndexerStorageOptions {
serverBaseUrl: string;
id: string;
}
export class CloudIndexerStorage extends IndexerStorageBase {
static readonly identifier = 'CloudIndexerStorage';
readonly isReadonly = true;
readonly connection = new HttpConnection(this.options.serverBaseUrl);
constructor(private readonly options: CloudIndexerStorageOptions) {
super();
}
override async search<
T extends keyof IndexerSchema,
const O extends SearchOptions<T>,
>(table: T, query: Query<T>, options?: O): Promise<SearchResult<T, O>> {
const res = await this.connection.gql({
query: indexerSearchQuery,
variables: {
id: this.options.id,
input: {
table,
query,
options,
} as SearchInput,
},
});
const result = res.workspace.search as unknown as SearchResult<T, O>;
return result;
}
override search$<
T extends keyof IndexerSchema,
const O extends SearchOptions<T>,
>(table: T, query: Query<T>, options?: O): Observable<SearchResult<T, O>> {
return new Observable(observer => {
this.search(table, query, options)
.then(data => {
observer.next(data);
observer.complete();
})
.catch(error => {
observer.error(error);
});
});
}
override async aggregate<
T extends keyof IndexerSchema,
const O extends AggregateOptions<T>,
>(
table: T,
query: Query<T>,
field: keyof IndexerSchema[T],
options?: O
): Promise<AggregateResult<T, O>> {
const res = await this.connection.gql({
query: indexerAggregateQuery,
variables: {
id: this.options.id,
input: { table, query, field, options } as AggregateInput,
},
});
const result = res.workspace.aggregate as unknown as AggregateResult<T, O>;
return result;
}
override aggregate$<
T extends keyof IndexerSchema,
const O extends AggregateOptions<T>,
>(
table: T,
query: Query<T>,
field: keyof IndexerSchema[T],
options?: O
): Observable<AggregateResult<T, O>> {
return new Observable(observer => {
this.aggregate(table, query, field, options)
.then(data => {
observer.next(data);
observer.complete();
})
.catch(error => {
observer.error(error);
});
});
}
override deleteByQuery<T extends keyof IndexerSchema>(
_table: T,
_query: Query<T>
): Promise<void> {
return Promise.resolve();
}
override insert<T extends keyof IndexerSchema>(
_table: T,
_document: IndexerDocument<T>
): Promise<void> {
return Promise.resolve();
}
override delete<T extends keyof IndexerSchema>(
_table: T,
_id: string
): Promise<void> {
return Promise.resolve();
}
override update<T extends keyof IndexerSchema>(
_table: T,
_document: IndexerDocument<T>
): Promise<void> {
return Promise.resolve();
}
override refresh<T extends keyof IndexerSchema>(_table: T): Promise<void> {
return Promise.resolve();
}
override async refreshIfNeed(): Promise<void> {
return;
}
override async indexVersion(): Promise<number> {
return 1;
}
}

View File

@@ -0,0 +1,329 @@
import {
type RealtimeEvent,
type RealtimeRequestEnvelope,
type RealtimeSubscribeEnvelope,
type RealtimeUnsubscribeEnvelope,
} from '@affine/realtime';
import {
Manager as SocketIOManager,
type Socket as SocketIO,
} from 'socket.io-client';
import { AutoReconnectConnection } from '../../connection';
import type { TelemetryAck, TelemetryBatch } from '../../telemetry/types';
import { throwIfAborted } from '../../utils/throw-if-aborted';
// TODO(@forehalo): use [UserFriendlyError]
interface EventError {
name: string;
message: string;
}
type WebsocketResponse<T> =
| {
error: EventError;
}
| {
data: T;
};
interface ServerEvents {
'space:broadcast-doc-updates': {
spaceType: string;
spaceId: string;
docId: string;
updates: string[];
timestamp: number;
editor?: string;
compressed?: boolean;
};
'space:broadcast-doc-invalidation': {
spaceType: string;
spaceId: string;
timestamp: number;
};
'space:collect-awareness': {
spaceType: string;
spaceId: string;
docId: string;
};
'space:broadcast-awareness-update': {
spaceType: string;
spaceId: string;
docId: string;
awarenessUpdate: string;
};
'realtime:event': RealtimeEvent;
}
interface ClientEvents {
'space:join-batch': [
{
spaces: Array<{
spaceType: string;
spaceId: string;
docId?: string;
}>;
clientVersion: string;
},
{ clientId: string; success: boolean },
];
'space:leave-batch': {
spaceType: string;
spaceId: string;
docIds: string[];
};
'space:update-awareness': {
spaceType: string;
spaceId: string;
docId: string;
awarenessUpdate: string;
};
'space:load-awarenesses': {
spaceType: string;
spaceId: string;
docId: string;
};
'space:push-doc-update': [
{ spaceType: string; spaceId: string; docId: string; update: string },
{ timestamp: number },
];
'space:load-doc-timestamps': [
{
spaceType: string;
spaceId: string;
timestamp?: number;
},
Record<string, number>,
];
'space:load-doc': [
{
spaceType: string;
spaceId: string;
docId: string;
stateVector?: string;
},
{
missing: string;
state: string;
timestamp: number;
},
];
'space:delete-doc': [
{ spaceType: string; spaceId: string; docId: string },
{ success?: true },
];
'space:doc-lifecycle': [
{
spaceType: string;
spaceId: string;
docId: string;
lifecycle: 'trash' | 'restore' | 'delete';
},
{ rootUpdate: string; timestamp: number },
];
'telemetry:batch': [TelemetryBatch, TelemetryAck];
'realtime:request': [RealtimeRequestEnvelope, unknown];
'realtime:subscribe': [RealtimeSubscribeEnvelope, { subscriptionId: string }];
'realtime:unsubscribe': [RealtimeUnsubscribeEnvelope, { ok: true }];
}
export const SPACE_JOIN_BATCH_LIMIT = 100;
export type ServerEventsMap = {
[Key in keyof ServerEvents]: (data: ServerEvents[Key]) => void;
};
export type ClientEventsMap = {
[Key in keyof ClientEvents]: ClientEvents[Key] extends Array<any>
? (
data: ClientEvents[Key][0],
ack: (res: WebsocketResponse<ClientEvents[Key][1]>) => void
) => void
: (data: ClientEvents[Key]) => void;
};
export type Socket = SocketIO<ServerEventsMap, ClientEventsMap>;
type BufferConstructorLike = {
from(
data: Uint8Array | string,
encoding?: string
): Uint8Array & {
toString(encoding: string): string;
};
};
const BufferCtor = (globalThis as { Buffer?: BufferConstructorLike }).Buffer;
const CHUNK_SIZE = 0x8000;
export async function uint8ArrayToBase64(array: Uint8Array): Promise<string> {
if (BufferCtor) {
return BufferCtor.from(array).toString('base64');
}
let binary = '';
for (let i = 0; i < array.length; i += CHUNK_SIZE) {
const chunk = array.subarray(i, i + CHUNK_SIZE);
binary += String.fromCharCode(...chunk);
}
return btoa(binary);
}
export function base64ToUint8Array(base64: string) {
if (BufferCtor) {
return new Uint8Array(BufferCtor.from(base64, 'base64'));
}
const binaryString = atob(base64);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes;
}
let authMethod:
| ((endpoint: string, cb: (data: object) => void) => void)
| undefined;
export function configureSocketAuthMethod(
cb: (endpoint: string, cb: (data: object) => void) => void
) {
authMethod = cb;
}
class SocketManager {
private readonly socketIOManager: SocketIOManager;
socket: Socket;
refCount = 0;
constructor(endpoint: string, isSelfHosted: boolean) {
this.socketIOManager = new SocketIOManager(endpoint, {
autoConnect: false,
transports: isSelfHosted ? ['polling', 'websocket'] : ['websocket'], // self-hosted server may not support websocket
secure: new URL(endpoint).protocol === 'https:',
// we will handle reconnection by ourselves
reconnection: false,
});
this.socket = this.socketIOManager.socket('/', {
auth(cb) {
if (authMethod) {
authMethod(endpoint, cb);
} else {
cb({});
}
},
});
}
connect() {
let disconnected = false;
this.refCount++;
this.socket.connect();
return {
socket: this.socket,
disconnect: () => {
if (disconnected) {
return;
}
disconnected = true;
this.refCount--;
if (this.refCount === 0) {
this.socket.disconnect();
}
},
};
}
reset() {
this.socket.disconnect();
}
}
const SOCKET_MANAGER_CACHE = new Map<string, SocketManager>();
function getSocketManagerKey(endpoint: string, isSelfHosted: boolean) {
return `${endpoint}:${isSelfHosted ? 'selfhosted' : 'cloud'}`;
}
function getSocketManager(endpoint: string, isSelfHosted: boolean) {
const key = getSocketManagerKey(endpoint, isSelfHosted);
let manager = SOCKET_MANAGER_CACHE.get(key);
if (!manager) {
manager = new SocketManager(endpoint, isSelfHosted);
SOCKET_MANAGER_CACHE.set(key, manager);
}
return manager;
}
export class SocketConnection extends AutoReconnectConnection<{
socket: Socket;
disconnect: () => void;
}> {
static resetSharedConnection(endpoint: string, isSelfHosted: boolean) {
SOCKET_MANAGER_CACHE.get(
getSocketManagerKey(endpoint, isSelfHosted)
)?.reset();
}
manager = getSocketManager(this.endpoint, this.isSelfHosted);
constructor(
private readonly endpoint: string,
private readonly isSelfHosted: boolean
) {
super();
}
override async doConnect(signal?: AbortSignal) {
const { socket, disconnect } = this.manager.connect();
try {
throwIfAborted(signal);
await Promise.race([
new Promise<void>((resolve, reject) => {
if (socket.connected) {
resolve();
return;
}
socket.once('connect', () => {
resolve();
});
socket.once('connect_error', err => {
reject(err);
});
}),
new Promise<void>((_resolve, reject) => {
signal?.addEventListener('abort', () => {
reject(signal.reason);
});
}),
]);
} catch (err) {
disconnect();
throw err;
}
socket.on('disconnect', this.handleDisconnect);
return {
socket,
disconnect,
};
}
override doDisconnect(conn: { socket: Socket; disconnect: () => void }) {
conn.socket.off('disconnect', this.handleDisconnect);
conn.disconnect();
}
handleDisconnect = (reason: SocketIO.DisconnectReason) => {
this.error = new Error(reason);
};
}

View File

@@ -0,0 +1,36 @@
import { share } from '../../connection';
import { BlobSyncStorageBase } from '../../storage';
import { IDBConnection, type IDBConnectionOptions } from './db';
export class IndexedDBBlobSyncStorage extends BlobSyncStorageBase {
static readonly identifier = 'IndexedDBBlobSyncStorage';
readonly connection = share(new IDBConnection(this.options));
constructor(private readonly options: IDBConnectionOptions) {
super();
}
get db() {
return this.connection;
}
async setBlobUploadedAt(
peer: string,
blobId: string,
uploadedAt: Date | null
): Promise<void> {
const trx = this.db.inner.db.transaction('blobSync', 'readwrite');
await trx.store.put({
peer,
key: blobId,
uploadedAt,
});
}
async getBlobUploadedAt(peer: string, blobId: string): Promise<Date | null> {
const trx = this.db.inner.db.transaction('blobSync', 'readonly');
const record = await trx.store.get([peer, blobId]);
return record?.uploadedAt ?? null;
}
}

View File

@@ -0,0 +1,96 @@
import { share } from '../../connection';
import {
type BlobRecord,
BlobStorageBase,
type ListedBlobRecord,
} from '../../storage';
import { IDBConnection, type IDBConnectionOptions } from './db';
export class IndexedDBBlobStorage extends BlobStorageBase {
static readonly identifier = 'IndexedDBBlobStorage';
override readonly isReadonly = false;
readonly connection = share(new IDBConnection(this.options));
constructor(private readonly options: IDBConnectionOptions) {
super();
}
get db() {
return this.connection.inner.db;
}
override async get(key: string) {
const trx = this.db.transaction(['blobs', 'blobData'], 'readonly');
const blob = await trx.objectStore('blobs').get(key);
const data = await trx.objectStore('blobData').get(key);
if (!blob || blob.deletedAt || !data) {
return null;
}
return {
...blob,
data: data.data,
};
}
override async set(blob: BlobRecord) {
const trx = this.db.transaction(['blobs', 'blobData'], 'readwrite');
await trx.objectStore('blobs').put({
key: blob.key,
mime: blob.mime,
size: blob.data.byteLength,
createdAt: new Date(),
deletedAt: null,
});
await trx.objectStore('blobData').put({
key: blob.key,
data: blob.data,
});
}
override async delete(key: string, permanently: boolean) {
if (permanently) {
const trx = this.db.transaction(['blobs', 'blobData'], 'readwrite');
await trx.objectStore('blobs').delete(key);
await trx.objectStore('blobData').delete(key);
} else {
const trx = this.db.transaction('blobs', 'readwrite');
const blob = await trx.store.get(key);
if (blob) {
await trx.store.put({
...blob,
deletedAt: new Date(),
});
}
}
}
override async release() {
const trx = this.db.transaction(['blobs', 'blobData'], 'readwrite');
const it = trx.objectStore('blobs').iterate();
for await (const item of it) {
if (item.value.deletedAt) {
await item.delete();
await trx.objectStore('blobData').delete(item.value.key);
}
}
}
override async list() {
const trx = this.db.transaction('blobs', 'readonly');
const it = trx.store.iterate();
const blobs: ListedBlobRecord[] = [];
for await (const item of it) {
if (!item.value.deletedAt) {
blobs.push(item.value);
}
}
return blobs;
}
}

View File

@@ -0,0 +1,59 @@
import { type IDBPDatabase, openDB } from 'idb';
import { AutoReconnectConnection } from '../../connection';
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.flavour}:${this.opts.type}:${this.opts.id}`;
override get shareId() {
return `idb(${migrator.version}):${this.dbName}`;
}
constructor(private readonly opts: IDBConnectionOptions) {
super();
}
override async doConnect() {
// indexeddb will responsible for version control, so the db.version always match migrator.version
const db = await openDB<DocStorageSchema>(this.dbName, migrator.version, {
upgrade: migrator.migrate,
});
db.addEventListener('versionchange', this.handleVersionChange);
return {
db,
channel: new BroadcastChannel('idb:' + this.dbName),
};
}
override doDisconnect(db: {
db: IDBPDatabase<DocStorageSchema>;
channel: BroadcastChannel;
}) {
db.db.removeEventListener('versionchange', this.handleVersionChange);
db.channel.close();
db.db.close();
}
handleVersionChange = (e: IDBVersionChangeEvent) => {
if (e.newVersion !== migrator.version) {
this.error = new Error(
'Database version mismatch, expected ' +
migrator.version +
' but got ' +
e.newVersion
);
}
};
}

View File

@@ -0,0 +1,149 @@
import { share } from '../../connection';
import {
type DocClock,
type DocClocks,
DocSyncStorageBase,
} from '../../storage';
import { IDBConnection, type IDBConnectionOptions } from './db';
export class IndexedDBDocSyncStorage extends DocSyncStorageBase {
static readonly identifier = 'IndexedDBDocSyncStorage';
constructor(private readonly options: IDBConnectionOptions) {
super();
}
readonly connection = share(new IDBConnection(this.options));
get db() {
return this.connection.inner.db;
}
override async getPeerRemoteClock(
peer: string,
docId: string
): Promise<DocClock | null> {
const trx = this.db.transaction('peerClocks', 'readonly');
const record = await trx.store.get([peer, docId]);
return record
? {
docId: record.docId,
timestamp: record.clock,
}
: null;
}
override async getPeerRemoteClocks(peer: string) {
const trx = this.db.transaction('peerClocks', 'readonly');
const records = await trx.store.index('peer').getAll(peer);
return records.reduce((clocks, { docId, clock }) => {
clocks[docId] = clock;
return clocks;
}, {} as DocClocks);
}
override async setPeerRemoteClock(peer: string, clock: DocClock) {
const trx = this.db.transaction('peerClocks', 'readwrite');
const record = await trx.store.get([peer, clock.docId]);
if (!record || record.clock < clock.timestamp) {
await trx.store.put({
peer,
docId: clock.docId,
clock: clock.timestamp,
pulledClock: record?.pulledClock ?? new Date(0),
pushedClock: record?.pushedClock ?? new Date(0),
});
}
}
override async getPeerPulledRemoteClock(
peer: string,
docId: string
): Promise<DocClock | null> {
const trx = this.db.transaction('peerClocks', 'readonly');
const record = await trx.store.get([peer, docId]);
return record
? {
docId: record.docId,
timestamp: record.pulledClock,
}
: null;
}
override async getPeerPulledRemoteClocks(peer: string) {
const trx = this.db.transaction('peerClocks', 'readonly');
const records = await trx.store.index('peer').getAll(peer);
return records.reduce((clocks, { docId, pulledClock }) => {
clocks[docId] = pulledClock;
return clocks;
}, {} as DocClocks);
}
override async setPeerPulledRemoteClock(peer: string, clock: DocClock) {
const trx = this.db.transaction('peerClocks', 'readwrite');
const record = await trx.store.get([peer, clock.docId]);
if (!record || record.pulledClock < clock.timestamp) {
await trx.store.put({
peer,
docId: clock.docId,
clock: record?.clock ?? new Date(0),
pulledClock: clock.timestamp,
pushedClock: record?.pushedClock ?? new Date(0),
});
}
}
override async getPeerPushedClock(
peer: string,
docId: string
): Promise<DocClock | null> {
const trx = this.db.transaction('peerClocks', 'readonly');
const record = await trx.store.get([peer, docId]);
return record
? {
docId: record.docId,
timestamp: record.pushedClock,
}
: null;
}
override async getPeerPushedClocks(peer: string) {
const trx = this.db.transaction('peerClocks', 'readonly');
const records = await trx.store.index('peer').getAll(peer);
return records.reduce((clocks, { docId, pushedClock }) => {
clocks[docId] = pushedClock;
return clocks;
}, {} as DocClocks);
}
override async setPeerPushedClock(peer: string, clock: DocClock) {
const trx = this.db.transaction('peerClocks', 'readwrite');
const record = await trx.store.get([peer, clock.docId]);
if (!record || record.pushedClock < clock.timestamp) {
await trx.store.put({
peer,
docId: clock.docId,
clock: record?.clock ?? new Date(0),
pushedClock: clock.timestamp,
pulledClock: record?.pulledClock ?? new Date(0),
});
}
}
override async clearClocks() {
const trx = this.db.transaction('peerClocks', 'readwrite');
await trx.store.clear();
}
}

View File

@@ -0,0 +1,209 @@
import { share } from '../../connection';
import {
type DocClock,
type DocClocks,
type DocRecord,
DocStorageBase,
type DocUpdate,
} from '../../storage';
import { IDBConnection, type IDBConnectionOptions } from './db';
import { IndexedDBLocker } from './lock';
interface ChannelMessage {
type: 'update';
update: DocRecord;
origin?: string;
}
export class IndexedDBDocStorage extends DocStorageBase<IDBConnectionOptions> {
static readonly identifier = 'IndexedDBDocStorage';
readonly connection = share(new IDBConnection(this.options));
get db() {
return this.connection.inner.db;
}
get channel() {
return this.connection.inner.channel;
}
override locker = new IndexedDBLocker(this.connection);
override async pushDocUpdate(update: DocUpdate, origin?: string) {
let timestamp = new Date();
let retry = 0;
while (true) {
try {
const trx = this.db.transaction(['updates', 'clocks'], 'readwrite');
await trx.objectStore('updates').add({
...update,
createdAt: timestamp,
});
await trx.objectStore('clocks').put({ docId: update.docId, timestamp });
trx.commit();
} catch (e) {
if (e instanceof Error && e.name === 'ConstraintError') {
retry++;
if (retry < 10) {
timestamp = new Date(timestamp.getTime() + 1);
continue;
}
}
throw e;
}
break;
}
this.emit(
'update',
{
docId: update.docId,
bin: update.bin,
timestamp,
editor: update.editor,
},
origin
);
this.channel.postMessage({
type: 'update',
update: {
docId: update.docId,
bin: update.bin,
timestamp,
editor: update.editor,
},
origin,
} satisfies ChannelMessage);
return { docId: update.docId, timestamp };
}
protected override async getDocSnapshot(docId: string) {
const trx = this.db.transaction('snapshots', 'readonly');
const record = await trx.store.get(docId);
if (!record) {
return null;
}
return {
docId,
bin: record.bin,
timestamp: record.updatedAt,
};
}
override async deleteDoc(docId: string) {
const trx = this.db.transaction(
['snapshots', 'updates', 'clocks'],
'readwrite'
);
const idx = trx.objectStore('updates').index('docId');
const iter = idx.iterate(IDBKeyRange.only(docId));
for await (const { value } of iter) {
await trx.objectStore('updates').delete([value.docId, value.createdAt]);
}
await trx.objectStore('snapshots').delete(docId);
await trx.objectStore('clocks').delete(docId);
}
override async getDocTimestamps(after: Date = new Date(0)) {
const trx = this.db.transaction('clocks', 'readonly');
const clocks = await trx.store.getAll();
return clocks.reduce((ret, cur) => {
if (cur.timestamp > after) {
ret[cur.docId] = cur.timestamp;
}
return ret;
}, {} as DocClocks);
}
override async getDocTimestamp(docId: string): Promise<DocClock | null> {
const trx = this.db.transaction('clocks', 'readonly');
return (await trx.store.get(docId)) ?? null;
}
protected override async setDocSnapshot(
snapshot: DocRecord
): Promise<boolean> {
const trx = this.db.transaction('snapshots', 'readwrite');
const record = await trx.store.get(snapshot.docId);
if (!record || record.updatedAt < snapshot.timestamp) {
await trx.store.put({
docId: snapshot.docId,
bin: snapshot.bin,
createdAt: record?.createdAt ?? snapshot.timestamp,
updatedAt: snapshot.timestamp,
});
}
trx.commit();
return true;
}
protected override async getDocUpdates(docId: string): Promise<DocRecord[]> {
const trx = this.db.transaction('updates', 'readonly');
const updates = await trx.store.index('docId').getAll(docId);
return updates.map(update => ({
docId,
bin: update.bin,
timestamp: update.createdAt,
}));
}
protected override async markUpdatesMerged(
docId: string,
updates: DocRecord[]
): Promise<number> {
const trx = this.db.transaction('updates', 'readwrite');
await Promise.all(
updates.map(update => trx.store.delete([docId, update.timestamp]))
);
trx.commit();
return updates.length;
}
private docUpdateListener = 0;
override subscribeDocUpdate(
callback: (update: DocRecord, origin?: string) => void
): () => void {
if (this.docUpdateListener === 0) {
this.channel.addEventListener('message', this.handleChannelMessage);
}
this.docUpdateListener++;
const dispose = super.subscribeDocUpdate(callback);
return () => {
dispose();
this.docUpdateListener--;
if (this.docUpdateListener === 0) {
this.channel.removeEventListener('message', this.handleChannelMessage);
}
};
}
handleChannelMessage = (event: MessageEvent<ChannelMessage>) => {
if (event.data.type === 'update') {
this.emit('update', event.data.update, event.data.origin);
}
};
}

View File

@@ -0,0 +1,28 @@
import type { StorageConstructor } from '..';
import { IndexedDBBlobStorage } from './blob';
import { IndexedDBBlobSyncStorage } from './blob-sync';
import { IndexedDBDocStorage } from './doc';
import { IndexedDBDocSyncStorage } from './doc-sync';
import { IndexedDBIndexerStorage } from './indexer';
import { IndexedDBIndexerSyncStorage } from './indexer-sync';
export * from './blob';
export * from './blob-sync';
export * from './doc';
export * from './doc-sync';
export * from './indexer';
export * from './indexer-sync';
export const idbStorages = [
IndexedDBDocStorage,
IndexedDBBlobStorage,
IndexedDBDocSyncStorage,
IndexedDBBlobSyncStorage,
IndexedDBIndexerStorage,
IndexedDBIndexerSyncStorage,
] satisfies StorageConstructor[];
export const idbStoragesIndexerOnly = [
IndexedDBIndexerStorage,
IndexedDBIndexerSyncStorage,
] satisfies StorageConstructor[];

View File

@@ -0,0 +1,45 @@
import { share } from '../../connection';
import {
type DocIndexedClock,
IndexerSyncStorageBase,
} from '../../storage/indexer-sync';
import { IDBConnection, type IDBConnectionOptions } from './db';
export class IndexedDBIndexerSyncStorage extends IndexerSyncStorageBase {
static readonly identifier = 'IndexedDBIndexerSyncStorage';
readonly connection = share(new IDBConnection(this.options));
constructor(private readonly options: IDBConnectionOptions) {
super();
}
async getDocIndexedClock(docId: string): Promise<DocIndexedClock | null> {
const tx = this.connection.inner.db.transaction('indexerSync', 'readonly');
const store = tx.store;
const result = await store.get(docId);
return result
? {
docId: result.docId,
timestamp: result.indexedClock,
indexerVersion: result.indexerVersion ?? 0,
}
: null;
}
async setDocIndexedClock(docClock: DocIndexedClock): Promise<void> {
const tx = this.connection.inner.db.transaction('indexerSync', 'readwrite');
const store = tx.store;
await store.put({
docId: docClock.docId,
indexedClock: docClock.timestamp,
indexerVersion: docClock.indexerVersion,
});
}
async clearDocIndexedClock(docId: string): Promise<void> {
const tx = this.connection.inner.db.transaction('indexerSync', 'readwrite');
const store = tx.store;
await store.delete(docId);
}
}

View File

@@ -0,0 +1,10 @@
import { expect, test } from 'vitest';
import { bm25 } from '../bm25';
test('bm25', () => {
expect(bm25(1, 1, 10, 10, 15)).toEqual(3.2792079793859643);
expect(bm25(2, 1, 10, 10, 15) > bm25(1, 1, 10, 10, 15)).toBeTruthy();
expect(bm25(1, 1, 10, 10, 15) > bm25(2, 1, 10, 100, 15)).toBeTruthy();
expect(bm25(1, 1, 10, 10, 15) > bm25(1, 1, 10, 100, 15)).toBeTruthy();
});

View File

@@ -0,0 +1,32 @@
import { expect, test } from 'vitest';
import { highlighter } from '../highlighter';
test('highlighter', () => {
expect(highlighter('0123456789', '<b>', '</b>', [[3, 5]])).toEqual(
'012<b>34</b>56789'
);
expect(
highlighter(
'012345678901234567890123456789012345678901234567890123456789',
'<b>',
'</b>',
[[59, 60]]
)
).toEqual('...0123456789012345678901234567890123456789012345678<b>9</b>');
expect(
highlighter(
'012345678901234567890123456789012345678901234567890123456789',
'<b>',
'</b>',
[
[10, 11],
[49, 51],
]
)
).toEqual(
'0123456789<b>0</b>12345678901234567890123456789012345678<b>9</b>...'
);
});

View File

@@ -0,0 +1,128 @@
import { expect, test } from 'vitest';
import { GeneralTokenizer } from '../tokenizer';
test('tokenizer', () => {
{
const tokens = new GeneralTokenizer().tokenize('hello world,\n AFFiNE');
expect(tokens).toEqual([
{ term: 'hello', start: 0, end: 5 },
{ term: 'world', start: 7, end: 12 },
{ term: 'affine', start: 15, end: 21 },
]);
}
{
const tokens = new GeneralTokenizer().tokenize('你好世界,阿芬');
expect(tokens).toEqual([
{
end: 2,
start: 0,
term: '你好',
},
{
end: 3,
start: 1,
term: '好世',
},
{
end: 4,
start: 2,
term: '世界',
},
{
end: 7,
start: 5,
term: '阿芬',
},
]);
}
{
const tokens = new GeneralTokenizer().tokenize('1阿2芬');
expect(tokens).toEqual([
{ term: '1', start: 0, end: 1 },
{ term: '阿', start: 1, end: 2 },
{ term: '2', start: 2, end: 3 },
{ term: '芬', start: 3, end: 4 },
]);
}
{
const tokens = new GeneralTokenizer().tokenize('안녕하세요 세계');
expect(tokens).toEqual([
{
end: 2,
start: 0,
term: '안녕',
},
{
end: 3,
start: 1,
term: '녕하',
},
{
end: 4,
start: 2,
term: '하세',
},
{
end: 5,
start: 3,
term: '세요',
},
{
end: 8,
start: 6,
term: '세계',
},
]);
}
{
const tokens = new GeneralTokenizer().tokenize('ハローワールド');
expect(tokens).toEqual([
{ term: 'ハロ', start: 0, end: 2 },
{ term: 'ロー', start: 1, end: 3 },
{ term: 'ーワ', start: 2, end: 4 },
{ term: 'ワー', start: 3, end: 5 },
{ term: 'ール', start: 4, end: 6 },
{ term: 'ルド', start: 5, end: 7 },
]);
}
{
const tokens = new GeneralTokenizer().tokenize('はろーわーるど');
expect(tokens).toEqual([
{ term: 'はろ', start: 0, end: 2 },
{ term: 'ろー', start: 1, end: 3 },
{ term: 'ーわ', start: 2, end: 4 },
{ term: 'わー', start: 3, end: 5 },
{ term: 'ーる', start: 4, end: 6 },
{ term: 'るど', start: 5, end: 7 },
]);
}
{
const tokens = new GeneralTokenizer().tokenize('👋1️⃣🚪👋🏿');
expect(tokens).toEqual([
{ term: '👋', start: 0, end: 2 },
{ term: '1️⃣', start: 2, end: 5 },
{ term: '🚪', start: 5, end: 7 },
{ term: '👋🏿', start: 7, end: 11 },
]);
}
{
const tokens = new GeneralTokenizer().tokenize('1️');
expect(tokens).toEqual([{ term: '1️', start: 0, end: 2 }]);
}
});

View File

@@ -0,0 +1,62 @@
/**
* Parameters of the BM25+ scoring algorithm. Customizing these is almost never
* necessary, and finetuning them requires an understanding of the BM25 scoring
* model.
*
* Some information about BM25 (and BM25+) can be found at these links:
*
* - https://en.wikipedia.org/wiki/Okapi_BM25
* - https://opensourceconnections.com/blog/2015/10/16/bm25-the-next-generation-of-lucene-relevation/
*/
export type BM25Params = {
/** Term frequency saturation point.
*
* Recommended values are between `1.2` and `2`. Higher values increase the
* difference in score between documents with higher and lower term
* frequencies. Setting this to `0` or a negative value is invalid. Defaults
* to `1.2`
*/
k: number;
/**
* Length normalization impact.
*
* Recommended values are around `0.75`. Higher values increase the weight
* that field length has on scoring. Setting this to `0` (not recommended)
* means that the field length has no effect on scoring. Negative values are
* invalid. Defaults to `0.7`.
*/
b: number;
/**
* BM25+ frequency normalization lower bound (usually called δ).
*
* Recommended values are between `0.5` and `1`. Increasing this parameter
* increases the minimum relevance of one occurrence of a search term
* regardless of its (possibly very long) field length. Negative values are
* invalid. Defaults to `0.5`.
*/
d: number;
};
const defaultBM25params: BM25Params = { k: 1.2, b: 0.7, d: 0.5 };
export const bm25 = (
termFreq: number,
matchingCount: number,
totalCount: number,
fieldLength: number,
avgFieldLength: number,
bm25params: BM25Params = defaultBM25params
): number => {
const { k, b, d } = bm25params;
const invDocFreq = Math.log(
1 + (totalCount - matchingCount + 0.5) / (matchingCount + 0.5)
);
return (
invDocFreq *
(d +
(termFreq * (k + 1)) /
(termFreq + k * (1 - b + b * (fieldLength / avgFieldLength))))
);
};

View File

@@ -0,0 +1,544 @@
import { type IDBPDatabase, type IDBPTransaction, type StoreNames } from 'idb';
import {
type AggregateOptions,
type AggregateResult,
type IndexerDocument,
type IndexerFieldSchema,
IndexerSchema,
type Query,
type SearchOptions,
type SearchResult,
} from '../../../storage';
import { shallowEqual } from '../../../utils/shallow-equal';
import type { DocStorageSchema } from '../schema';
import { highlighter } from './highlighter';
import {
BooleanInvertedIndex,
FullTextInvertedIndex,
IntegerInvertedIndex,
type InvertedIndex,
StringInvertedIndex,
} from './inverted-index';
import { Match } from './match';
export type DataStructRWTransaction = IDBPTransaction<
DocStorageSchema,
ArrayLike<StoreNames<DocStorageSchema>>,
'readwrite'
>;
export type DataStructROTransaction = IDBPTransaction<
DocStorageSchema,
ArrayLike<StoreNames<DocStorageSchema>>,
'readonly' | 'readwrite'
>;
let debugMarkCount = 0;
export class DataStruct {
database: IDBPDatabase<DocStorageSchema> = null as any;
invertedIndex = new Map<string, Map<string, InvertedIndex>>();
constructor() {
for (const [tableName, table] of Object.entries(IndexerSchema)) {
const tableInvertedIndex = new Map<string, InvertedIndex>();
for (const [fieldName, type] of Object.entries(table)) {
const typeInfo: IndexerFieldSchema =
typeof type === 'string' ? { type } : type;
if ('index' in typeInfo && typeInfo.index === false) {
// If index is false, we don't need to create an inverted index for this field.
continue;
}
if (typeInfo.type === 'String') {
tableInvertedIndex.set(
fieldName,
new StringInvertedIndex(tableName, fieldName)
);
} else if (typeInfo.type === 'Integer') {
tableInvertedIndex.set(
fieldName,
new IntegerInvertedIndex(tableName, fieldName)
);
} else if (typeInfo.type === 'FullText') {
tableInvertedIndex.set(
fieldName,
new FullTextInvertedIndex(tableName, fieldName)
);
} else if (typeInfo.type === 'Boolean') {
tableInvertedIndex.set(
fieldName,
new BooleanInvertedIndex(tableName, fieldName)
);
} else {
throw new Error(`Field type '${typeInfo.type}' not supported`);
}
}
this.invertedIndex.set(tableName, tableInvertedIndex);
}
}
private async update(
trx: DataStructRWTransaction,
table: keyof IndexerSchema,
document: IndexerDocument
) {
using _ = await this.measure(`update`);
const existsNid = await trx
.objectStore('indexerRecords')
.index('id')
.getKey([table, document.id]);
const exists = existsNid
? await trx.objectStore('indexerRecords').get(existsNid)
: null;
if (!existsNid || !exists) {
// if not exists, return
return;
}
// delete exists one
await this.deleteByNid(trx, existsNid);
const dataMap = new Map([...exists.data, ...document.fields]); // merge exists data with new data
const nid = await trx
.objectStore('indexerRecords')
.put({ table, id: document.id, data: dataMap });
for (const [key, values] of dataMap) {
const type = IndexerSchema[table][
key as keyof IndexerSchema[typeof table]
] as IndexerFieldSchema;
if (!type) {
continue;
}
const typeInfo = typeof type === 'string' ? { type } : type;
if (typeInfo.index !== false) {
// If index is false, the field will not be indexed
const iidx = this.invertedIndex.get(table)?.get(key);
if (!iidx) {
continue;
}
using _ = await this.measure(`insert[${typeInfo.type}]`);
await iidx.insert(trx, nid, values);
}
}
}
private async insert(
trx: DataStructRWTransaction,
table: keyof IndexerSchema,
document: IndexerDocument
) {
using _ = await this.measure(`insert`);
const existsNid = await trx
.objectStore('indexerRecords')
.index('id')
.getKey([table, document.id]);
if (existsNid) {
// delete exists one
await this.deleteByNid(trx, existsNid);
}
const dataMap = document.fields;
const nid = await trx
.objectStore('indexerRecords')
.put({ table, id: document.id, data: dataMap });
for (const [key, values] of dataMap) {
const type = IndexerSchema[table][
key as keyof IndexerSchema[typeof table]
] as IndexerFieldSchema;
if (!type) {
continue;
}
const typeInfo = typeof type === 'string' ? { type } : type;
if (typeInfo.index !== false) {
// If index is false, the field will not be indexed
const iidx = this.invertedIndex.get(table)?.get(key);
if (!iidx) {
continue;
}
using _ = await this.measure(`insert[${typeInfo.type}]`);
await iidx.insert(trx, nid, values);
}
}
}
private async deleteByNid(trx: DataStructRWTransaction, nid: number) {
await trx.objectStore('indexerRecords').delete(nid);
const indexIds = await trx
.objectStore('invertedIndex')
.index('nid')
.getAllKeys(nid);
await Promise.all(
indexIds.map(indexId => trx.objectStore('invertedIndex').delete(indexId))
);
}
private async delete(
trx: DataStructRWTransaction,
table: keyof IndexerSchema,
id: string
) {
using _ = await this.measure(`delete`);
const nid = await trx
.objectStore('indexerRecords')
.index('id')
.getKey([table, id]);
if (nid) {
await this.deleteByNid(trx, nid);
} else {
return;
}
}
private async deleteByQuery(
trx: DataStructRWTransaction,
table: keyof IndexerSchema,
query: Query<any>
) {
using _ = await this.measure(`deleteByQuery`);
const match = await this.queryRaw(trx, table, query);
for (const nid of match.scores.keys()) {
await this.deleteByNid(trx, nid);
}
}
async batchWrite(
trx: DataStructRWTransaction,
table: keyof IndexerSchema,
deleteByQueries: Query<any>[],
deletes: string[],
inserts: IndexerDocument<any>[],
updates: IndexerDocument<any>[]
) {
using _ = await this.measure(`batchWrite`);
for (const query of deleteByQueries) {
await this.deleteByQuery(trx, table, query);
}
for (const del of deletes) {
await this.delete(trx, table, del);
}
for (const inst of inserts) {
await this.insert(trx, table, inst);
}
for (const update of updates) {
await this.update(trx, table, update);
}
}
async matchAll(
trx: DataStructROTransaction,
table: keyof IndexerSchema
): Promise<Match> {
const allNids = await trx
.objectStore('indexerRecords')
.index('table')
.getAllKeys(table);
const match = new Match();
for (const nid of allNids) {
match.addScore(nid, 1);
}
return match;
}
async queryRaw(
trx: DataStructROTransaction,
table: keyof IndexerSchema,
query: Query<any>,
cache: QueryCache = new QueryCache()
): Promise<Match> {
const cached = cache.get(query);
if (cached) {
return cached;
}
const result = await (async () => {
using _ = await this.measure(`query[${query.type}]`);
if (query.type === 'match') {
const iidx = this.invertedIndex.get(table)?.get(query.field as string);
if (!iidx) {
return new Match();
}
return await iidx.match(trx, query.match);
} else if (query.type === 'boolean') {
const weights = [];
for (const q of query.queries) {
weights.push(await this.queryRaw(trx, table, q, cache));
}
if (query.occur === 'must') {
return weights.reduce((acc, w) => acc.and(w));
} else if (query.occur === 'must_not') {
const total = weights.reduce((acc, w) => acc.and(w));
return (await this.matchAll(trx, table)).exclude(total);
} else if (query.occur === 'should') {
return weights.reduce((acc, w) => acc.or(w));
}
} else if (query.type === 'all') {
return await this.matchAll(trx, table);
} else if (query.type === 'boost') {
return (await this.queryRaw(trx, table, query.query, cache)).boost(
query.boost
);
} else if (query.type === 'exists') {
const iidx = this.invertedIndex.get(table)?.get(query.field as string);
if (!iidx) {
return new Match();
}
return await iidx.all(trx);
}
throw new Error(`Query type '${query.type}' not supported`);
})();
cache.set(query, result);
return result;
}
async clear(trx: DataStructRWTransaction) {
await trx.objectStore('indexerRecords').clear();
await trx.objectStore('invertedIndex').clear();
await trx.objectStore('indexerMetadata').clear();
}
async search(
trx: DataStructROTransaction,
table: keyof IndexerSchema,
query: Query<any>,
options: SearchOptions<any> = {}
): Promise<SearchResult<any, any>> {
const pagination = {
skip: options.pagination?.skip ?? 0,
limit: options.pagination?.limit ?? 100,
};
const match = await this.queryRaw(trx, table, query);
const nids = match
.toArray()
.slice(pagination.skip, pagination.skip + pagination.limit);
const nodes = [];
for (const nid of nids) {
const record = await trx.objectStore('indexerRecords').get(nid);
if (!record) {
continue;
}
nodes.push(this.resultNode(record, options, match, nid));
}
return {
pagination: {
count: match.size(),
hasMore: match.size() > pagination.limit + pagination.skip,
limit: pagination.limit,
skip: pagination.skip,
},
nodes: nodes,
};
}
async aggregate(
trx: DataStructROTransaction,
table: keyof IndexerSchema,
query: Query<any>,
field: string,
options: AggregateOptions<any> = {}
): Promise<AggregateResult<any, any>> {
const pagination = {
skip: options.pagination?.skip ?? 0,
limit: options.pagination?.limit ?? 100,
};
const hitPagination = options.hits
? {
skip: options.hits.pagination?.skip ?? 0,
limit: options.hits.pagination?.limit ?? 3,
}
: { skip: 0, limit: 0 };
const match = await this.queryRaw(trx, table, query);
const nids = match.toArray();
const buckets: {
key: string;
nids: number[];
hits: SearchResult<any, any>['nodes'];
}[] = [];
for (const nid of nids) {
const record = await trx.objectStore('indexerRecords').get(nid);
if (!record) {
continue;
}
const values = record.data.get(field);
for (const value of values ?? []) {
let bucket;
let bucketIndex = buckets.findIndex(b => b.key === value);
if (bucketIndex === -1) {
bucket = { key: value, nids: [], hits: [] };
buckets.push(bucket);
bucketIndex = buckets.length - 1;
} else {
bucket = buckets[bucketIndex];
}
if (
bucketIndex >= pagination.skip &&
bucketIndex < pagination.skip + pagination.limit
) {
bucket.nids.push(nid);
if (
bucket.nids.length - 1 >= hitPagination.skip &&
bucket.nids.length - 1 < hitPagination.skip + hitPagination.limit
) {
bucket.hits.push(
this.resultNode(record, options.hits ?? {}, match, nid)
);
}
}
}
}
return {
buckets: buckets
.slice(pagination.skip, pagination.skip + pagination.limit)
.map(bucket => {
const result = {
key: bucket.key,
score: match.getScore(bucket.nids[0]),
count: bucket.nids.length,
} as AggregateResult<any, any>['buckets'][number];
if (options.hits) {
(result as any).hits = {
pagination: {
count: bucket.nids.length,
hasMore:
bucket.nids.length > hitPagination.limit + hitPagination.skip,
limit: hitPagination.limit,
skip: hitPagination.skip,
},
nodes: bucket.hits,
} as SearchResult<any, any>;
}
return result;
}),
pagination: {
count: buckets.length,
hasMore: buckets.length > pagination.limit + pagination.skip,
limit: pagination.limit,
skip: pagination.skip,
},
};
}
async readonly(database: IDBPDatabase<DocStorageSchema>) {
return database.transaction(
['indexerRecords', 'invertedIndex', 'indexerMetadata'],
'readonly',
{ durability: 'relaxed' }
);
}
async readwrite(database: IDBPDatabase<DocStorageSchema>) {
return database.transaction(
['indexerRecords', 'invertedIndex', 'indexerMetadata'],
'readwrite',
{ durability: 'relaxed' }
);
}
private resultNode(
record: { id: string; data: Map<string, string[]> },
options: SearchOptions<any>,
match?: Match,
nid?: number
): SearchResult<any, any>['nodes'][number] {
const node = {
id: record.id,
score: match && nid ? match.getScore(nid) : 1,
} as any;
if (options.fields) {
const fields = {} as Record<string, string | string[]>;
for (const field of options.fields as string[]) {
fields[field] = record.data.get(field) ?? [''];
if (fields[field].length === 1) {
fields[field] = fields[field][0];
}
}
node.fields = fields;
}
if (match && nid && options.highlights) {
const highlights = {} as Record<string, string[]>;
for (const { field, before, end } of options.highlights) {
const highlightValues = match.getHighlighters(nid, field);
if (highlightValues) {
const rawValues = record.data.get(field) ?? [];
highlights[field] = Array.from(highlightValues)
.map(([index, ranges]) => {
const raw = rawValues[index];
if (raw) {
return (
highlighter(raw, before, end, ranges, {
maxPrefix: 20,
maxLength: 50,
}) ?? ''
);
}
return '';
})
.filter(Boolean);
}
}
node.highlights = highlights;
}
return node;
}
async measure(name: string) {
const count = debugMarkCount++;
performance.mark(`${name}Start(${count})`);
return {
[Symbol.dispose]: () => {
performance.mark(`${name}End(${count})`);
performance.measure(
`${name}`,
`${name}Start(${count})`,
`${name}End(${count})`
);
},
};
}
}
class QueryCache {
private readonly cache: [Query<any>, Match][] = [];
get(query: Query<any>) {
return this.cache.find(q => shallowEqual(q[0], query))?.[1];
}
set(query: Query<any>, match: Match) {
this.cache.push([query, match]);
}
}

View File

@@ -0,0 +1,77 @@
export function highlighter(
originText: string,
before: string,
after: string,
matches: [number, number][],
{
maxLength = 50,
maxPrefix = 20,
}: { maxLength?: number; maxPrefix?: number } = {}
) {
const merged = mergeRanges(matches);
if (merged.length === 0) {
return null;
}
const firstMatch = merged[0][0];
const start = Math.max(
0,
Math.min(firstMatch - maxPrefix, originText.length - maxLength)
);
const end = Math.min(start + maxLength, originText.length);
const text = originText.substring(start, end);
let result = '';
let pointer = 0;
for (const match of merged) {
const matchStart = match[0] - start;
const matchEnd = match[1] - start;
if (matchStart >= text.length) {
break;
}
result += text.substring(pointer, matchStart);
pointer = matchStart;
const highlighted = text.substring(matchStart, matchEnd);
if (highlighted.length === 0) {
continue;
}
result += `${before}${highlighted}${after}`;
pointer = matchEnd;
}
result += text.substring(pointer);
if (start > 0) {
result = `...${result}`;
}
if (end < originText.length) {
result = `${result}...`;
}
return result;
}
function mergeRanges(intervals: [number, number][]) {
if (intervals.length === 0) return [];
intervals.sort((a, b) => a[0] - b[0]);
const merged = [intervals[0]];
for (let i = 1; i < intervals.length; i++) {
const last = merged[merged.length - 1];
const current = intervals[i];
if (current[0] <= last[1]) {
last[1] = Math.max(last[1], current[1]);
} else {
merged.push(current);
}
}
return merged;
}

View File

@@ -0,0 +1,227 @@
import { merge, Observable, of, Subject, throttleTime } from 'rxjs';
import { share } from '../../../connection';
import type {
AggregateOptions,
AggregateResult,
IndexerDocument,
IndexerSchema,
Query,
SearchOptions,
SearchResult,
} from '../../../storage';
import { IndexerStorageBase } from '../../../storage';
import { fromPromise } from '../../../utils/from-promise';
import { IDBConnection, type IDBConnectionOptions } from '../db';
import { DataStruct } from './data-struct';
import { backoffRetry, exhaustMapWithTrailing } from './utils';
export class IndexedDBIndexerStorage extends IndexerStorageBase {
static readonly identifier = 'IndexedDBIndexerStorage';
override recommendRefreshInterval: number = 0; // force refresh on each indexer operation
readonly connection = share(new IDBConnection(this.options));
override isReadonly = false;
private readonly data = new DataStruct();
private readonly tableUpdate$ = new Subject<string>();
/**
* The write operations of IndexedDBIndexerStorage are first cached in pendingUpdates,
* and then committed to IndexedDB in a batch through the refresh method.
*/
private readonly pendingUpdates: Record<
keyof IndexerSchema,
{
deleteByQueries: Query<any>[];
deletes: string[];
inserts: IndexerDocument[];
updates: IndexerDocument[];
}
> = {
doc: { deleteByQueries: [], deletes: [], inserts: [], updates: [] },
block: { deleteByQueries: [], deletes: [], inserts: [], updates: [] },
};
get channel() {
return this.connection.inner.channel;
}
get database() {
return this.connection.inner.db;
}
constructor(private readonly options: IDBConnectionOptions) {
super();
}
override async search<
T extends keyof IndexerSchema,
const O extends SearchOptions<T>,
>(table: T, query: Query<T>, options?: O): Promise<SearchResult<T, O>> {
const trx = await this.data.readonly(this.database);
return this.data.search(trx, table, query, options);
}
override async aggregate<
T extends keyof IndexerSchema,
const O extends AggregateOptions<T>,
>(
table: T,
query: Query<T>,
field: keyof IndexerSchema[T],
options?: O
): Promise<AggregateResult<T, O>> {
const trx = await this.data.readonly(this.database);
return this.data.aggregate(trx, table, query, field as string, options);
}
override search$<
T extends keyof IndexerSchema,
const O extends SearchOptions<T>,
>(table: T, query: Query<T>, options?: O): Observable<SearchResult<T, O>> {
return merge(of(1), this.watchTableUpdated(table)).pipe(
throttleTime(3000, undefined, { leading: true, trailing: true }),
exhaustMapWithTrailing(() => {
return fromPromise(async () => {
try {
const trx = await this.data.readonly(this.database);
return await this.data.search(trx, table, query, options);
} catch (error) {
console.error('search error', error);
throw error;
}
}).pipe(backoffRetry());
})
);
}
override aggregate$<
T extends keyof IndexerSchema,
const O extends AggregateOptions<T>,
>(
table: T,
query: Query<T>,
field: keyof IndexerSchema[T],
options?: O
): Observable<AggregateResult<T, O>> {
return merge(of(1), this.watchTableUpdated(table)).pipe(
throttleTime(3000, undefined, { leading: true, trailing: true }),
exhaustMapWithTrailing(() => {
return fromPromise(async () => {
try {
const trx = await this.data.readonly(this.database);
return await this.data.aggregate(
trx,
table,
query,
field as string,
options
);
} catch (error) {
console.error('aggregate error', error);
throw error;
}
}).pipe(backoffRetry());
})
);
}
override async deleteByQuery<T extends keyof IndexerSchema>(
table: T,
query: Query<T>
): Promise<void> {
this.pendingUpdates[table].deleteByQueries.push(query);
}
override insert<T extends keyof IndexerSchema>(
table: T,
document: IndexerDocument
): Promise<void> {
this.pendingUpdates[table].inserts.push(document);
return Promise.resolve();
}
override delete<T extends keyof IndexerSchema>(
table: T,
id: string
): Promise<void> {
this.pendingUpdates[table].deletes.push(id);
return Promise.resolve();
}
override update<T extends keyof IndexerSchema>(
table: T,
document: IndexerDocument
): Promise<void> {
this.pendingUpdates[table].updates.push(document);
return Promise.resolve();
}
override async refresh<T extends keyof IndexerSchema>(
table: T
): Promise<void> {
const trx = await this.data.readwrite(this.database);
const tables = table ? [table] : (['doc', 'block'] as const);
for (const table of tables) {
await this.data.batchWrite(
trx,
table,
this.pendingUpdates[table].deleteByQueries,
this.pendingUpdates[table].deletes,
this.pendingUpdates[table].inserts,
this.pendingUpdates[table].updates
);
this.pendingUpdates[table] = {
deleteByQueries: [],
deletes: [],
inserts: [],
updates: [],
};
}
this.emitTableUpdated(table);
}
override async refreshIfNeed(): Promise<void> {
const needRefreshTable = Object.entries(this.pendingUpdates)
.filter(
([, updates]) =>
updates.deleteByQueries.length > 0 ||
updates.deletes.length > 0 ||
updates.inserts.length > 0 ||
updates.updates.length > 0
)
.map(([table]) => table as keyof IndexerSchema);
for (const table of needRefreshTable) {
await this.refresh(table);
}
}
private watchTableUpdated(table: keyof IndexerSchema) {
return new Observable(subscriber => {
const listener = (ev: MessageEvent) => {
if (ev.data.type === 'indexer-updated' && ev.data.table === table) {
subscriber.next(1);
}
};
const subscription = this.tableUpdate$.subscribe(updatedTable => {
if (updatedTable === table) {
subscriber.next(1);
}
});
this.channel.addEventListener('message', listener);
return () => {
this.channel.removeEventListener('message', listener);
subscription.unsubscribe();
};
});
}
emitTableUpdated(table: keyof IndexerSchema) {
this.tableUpdate$.next(table);
this.channel.postMessage({ type: 'indexer-updated', table });
}
// Get the current indexer version
// increase this number to re-index all docs
async indexVersion(): Promise<number> {
return 1;
}
}

View File

@@ -0,0 +1,537 @@
import { bm25 } from './bm25';
import type {
DataStructROTransaction,
DataStructRWTransaction,
} from './data-struct';
import { Match } from './match';
import { GeneralTokenizer, type Token } from './tokenizer';
export interface InvertedIndex {
fieldKey: string;
match(trx: DataStructROTransaction, term: string): Promise<Match>;
all(trx: DataStructROTransaction): Promise<Match>;
insert(
trx: DataStructRWTransaction,
id: number,
terms: string[]
): Promise<void>;
}
export class StringInvertedIndex implements InvertedIndex {
constructor(
readonly table: string,
readonly fieldKey: string
) {}
async match(trx: DataStructROTransaction, term: string): Promise<Match> {
const objs = await trx
.objectStore('invertedIndex')
.index('key')
.getAll([
this.table,
InvertedIndexKey.forString(this.fieldKey, term).buffer(),
]);
const match = new Match();
for (const obj of objs) {
match.addScore(obj.nid, 1);
}
return match;
}
async all(trx: DataStructROTransaction): Promise<Match> {
const objs = await trx
.objectStore('invertedIndex')
.index('key')
.getAll(
IDBKeyRange.bound(
[this.table, InvertedIndexKey.forPrefix(this.fieldKey).buffer()],
[
this.table,
InvertedIndexKey.forPrefix(this.fieldKey).add1().buffer(),
]
)
);
const set = new Set<number>();
for (const obj of objs) {
set.add(obj.nid);
}
const match = new Match();
for (const nid of set) {
match.addScore(nid, 1);
}
return match;
}
async insert(trx: DataStructRWTransaction, id: number, terms: string[]) {
const uniqueTerms = new Set(terms);
await Promise.all(
Array.from(uniqueTerms).map(term =>
trx.objectStore('invertedIndex').put({
table: this.table,
key: InvertedIndexKey.forString(this.fieldKey, term).buffer(),
nid: id,
})
)
);
}
}
export class IntegerInvertedIndex implements InvertedIndex {
constructor(
readonly table: string,
readonly fieldKey: string
) {}
async match(trx: DataStructROTransaction, term: string): Promise<Match> {
const objs = await trx
.objectStore('invertedIndex')
.index('key')
.getAll([
this.table,
InvertedIndexKey.forInt64(this.fieldKey, BigInt(term)).buffer(),
]);
const match = new Match();
for (const obj of objs) {
match.addScore(obj.nid, 1);
}
return match;
}
// oxlint-disable-next-line sonarjs/no-identical-functions
async all(trx: DataStructROTransaction): Promise<Match> {
const objs = await trx
.objectStore('invertedIndex')
.index('key')
.getAll(
IDBKeyRange.bound(
[this.table, InvertedIndexKey.forPrefix(this.fieldKey).buffer()],
[
this.table,
InvertedIndexKey.forPrefix(this.fieldKey).add1().buffer(),
]
)
);
const set = new Set<number>();
for (const obj of objs) {
set.add(obj.nid);
}
const match = new Match();
for (const nid of set) {
match.addScore(nid, 1);
}
return match;
}
async insert(trx: DataStructRWTransaction, id: number, terms: string[]) {
const uniqueTerms = new Set(terms);
await Promise.all(
Array.from(uniqueTerms).map(term =>
trx.objectStore('invertedIndex').put({
table: this.table,
key: InvertedIndexKey.forInt64(this.fieldKey, BigInt(term)).buffer(),
nid: id,
})
)
);
}
}
export class BooleanInvertedIndex implements InvertedIndex {
constructor(
readonly table: string,
readonly fieldKey: string
) {}
// oxlint-disable-next-line sonarjs/no-identical-functions
async all(trx: DataStructROTransaction): Promise<Match> {
const objs = await trx
.objectStore('invertedIndex')
.index('key')
.getAll(
IDBKeyRange.bound(
[this.table, InvertedIndexKey.forPrefix(this.fieldKey).buffer()],
[
this.table,
InvertedIndexKey.forPrefix(this.fieldKey).add1().buffer(),
]
)
);
const set = new Set<number>();
for (const obj of objs) {
set.add(obj.nid);
}
const match = new Match();
for (const nid of set) {
match.addScore(nid, 1);
}
return match;
}
async match(trx: DataStructROTransaction, term: string): Promise<Match> {
const objs = await trx
.objectStore('invertedIndex')
.index('key')
.getAll([
this.table,
InvertedIndexKey.forBoolean(this.fieldKey, term === 'true').buffer(),
]);
const match = new Match();
for (const obj of objs) {
match.addScore(obj.nid, 1);
}
return match;
}
async insert(trx: DataStructRWTransaction, id: number, terms: string[]) {
const uniqueTerms = new Set(terms);
await Promise.all(
Array.from(uniqueTerms).map(term =>
trx.objectStore('invertedIndex').put({
table: this.table,
key: InvertedIndexKey.forBoolean(
this.fieldKey,
term === 'true'
).buffer(),
nid: id,
})
)
);
}
}
export class FullTextInvertedIndex implements InvertedIndex {
constructor(
readonly table: string,
readonly fieldKey: string
) {}
async match(trx: DataStructROTransaction, term: string): Promise<Match> {
const queryTokens = new GeneralTokenizer().tokenize(term);
const matched = new Map<
number,
Map<
number, // index
{
score: number;
ranges: [number, number][];
}
>
>();
const avgFieldLength =
(
await trx
.objectStore('indexerMetadata')
.get(`full-text:avg-field-length:${this.table}:${this.fieldKey}`)
)?.value ?? 0;
for (const token of queryTokens) {
const key = InvertedIndexKey.forString(this.fieldKey, token.term);
const objs = [
// match exact
...(await trx
.objectStore('invertedIndex')
.index('key')
.getAll([this.table, key.buffer()])),
// match prefix
...(await trx
.objectStore('invertedIndex')
.index('key')
.getAll(
IDBKeyRange.bound(
[this.table, key.buffer()],
[this.table, key.add1().buffer()],
true,
true
),
5000 // get maximum 5000 items for prefix match
)),
];
const submatched: {
nid: number;
score: number;
position: {
index: number;
ranges: [number, number][];
};
}[] = [];
for (const obj of objs) {
if (!obj) {
continue;
}
const key = InvertedIndexKey.fromBuffer(obj.key);
const originTokenTerm = key.asString();
const matchLength = token.term.length;
let positions = obj.pos
? Array.isArray(obj.pos)
? obj.pos
: [obj.pos]
: [
{
i: 0,
l: 0,
rs: [] as [number, number][],
},
];
for (const position of positions) {
const termFreq = position.rs.length;
const totalCount = objs.length;
const fieldLength = position.l;
const score =
bm25(termFreq, 1, totalCount, fieldLength, avgFieldLength) *
(matchLength / originTokenTerm.length);
submatched.push({
nid: obj.nid,
score,
position: {
index: position.i,
ranges: position.rs.map(([start, _end]) => [
start,
start + matchLength,
]),
},
});
}
}
// normalize score
const maxScore = submatched.reduce((acc, s) => Math.max(acc, s.score), 0);
const minScore = submatched.reduce((acc, s) => Math.min(acc, s.score), 0);
for (const { nid, score, position } of submatched) {
const normalizedScore =
maxScore === minScore
? score
: (score - minScore) / (maxScore - minScore);
const match =
matched.get(nid) ??
new Map<
number, // index
{
score: number;
ranges: [number, number][];
}
>();
const item = match.get(position.index) || {
score: 0,
ranges: [],
};
item.score += normalizedScore;
item.ranges.push(...position.ranges);
match.set(position.index, item);
matched.set(nid, match);
}
}
const match = new Match();
for (const [nid, items] of matched) {
if (items.size === 0) {
break;
}
let highestScore = -1;
let highestIndex = -1;
let highestRanges: [number, number][] = [];
for (const [index, { score, ranges }] of items) {
if (score > highestScore) {
highestScore = score;
highestIndex = index;
highestRanges = ranges;
}
}
match.addScore(nid, highestScore);
match.addHighlighter(nid, this.fieldKey, highestIndex, highestRanges);
}
return match;
}
// oxlint-disable-next-line sonarjs/no-identical-functions
async all(trx: DataStructROTransaction): Promise<Match> {
const objs = await trx
.objectStore('invertedIndex')
.index('key')
.getAll(
IDBKeyRange.bound(
[this.table, InvertedIndexKey.forPrefix(this.fieldKey).buffer()],
[
this.table,
InvertedIndexKey.forPrefix(this.fieldKey).add1().buffer(),
]
)
);
const set = new Set<number>();
for (const obj of objs) {
set.add(obj.nid);
}
const match = new Match();
for (const nid of set) {
match.addScore(nid, 1);
}
return match;
}
async insert(trx: DataStructRWTransaction, id: number, terms: string[]) {
const promises: Promise<any>[] = [];
const totalTermLength = terms.reduce((acc, term) => acc + term.length, 0);
const globalTokenMap = new Map<
string,
{ l: number; i: number; rs: [number, number][] }[]
>();
for (let i = 0; i < terms.length; i++) {
const tokenMap = new Map<string, Token[]>();
const originString = terms[i];
const tokens = new GeneralTokenizer().tokenize(originString);
for (const token of tokens) {
const tokens = tokenMap.get(token.term) || [];
tokens.push(token);
tokenMap.set(token.term, tokens);
}
for (const [term, tokens] of tokenMap) {
const entry = globalTokenMap.get(term) || [];
entry.push({
l: originString.length,
i: i,
rs: tokens.map(token => [token.start, token.end]),
});
globalTokenMap.set(term, entry);
}
}
for (const [term, positions] of globalTokenMap) {
promises.push(
trx.objectStore('invertedIndex').put({
table: this.table,
key: InvertedIndexKey.forString(this.fieldKey, term).buffer(),
nid: id,
pos: positions,
})
);
}
const indexerMetadataStore = trx.objectStore('indexerMetadata');
const countKey = `full-text:field-count:${this.table}:${this.fieldKey}`;
const avgKey = `full-text:avg-field-length:${this.table}:${this.fieldKey}`;
const [countObj, avgObj] = await Promise.all([
indexerMetadataStore.get(countKey),
indexerMetadataStore.get(avgKey),
]);
const totalCount = countObj?.value ?? 0;
const avgFieldLength = avgObj?.value ?? 0;
const newTotalCount = totalCount + terms.length;
const newAvgFieldLength =
(avgFieldLength * totalCount + totalTermLength) / newTotalCount;
promises.push(
indexerMetadataStore.put({
key: countKey,
value: newTotalCount,
})
);
promises.push(
indexerMetadataStore.put({
key: avgKey,
value: isNaN(newAvgFieldLength) ? 0 : newAvgFieldLength,
})
);
await Promise.all(promises);
}
}
export class InvertedIndexKey {
constructor(
readonly field: Uint8Array,
readonly value: Uint8Array,
readonly gap: Uint8Array = new Uint8Array([58])
) {}
asString() {
return new TextDecoder().decode(this.value);
}
asInt64() {
return new DataView(this.value.buffer).getBigInt64(
0,
false
); /* big-endian */
}
add1() {
if (this.value.byteLength > 0) {
const bytes = new Uint8Array(this.value.slice(0));
let carry = 1;
for (let i = bytes.length - 1; i >= 0 && carry > 0; i--) {
const sum = bytes[i] + carry;
bytes[i] = sum % 256;
carry = sum >> 8;
}
return new InvertedIndexKey(this.field, bytes);
} else {
return new InvertedIndexKey(
this.field,
new Uint8Array(0),
new Uint8Array([59])
);
}
}
static forPrefix(field: string) {
return new InvertedIndexKey(
new TextEncoder().encode(field),
new Uint8Array(0)
);
}
static forString(field: string, value: string) {
return new InvertedIndexKey(
new TextEncoder().encode(field),
new TextEncoder().encode(value)
);
}
static forBoolean(field: string, value: boolean) {
const bytes = new Uint8Array(1);
bytes.set([value ? 1 : 0]);
return new InvertedIndexKey(new TextEncoder().encode(field), bytes);
}
static forInt64(field: string, value: bigint) {
const bytes = new Uint8Array(8);
new DataView(bytes.buffer).setBigInt64(0, value, false); /* big-endian */
return new InvertedIndexKey(new TextEncoder().encode(field), bytes);
}
buffer() {
const tmp = new Uint8Array(
this.field.byteLength + (this.value?.byteLength ?? 0) + 1
);
tmp.set(new Uint8Array(this.field), 0);
tmp.set(new Uint8Array(this.gap), this.field.byteLength);
if (this.value.byteLength > 0) {
tmp.set(new Uint8Array(this.value), this.field.byteLength + 1);
}
return tmp.buffer;
}
static fromBuffer(buffer: ArrayBuffer) {
const array = new Uint8Array(buffer);
const fieldLength = array.indexOf(58);
const field = array.slice(0, fieldLength);
const value = array.slice(fieldLength + 1);
return new InvertedIndexKey(field, value);
}
}

View File

@@ -0,0 +1,105 @@
export class Match {
scores = new Map<number, number>();
/**
* nid -> field -> index(multi value field) -> [start, end][]
*/
highlighters = new Map<
number,
Map<string, Map<number, [number, number][]>>
>();
constructor() {}
size() {
return this.scores.size;
}
getScore(id: number) {
return this.scores.get(id) ?? 0;
}
addScore(id: number, score: number) {
const currentScore = this.scores.get(id) || 0;
this.scores.set(id, currentScore + score);
}
getHighlighters(id: number, field: string) {
return this.highlighters.get(id)?.get(field);
}
addHighlighter(
id: number,
field: string,
index: number,
newRanges: [number, number][]
) {
const fields =
this.highlighters.get(id) ||
new Map<string, Map<number, [number, number][]>>();
const values = fields.get(field) || new Map<number, [number, number][]>();
const ranges = values.get(index) || [];
ranges.push(...newRanges);
values.set(index, ranges);
fields.set(field, values);
this.highlighters.set(id, fields);
}
and(other: Match) {
const newWeight = new Match();
for (const [id, score] of this.scores) {
if (other.scores.has(id)) {
newWeight.addScore(id, score + (other.scores.get(id) ?? 0));
newWeight.copyExtData(this, id);
newWeight.copyExtData(other, id);
}
}
return newWeight;
}
or(other: Match) {
const newWeight = new Match();
for (const [id, score] of this.scores) {
newWeight.addScore(id, score);
newWeight.copyExtData(this, id);
}
for (const [id, score] of other.scores) {
newWeight.addScore(id, score);
newWeight.copyExtData(other, id);
}
return newWeight;
}
exclude(other: Match) {
const newWeight = new Match();
for (const [id, score] of this.scores) {
if (!other.scores.has(id)) {
newWeight.addScore(id, score);
newWeight.copyExtData(this, id);
}
}
return newWeight;
}
boost(boost: number) {
const newWeight = new Match();
for (const [id, score] of this.scores) {
newWeight.addScore(id, score * boost);
newWeight.copyExtData(this, id);
}
return newWeight;
}
toArray() {
return Array.from(this.scores.entries())
.sort((a, b) => b[1] - a[1])
.map(e => e[0]);
}
private copyExtData(from: Match, id: number) {
for (const [field, values] of from.highlighters.get(id) ?? []) {
for (const [index, ranges] of values) {
this.addHighlighter(id, field, index, ranges);
}
}
}
}

View File

@@ -0,0 +1,162 @@
import Graphemer from 'graphemer';
export interface Tokenizer {
tokenize(text: string): Token[];
}
export interface Token {
term: string;
start: number;
end: number;
}
export class SimpleTokenizer implements Tokenizer {
tokenize(text: string): Token[] {
const tokens: Token[] = [];
let start = 0;
let end = 0;
let inWord = false;
for (let i = 0; i < text.length; i++) {
const c = text[i];
if (c.match(/[\n\r\p{Z}\p{P}]/u)) {
if (inWord) {
end = i;
tokens.push({
term: text.substring(start, end).toLowerCase(),
start,
end,
});
inWord = false;
}
} else {
if (!inWord) {
start = i;
end = i;
inWord = true;
}
}
}
if (inWord) {
tokens.push({
term: text.substring(start).toLowerCase(),
start,
end: text.length,
});
}
return tokens;
}
}
export class NGramTokenizer implements Tokenizer {
constructor(private readonly n: number) {}
tokenize(text: string): Token[] {
const splitted: Token[] = [];
for (let i = 0; i < text.length;) {
const nextBreak = Graphemer.nextBreak(text, i);
const c = text.substring(i, nextBreak);
splitted.push({
term: c,
start: i,
end: nextBreak,
});
i = nextBreak;
}
const tokens: Token[] = [];
for (let i = 0; i < splitted.length - this.n + 1; i++) {
tokens.push(
splitted.slice(i, i + this.n).reduce(
(acc, t) => ({
term: acc.term + t.term,
start: Math.min(acc.start, t.start),
end: Math.max(acc.end, t.end),
}),
{ term: '', start: Infinity, end: -Infinity }
)
);
}
return tokens;
}
}
export class GeneralTokenizer implements Tokenizer {
constructor() {}
tokenizeWord(word: string, lang: string): Token[] {
if (lang === 'en') {
return [{ term: word.toLowerCase(), start: 0, end: word.length }];
} else if (lang === 'cjk') {
if (word.length < 3) {
return [{ term: word, start: 0, end: word.length }];
}
return new NGramTokenizer(2).tokenize(word);
} else if (lang === 'emoji') {
return new NGramTokenizer(1).tokenize(word);
} else if (lang === '-') {
return [];
}
throw new Error('Not implemented');
}
testLang(c: string): string {
if (c.match(/[\p{Emoji}]/u)) {
return 'emoji';
} else if (c.match(/[\p{sc=Han}\p{scx=Hira}\p{scx=Kana}\p{sc=Hang}]/u)) {
return 'cjk';
} else if (c.match(/[\n\r\p{Z}\p{P}]/u)) {
return '-';
} else {
return 'en';
}
}
tokenize(text: string): Token[] {
const tokens: Token[] = [];
let start = 0;
let end = 0;
let lang: string | null = null;
for (let i = 0; i < text.length;) {
const nextBreak = Graphemer.nextBreak(text, i);
const c = text.substring(i, nextBreak);
const l = this.testLang(c);
if (lang !== l) {
if (lang !== null) {
end = i;
tokens.push(
...this.tokenizeWord(text.substring(start, end), lang).map(
token => ({
...token,
start: token.start + start,
end: token.end + start,
})
)
);
}
start = i;
end = i;
lang = l;
}
i = nextBreak;
}
if (lang !== null) {
tokens.push(
...this.tokenizeWord(text.substring(start, text.length), lang).map(
token => ({
...token,
start: token.start + start,
end: token.end + start,
})
)
);
}
return tokens;
}
}

View File

@@ -0,0 +1,75 @@
import {
asyncScheduler,
defer,
exhaustMap,
finalize,
type Observable,
type ObservableInput,
type OperatorFunction,
retry,
scheduled,
Subject,
throttle,
throwError,
timer,
} from 'rxjs';
/**
* Like exhaustMap, but also includes the trailing value emitted from the source observable while waiting for the preceding inner observable to complete
*
* Original code adapted from https://github.com/ReactiveX/rxjs/issues/5004
* @param {function<T, K>(value: T, ?index: number): ObservableInput<K>} project - A function that, when applied to an item emitted by the
* source Observable, returns a projected Observable.
*/
export function exhaustMapWithTrailing<T, R>(
project: (value: T, index: number) => ObservableInput<R>
): OperatorFunction<T, R> {
return (source$): Observable<R> =>
defer(() => {
const release$ = new Subject<void>();
return source$.pipe(
throttle(() => release$, {
leading: true,
trailing: true,
}),
exhaustMap((value, index) =>
scheduled(project(value, index), asyncScheduler).pipe(
finalize(() => {
release$.next();
})
)
)
);
});
}
/**
* An operator that retries the source observable when an error occurs.
*
* https://en.wikipedia.org/wiki/Exponential_backoff
*/
export function backoffRetry<T>({
when,
count = 3,
delay = 200,
maxDelay = 15000,
}: {
when?: (err: any) => boolean;
count?: number;
delay?: number;
maxDelay?: number;
} = {}) {
return (obs$: Observable<T>) =>
obs$.pipe(
retry({
count,
delay: (err, retryIndex) => {
if (when && !when(err)) {
return throwError(() => err);
}
const d = Math.pow(2, retryIndex - 1) * delay;
return timer(Math.min(d, maxDelay));
},
})
);
}

View File

@@ -0,0 +1,83 @@
import EventEmitter2 from 'eventemitter2';
import { type Locker } from '../../storage/lock';
import type { IDBConnection } from './db';
interface ChannelMessage {
type: 'unlock';
key: string;
}
export class IndexedDBLocker implements Locker {
get db() {
return this.dbConnection.inner.db;
}
private readonly eventEmitter = new EventEmitter2();
get channel() {
return this.dbConnection.inner.channel;
}
constructor(private readonly dbConnection: IDBConnection) {}
async lock(domain: string, resource: string) {
const key = `${domain}:${resource}`;
// oxlint-disable-next-line no-constant-condition
while (true) {
const trx = this.db.transaction('locks', 'readwrite');
const record = await trx.store.get(key);
const lockTimestamp = record?.lock.getTime();
if (
lockTimestamp &&
lockTimestamp > Date.now() - 30000 /* lock timeout 3s */
) {
trx.commit();
await new Promise<void>(resolve => {
const cleanup = () => {
this.channel.removeEventListener('message', channelListener);
this.eventEmitter.off('unlock', eventListener);
clearTimeout(timer);
};
const channelListener = (event: MessageEvent<ChannelMessage>) => {
if (event.data.type === 'unlock' && event.data.key === key) {
cleanup();
resolve();
}
};
const eventListener = (unlockKey: string) => {
if (unlockKey === key) {
cleanup();
resolve();
}
};
this.channel.addEventListener('message', channelListener); // add listener
this.eventEmitter.on('unlock', eventListener);
const timer = setTimeout(() => {
cleanup();
resolve();
}, 3000);
// timeout to avoid dead lock
});
continue;
} else {
await trx.store.put({ key, lock: new Date() });
trx.commit();
break;
}
}
return {
[Symbol.asyncDispose]: async () => {
const trx = this.db.transaction('locks', 'readwrite');
await trx.store.delete(key);
trx.commit();
this.channel.postMessage({ type: 'unlock', key });
this.eventEmitter.emit('unlock', key);
},
};
}
}

View File

@@ -0,0 +1,279 @@
import { type DBSchema, type OpenDBCallbacks } from 'idb';
/**
IndexedDB
> DB(workspace:${workspaceId})
> Table(Snapshots)
> Table(Updates)
> Table(...)
Table(Snapshots)
| docId | blob | createdAt | updatedAt |
|-------|------|-----------|-----------|
| str | bin | Date | Date |
Table(Updates)
| id | docId | blob | createdAt |
|----|-------|------|-----------|
|auto| str | bin | Date |
Table(Clocks)
| docId | clock |
|-------|-----------|
| str | Date |
Table(Blobs)
| key | mime | size | createdAt | deletedAt |
|-----|------|------|-----------|-----------|
| str | str | num | Date | Date |
Table(BlobData)
| key | data |
|-----|------|
| str | bin |
Table(PeerClocks)
| peer | docId | clock | pushed |
|------|-------|-----------|-----------|
| str | str | Date | Date |
Table(IndexerSync)
| docId | indexedClock | indexerVersion |
|-------|--------------|----------------|
| str | Date | number |
Table(BlobSync)
| peer | key | uploadedAt |
|------|-----|------------|
| str | str | Date |
*/
export interface DocStorageSchema extends DBSchema {
snapshots: {
key: string;
value: {
docId: string;
bin: Uint8Array;
createdAt: Date;
updatedAt: Date;
};
indexes: {
updatedAt: Date;
};
};
updates: {
key: [string, Date];
value: {
docId: string;
bin: Uint8Array;
createdAt: Date;
};
indexes: {
docId: string;
};
};
clocks: {
key: string;
value: {
docId: string;
timestamp: Date;
};
indexes: {
timestamp: Date;
};
};
blobs: {
key: string;
value: {
key: string;
mime: string;
size: number;
createdAt: Date;
deletedAt: Date | null;
};
};
blobSync: {
key: [string, string];
value: {
peer: string;
key: string;
uploadedAt: Date | null;
};
indexes: {
peer: string;
};
};
blobData: {
key: string;
value: {
key: string;
data: Uint8Array;
};
};
peerClocks: {
key: [string, string];
value: {
peer: string;
docId: string;
clock: Date;
pulledClock: Date;
pushedClock: Date;
};
indexes: {
peer: string;
};
};
locks: {
key: string;
value: {
key: string;
lock: Date;
};
};
indexerSync: {
key: string;
value: {
docId: string;
indexedClock: Date;
indexerVersion?: number;
};
};
indexerMetadata: {
key: string;
value: {
key: string;
value: any;
};
};
indexerRecords: {
key: number;
value: {
table: string;
id: string;
data: Map<string, string[]>;
};
indexes: { table: string; id: [string, string] };
};
invertedIndex: {
key: number;
value: {
table: string;
nid: number;
pos?: {
i: number /* index */;
l: number /* length */;
rs: [number, number][] /* ranges: [start, end] */;
}[];
key: ArrayBuffer;
};
indexes: { key: [string, ArrayBuffer]; nid: number };
};
}
const migrate: OpenDBCallbacks<DocStorageSchema>['upgrade'] = (
db,
oldVersion,
_newVersion,
trx
) => {
if (!oldVersion) {
oldVersion = 0;
}
for (let i = oldVersion; i < migrations.length; i++) {
migrations[i](db, trx);
}
};
type MigrateParameters = Parameters<
NonNullable<OpenDBCallbacks<DocStorageSchema>['upgrade']>
>;
type Migrate = (db: MigrateParameters[0], trx: MigrateParameters[3]) => void;
// START REGION: migrations
const init: Migrate = db => {
const snapshots = db.createObjectStore('snapshots', {
keyPath: 'docId',
autoIncrement: false,
});
snapshots.createIndex('updatedAt', 'updatedAt', { unique: false });
const updates = db.createObjectStore('updates', {
keyPath: ['docId', 'createdAt'],
autoIncrement: false,
});
updates.createIndex('docId', 'docId', { unique: false });
const clocks = db.createObjectStore('clocks', {
keyPath: 'docId',
autoIncrement: false,
});
clocks.createIndex('timestamp', 'timestamp', { unique: false });
const peerClocks = db.createObjectStore('peerClocks', {
keyPath: ['peer', 'docId'],
autoIncrement: false,
});
peerClocks.createIndex('peer', 'peer', { unique: false });
db.createObjectStore('blobs', {
keyPath: 'key',
autoIncrement: false,
});
db.createObjectStore('blobData', {
keyPath: 'key',
autoIncrement: false,
});
db.createObjectStore('locks', {
keyPath: 'key',
autoIncrement: false,
});
};
const initBlobSync: Migrate = db => {
const blobSync = db.createObjectStore('blobSync', {
keyPath: ['peer', 'key'],
autoIncrement: false,
});
blobSync.createIndex('peer', 'peer', { unique: false });
};
const initIndexer: Migrate = db => {
db.createObjectStore('indexerMetadata', {
keyPath: 'key',
});
const indexRecordsStore = db.createObjectStore('indexerRecords', {
autoIncrement: true,
});
indexRecordsStore.createIndex('table', 'table', {
unique: false,
});
indexRecordsStore.createIndex('id', ['table', 'id'], {
unique: true,
});
const invertedIndexStore = db.createObjectStore('invertedIndex', {
autoIncrement: true,
});
invertedIndexStore.createIndex('key', ['table', 'key'], {
unique: false,
});
invertedIndexStore.createIndex('nid', 'nid', { unique: false });
db.createObjectStore('indexerSync', {
keyPath: 'docId',
autoIncrement: false,
});
};
// END REGION
// 1. all schema changed should be put in migrations
// 2. order matters
const migrations: Migrate[] = [init, initBlobSync, initIndexer];
export const migrator = {
version: migrations.length,
migrate,
};

View File

@@ -0,0 +1,78 @@
import { share } from '../../../connection';
import { BlobStorageBase, type ListedBlobRecord } from '../../../storage';
import { BlobIDBConnection, type BlobIDBConnectionOptions } from './db';
/**
* @deprecated readonly
*/
export class IndexedDBV1BlobStorage extends BlobStorageBase {
static readonly identifier = 'IndexedDBV1BlobStorage';
override readonly isReadonly = true;
constructor(private readonly options: BlobIDBConnectionOptions) {
super();
}
readonly connection = share(new BlobIDBConnection(this.options));
get db() {
return this.connection.inner;
}
override async get(key: string) {
if (!this.db) {
return null;
}
const trx = this.db.transaction('blob', 'readonly');
const blob = await trx.store.get(key);
if (!blob) {
return null;
}
return {
key,
mime: '',
createdAt: new Date(),
data: new Uint8Array(blob),
};
}
override async delete(key: string, permanently: boolean) {
if (!this.db) {
return;
}
if (permanently) {
const trx = this.db.transaction('blob', 'readwrite');
await trx.store.delete(key);
}
}
override async list() {
if (!this.db) {
return [];
}
const trx = this.db.transaction('blob', 'readonly');
const it = trx.store.iterate();
const records: ListedBlobRecord[] = [];
for await (const { key, value } of it) {
records.push({
key,
mime: '',
size: value.byteLength,
createdAt: new Date(),
});
}
return records;
}
override async set() {
// no more writes
}
override async release() {
// no more writes
}
}

View File

@@ -0,0 +1,77 @@
import { type DBSchema, type IDBPDatabase, openDB } from 'idb';
import { AutoReconnectConnection } from '../../../connection';
export interface DocDBSchema extends DBSchema {
workspace: {
key: string;
value: {
id: string;
updates: {
timestamp: number;
update: Uint8Array;
}[];
};
};
}
export class DocIDBConnection extends AutoReconnectConnection<IDBPDatabase<DocDBSchema> | null> {
override get shareId() {
return 'idb(old):affine-local';
}
override async doConnect() {
const dbs = await indexedDB.databases();
if (dbs.some(d => d.name === 'affine-local')) {
return openDB<DocDBSchema>('affine-local', 1, {
upgrade: db => {
db.createObjectStore('workspace', { keyPath: 'id' });
},
});
} else {
return null;
}
}
override doDisconnect(conn: IDBPDatabase<DocDBSchema> | null) {
conn?.close();
}
}
export interface BlobDBSchema extends DBSchema {
blob: {
key: string;
value: ArrayBuffer;
};
}
export interface BlobIDBConnectionOptions {
id: string;
}
export class BlobIDBConnection extends AutoReconnectConnection<IDBPDatabase<BlobDBSchema> | null> {
constructor(private readonly options: BlobIDBConnectionOptions) {
super();
}
override get shareId() {
return `idb(old-blob):${this.options.id}`;
}
override async doConnect() {
const dbs = await indexedDB.databases();
if (dbs.some(d => d.name === `${this.options.id}_blob`)) {
return openDB<BlobDBSchema>(`${this.options.id}_blob`, 1, {
upgrade: db => {
db.createObjectStore('blob');
},
});
} else {
return null;
}
}
override doDisconnect(conn: IDBPDatabase<BlobDBSchema> | null) {
conn?.close();
}
}

View File

@@ -0,0 +1,201 @@
import { once } from 'lodash-es';
import {
applyUpdate,
type Array as YArray,
Doc as YDoc,
type Map as YMap,
} from 'yjs';
import { share } from '../../../connection';
import {
type DocClocks,
type DocRecord,
DocStorageBase,
type DocStorageOptions,
type DocUpdate,
} from '../../../storage';
import { getIdConverter } from '../../../utils/id-converter';
import { DocIDBConnection } from './db';
/**
* We use a fixed timestamp in v1 because the v1 should never be changed.
* This date is chosen because it is large enough to overwrite some previous error data.
* In our sync storage, only a larger timestamp can overwrite smaller one.
*/
const CONST_TIMESTAMP = new Date(1893456000000);
/**
* @deprecated readonly
*/
export class IndexedDBV1DocStorage extends DocStorageBase {
static readonly identifier = 'IndexedDBV1DocStorage';
readonly connection = share(new DocIDBConnection());
constructor(opts: DocStorageOptions) {
super({
...opts,
readonlyMode: true,
});
}
get db() {
return this.connection.inner;
}
get name() {
return 'idb(old)';
}
override async getDoc(docId: string) {
if (!this.db) {
return null;
}
const oldId = (await this.getIdConverter()).newIdToOldId(docId);
const rawDoc = await this.rawGetDoc(oldId);
return rawDoc
? {
docId: rawDoc.docId,
bin: rawDoc.bin,
timestamp: CONST_TIMESTAMP,
}
: null;
}
protected override async getDocSnapshot() {
return null;
}
override async pushDocUpdate(update: DocUpdate) {
// no more writes to old db
return { docId: update.docId, timestamp: CONST_TIMESTAMP };
}
override async deleteDoc(docId: string) {
if (!this.db) {
return;
}
const oldId = (await this.getIdConverter()).newIdToOldId(docId);
const trx = this.db.transaction('workspace', 'readwrite');
await trx.store.delete(oldId);
}
override async getDocTimestamps(): Promise<DocClocks> {
if (!this.db) {
return {};
}
const idConverter = await this.getIdConverter();
const oldIds: string[] = [this.spaceId];
const rootDocBuffer = await this.rawGetDoc(this.spaceId);
if (rootDocBuffer) {
const ydoc = new YDoc({
guid: this.spaceId,
});
applyUpdate(ydoc, rootDocBuffer.bin);
// get all ids from rootDoc.meta.pages.[*].id, trust this id as normalized id
const normalizedDocIds = (
(ydoc.getMap('meta') as YMap<any> | undefined)?.get('pages') as
| YArray<YMap<any>>
| undefined
)
?.map(i => i.get('id') as string)
.filter(i => !!i);
const spaces = ydoc.getMap('spaces') as YMap<any> | undefined;
for (const pageId of normalizedDocIds ?? []) {
const subdoc = spaces?.get(pageId);
if (subdoc && subdoc instanceof YDoc) {
oldIds.push(subdoc.guid);
}
}
}
const trx = this.db.transaction('workspace', 'readonly');
const allKeys = await trx.store.getAllKeys();
oldIds.push(...allKeys.filter(k => k.startsWith(`db$${this.spaceId}$`)));
oldIds.push(
...allKeys.filter(k =>
k.match(new RegExp(`^userdata\\$[\\w-]+\\$${this.spaceId}$`))
)
);
return Object.fromEntries(
oldIds.map(id => [idConverter.oldIdToNewId(id), CONST_TIMESTAMP])
);
}
override async getDocTimestamp(_docId: string) {
return null;
}
protected override async setDocSnapshot(): Promise<boolean> {
return false;
}
protected override async getDocUpdates(): Promise<DocRecord[]> {
return [];
}
protected override async markUpdatesMerged(): Promise<number> {
return 0;
}
private async rawGetDoc(id: string) {
if (!this.db) {
return null;
}
const trx = this.db.transaction('workspace', 'readonly');
const record = await trx.store.get(id);
if (!record?.updates.length) {
return null;
}
if (record.updates.length === 1) {
return {
docId: id,
bin: record.updates[0].update,
timestamp: new Date(record.updates[0].timestamp),
};
}
return {
docId: id,
bin: await this.mergeUpdates(record.updates.map(update => update.update)),
timestamp: new Date(record.updates.at(-1)?.timestamp ?? Date.now()),
};
}
private readonly getIdConverter = once(async () => {
const idConverter = getIdConverter(
{
getDocBuffer: async id => {
if (!this.db) {
return null;
}
const trx = this.db.transaction('workspace', 'readonly');
const record = await trx.store.get(id);
if (!record?.updates.length) {
return null;
}
if (record.updates.length === 1) {
return record.updates[0].update;
}
return await this.mergeUpdates(
record.updates.map(update => update.update)
);
},
},
this.spaceId
);
return await idConverter;
});
}

View File

@@ -0,0 +1,11 @@
import type { StorageConstructor } from '../..';
import { IndexedDBV1BlobStorage } from './blob';
import { IndexedDBV1DocStorage } from './doc';
export * from './blob';
export * from './doc';
export const idbV1Storages = [
IndexedDBV1DocStorage,
IndexedDBV1BlobStorage,
] satisfies StorageConstructor[];

View File

@@ -0,0 +1,27 @@
import type { Storage } from '../storage';
import type { broadcastChannelStorages } from './broadcast-channel';
import type { cloudStorages } from './cloud';
import type { idbStorages } from './idb';
import type { idbV1Storages } from './idb/v1';
import type { sqliteStorages } from './sqlite';
import type { sqliteV1Storages } from './sqlite/v1';
export type StorageConstructor = {
new (...args: any[]): Storage;
readonly identifier: string;
};
type Storages =
| typeof cloudStorages
| typeof idbV1Storages
| typeof idbStorages
| typeof sqliteStorages
| typeof sqliteV1Storages
| typeof broadcastChannelStorages;
// oxlint-disable-next-line no-redeclare
export type AvailableStorageImplementations = {
[key in Storages[number]['identifier']]: Storages[number] & {
identifier: key;
};
};

View File

@@ -0,0 +1,32 @@
import { share } from '../../connection';
import { BlobSyncStorageBase } from '../../storage';
import { NativeDBConnection, type SqliteNativeDBOptions } from './db';
export class SqliteBlobSyncStorage extends BlobSyncStorageBase {
static readonly identifier = 'SqliteBlobSyncStorage';
override connection = share(new NativeDBConnection(this.options));
constructor(private readonly options: SqliteNativeDBOptions) {
super();
}
get db() {
return this.connection.apis;
}
override async setBlobUploadedAt(
peer: string,
blobId: string,
uploadedAt: Date | null
): Promise<void> {
await this.db.setBlobUploadedAt(peer, blobId, uploadedAt);
}
override async getBlobUploadedAt(
peer: string,
blobId: string
): Promise<Date | null> {
return this.db.getBlobUploadedAt(peer, blobId);
}
}

View File

@@ -0,0 +1,38 @@
import { share } from '../../connection';
import { type BlobRecord, BlobStorageBase } from '../../storage';
import { NativeDBConnection, type SqliteNativeDBOptions } from './db';
export class SqliteBlobStorage extends BlobStorageBase {
static readonly identifier = 'SqliteBlobStorage';
override readonly isReadonly = false;
override connection = share(new NativeDBConnection(this.options));
constructor(private readonly options: SqliteNativeDBOptions) {
super();
}
get db() {
return this.connection.apis;
}
override async get(key: string) {
return this.db.getBlob(key);
}
override async set(blob: BlobRecord) {
await this.db.setBlob(blob);
}
override async delete(key: string, permanently: boolean) {
await this.db.deleteBlob(key, permanently);
}
override async release() {
await this.db.releaseBlobs();
}
override async list() {
return this.db.listBlobs();
}
}

View File

@@ -0,0 +1,244 @@
import { AutoReconnectConnection } from '../../connection';
import type {
BlobRecord,
CrawlResult,
DocClock,
DocIndexedClock,
DocRecord,
ListedBlobRecord,
} from '../../storage';
import { type SpaceType, universalId } from '../../utils/universal-id';
export interface SqliteNativeDBOptions {
readonly flavour: string;
readonly type: SpaceType;
readonly id: string;
}
export interface NativeIndexField {
field: string;
values: string[];
}
export interface NativeIndexQuery {
kind: 'match' | 'exists' | 'all' | 'boolean' | 'boost';
field?: string;
value?: string;
occur?: 'must' | 'should' | 'must_not';
clauses?: NativeIndexQuery[];
boost?: number;
}
export interface NativeIndexSearchOptions {
limit: number;
offset: number;
fields: string[];
highlights: string[];
}
export interface NativeIndexHit {
id: string;
score: number;
fields: NativeIndexField[];
highlights: {
field: string;
values: { valueIndex: number; spans: { start: number; end: number }[] }[];
}[];
}
export interface NativeIndexSearchResult {
total: number;
hits: NativeIndexHit[];
}
export interface 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 | null) => Promise<DocClock[]>;
getDocClock: (id: string, docId: string) => Promise<DocClock | null>;
getDocIndexedClock: (
id: string,
docId: string
) => Promise<DocIndexedClock | null>;
setDocIndexedClock: (
id: string,
docId: string,
indexedClock: Date,
indexerVersion: number
) => Promise<void>;
setDocIndexedClocks: (id: string, clocks: DocIndexedClock[]) => Promise<void>;
clearDocIndexedClock: (id: string, docId: string) => Promise<void>;
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 | null>;
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 | null>;
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 | null>;
setPeerPushedClock: (
id: string,
peer: string,
docId: string,
clock: Date
) => Promise<void>;
clearClocks: (id: string) => Promise<void>;
setBlobUploadedAt: (
id: string,
peer: string,
blobId: string,
uploadedAt: Date | null
) => Promise<void>;
getBlobUploadedAt: (
id: string,
peer: string,
blobId: string
) => Promise<Date | null>;
crawlDocData: (id: string, docId: string) => Promise<CrawlResult>;
indexUpsert: (
id: string,
table: string,
document: { id: string; fields: NativeIndexField[] }
) => Promise<void>;
indexDelete: (id: string, table: string, docId: string) => Promise<void>;
indexSearch: (
id: string,
table: string,
query: NativeIndexQuery,
options: NativeIndexSearchOptions
) => Promise<NativeIndexSearchResult>;
indexAggregate: (
id: string,
table: string,
query: NativeIndexQuery,
field: string,
limit: number,
offset: number,
hits?: NativeIndexSearchOptions
) => Promise<{
total: number;
buckets: {
key: string;
count: number;
score: number;
hits: NativeIndexHit[];
}[];
}>;
indexDeleteByQuery: (
id: string,
table: string,
query: NativeIndexQuery
) => Promise<number>;
indexFlush: (id: string) => Promise<void>;
indexVersion: () => Promise<number>;
}
type NativeDBApisWrapper = NativeDBApis extends infer APIs
? {
[K in keyof APIs]: APIs[K] extends (...args: any[]) => any
? Parameters<APIs[K]> extends [string, ...infer Rest]
? (...args: Rest) => ReturnType<APIs[K]>
: (...args: Parameters<APIs[K]>) => ReturnType<APIs[K]>
: never;
}
: never;
let apis: NativeDBApis | null = null;
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 native context.');
}
this.apis = this.warpApis(apis);
}
override get shareId(): string {
return `sqlite:${this.flavour}:${this.type}:${this.id}`;
}
warpApis(originalApis: NativeDBApis): NativeDBApisWrapper {
const id = universalId({
peer: this.flavour,
type: this.type,
id: this.id,
});
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 NativeDBApisWrapper;
}
override async doConnect() {
await this.apis.connect();
}
override doDisconnect() {
this.apis.disconnect().catch(err => {
console.error('NativeDBConnection close failed', err);
});
}
}

View File

@@ -0,0 +1,69 @@
import { share } from '../../connection';
import { type DocClock, DocSyncStorageBase } from '../../storage';
import { NativeDBConnection, type SqliteNativeDBOptions } from './db';
export class SqliteDocSyncStorage extends DocSyncStorageBase {
static readonly identifier = 'SqliteDocSyncStorage';
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)
.then(clocks =>
Object.fromEntries(clocks.map(clock => [clock.docId, clock.timestamp]))
);
}
override async getPeerRemoteClock(peer: string, docId: string) {
return this.db.getPeerRemoteClock(peer, docId);
}
override async setPeerRemoteClock(peer: string, clock: DocClock) {
await this.db.setPeerRemoteClock(peer, clock.docId, clock.timestamp);
}
override async getPeerPulledRemoteClocks(peer: string) {
return this.db
.getPeerPulledRemoteClocks(peer)
.then(clocks =>
Object.fromEntries(clocks.map(clock => [clock.docId, clock.timestamp]))
);
}
override async getPeerPulledRemoteClock(peer: string, docId: string) {
return this.db.getPeerPulledRemoteClock(peer, docId);
}
override async setPeerPulledRemoteClock(peer: string, clock: DocClock) {
await this.db.setPeerPulledRemoteClock(peer, clock.docId, clock.timestamp);
}
override async getPeerPushedClocks(peer: string) {
return this.db
.getPeerPushedClocks(peer)
.then(clocks =>
Object.fromEntries(clocks.map(clock => [clock.docId, clock.timestamp]))
);
}
override async getPeerPushedClock(peer: string, docId: string) {
return this.db.getPeerPushedClock(peer, docId);
}
override async setPeerPushedClock(peer: string, clock: DocClock) {
await this.db.setPeerPushedClock(peer, clock.docId, clock.timestamp);
}
override async clearClocks() {
await this.db.clearClocks();
}
}

View File

@@ -0,0 +1,207 @@
import { share } from '../../connection';
import {
type BlockInfo,
type CrawlResult,
type DocClocks,
type DocRecord,
DocStorageBase,
type DocUpdate,
} from '../../storage';
import { NativeDBConnection, type SqliteNativeDBOptions } from './db';
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 pushDocUpdate(update: DocUpdate, origin?: string) {
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) {
await this.db.deleteDoc(docId);
}
override async getDocTimestamps(after?: Date) {
const clocks = await this.db.getDocClocks(after);
return clocks.reduce((ret, cur) => {
ret[cur.docId] = cur.timestamp;
return ret;
}, {} as DocClocks);
}
override async getDocTimestamp(docId: string) {
return this.db.getDocClock(docId);
}
protected override async getDocSnapshot(docId: string) {
const snapshot = await this.db.getDocSnapshot(docId);
if (!snapshot) {
return null;
}
return snapshot;
}
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(docId: string) {
return this.db.getDocUpdates(docId);
}
protected override markUpdatesMerged(docId: string, updates: DocRecord[]) {
return this.db.markUpdatesMerged(
docId,
updates.map(update => update.timestamp)
);
}
override async crawlDocData(docId: string): Promise<CrawlResult | null> {
const result = await this.db.crawlDocData(docId);
return normalizeNativeCrawlResult(result);
}
}
function normalizeNativeCrawlResult(result: unknown): CrawlResult | null {
if (!isRecord(result)) {
console.warn('[nbstore] crawlDocData returned non-object result');
return null;
}
if (
typeof result.title !== 'string' ||
typeof result.summary !== 'string' ||
!Array.isArray(result.blocks)
) {
console.warn('[nbstore] crawlDocData result missing basic fields');
return null;
}
const { title, summary } = result as { title: string; summary: string };
const rawBlocks = result.blocks as unknown[];
const blocks: BlockInfo[] = [];
for (const block of rawBlocks) {
const normalized = normalizeBlock(block);
if (normalized) {
blocks.push(normalized);
}
}
if (blocks.length === 0) {
console.warn('[nbstore] crawlDocData has no valid blocks');
return null;
}
return {
blocks,
title,
summary,
};
}
function normalizeBlock(block: unknown): BlockInfo | null {
if (!isRecord(block)) {
return null;
}
const blockId = readStringField(block, 'blockId');
const flavour = readStringField(block, 'flavour');
if (!blockId || !flavour) {
return null;
}
return {
blockId,
flavour,
content: readStringArrayField(block, 'content'),
blob: readStringArrayField(block, 'blob'),
refDocId: readStringArrayField(block, 'refDocId'),
refInfo: readStringArrayField(block, 'refInfo'),
parentFlavour: readStringField(block, 'parentFlavour'),
parentBlockId: readStringField(block, 'parentBlockId'),
additional: safeAdditionalField(block),
};
}
function readStringField(
target: Record<string, unknown>,
key: string
): string | undefined {
const value = readField(target, key);
return typeof value === 'string' && value ? value : undefined;
}
function readStringArrayField(
target: Record<string, unknown>,
key: string
): string[] | undefined {
const value = readField(target, key);
if (Array.isArray(value)) {
const filtered = value.filter(
(item): item is string => typeof item === 'string' && item.length > 0
);
return filtered.length ? filtered : undefined;
}
if (typeof value === 'string' && value.length > 0) {
return [value];
}
return undefined;
}
function safeAdditionalField(
target: Record<string, unknown>
): string | undefined {
const value = readField(target, 'additional');
if (typeof value !== 'string' || value.length === 0) {
return undefined;
}
try {
const parsed = JSON.parse(value);
return JSON.stringify(parsed);
} catch {
console.warn(
'[nbstore] ignore invalid additional payload in crawlDocData block'
);
return undefined;
}
}
function readField(target: Record<string, unknown>, key: string) {
return target[key] ?? target[toSnakeCase(key)];
}
function toSnakeCase(key: string) {
return key.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}

View File

@@ -0,0 +1,32 @@
import type { StorageConstructor } from '..';
import { SqliteBlobStorage } from './blob';
import { SqliteBlobSyncStorage } from './blob-sync';
import { SqliteDocStorage } from './doc';
import { SqliteDocSyncStorage } from './doc-sync';
import { SqliteIndexerStorage } from './indexer';
import { SqliteIndexerSyncStorage } from './indexer-sync';
export * from './blob';
export * from './blob-sync';
export {
bindNativeDBApis,
type NativeDBApis,
type NativeIndexField,
type NativeIndexHit,
type NativeIndexQuery,
type NativeIndexSearchOptions,
type NativeIndexSearchResult,
} from './db';
export * from './doc';
export * from './doc-sync';
export * from './indexer';
export * from './indexer-sync';
export const sqliteStorages = [
SqliteDocStorage,
SqliteBlobStorage,
SqliteDocSyncStorage,
SqliteBlobSyncStorage,
SqliteIndexerStorage,
SqliteIndexerSyncStorage,
] satisfies StorageConstructor[];

View File

@@ -0,0 +1,43 @@
import { share } from '../../connection';
import {
type DocIndexedClock,
IndexerSyncStorageBase,
} from '../../storage/indexer-sync';
import { NativeDBConnection, type SqliteNativeDBOptions } from './db';
export class SqliteIndexerSyncStorage extends IndexerSyncStorageBase {
static readonly identifier = 'SqliteIndexerSyncStorage';
override readonly commitsIndexAtomically = true;
override connection = share(new NativeDBConnection(this.options));
constructor(private readonly options: SqliteNativeDBOptions) {
super();
}
private get db() {
return this.connection.apis;
}
override async getDocIndexedClock(
docId: string
): Promise<DocIndexedClock | null> {
return this.db.getDocIndexedClock(docId);
}
override async setDocIndexedClock(clock: DocIndexedClock): Promise<void> {
await this.db.setDocIndexedClock(
clock.docId,
clock.timestamp,
clock.indexerVersion
);
}
override async setDocIndexedClocks(clocks: DocIndexedClock[]): Promise<void> {
await this.db.setDocIndexedClocks(clocks);
}
override async clearDocIndexedClock(docId: string): Promise<void> {
await this.db.clearDocIndexedClock(docId);
}
}

View File

@@ -0,0 +1,255 @@
import type { Observable } from 'rxjs';
import { merge, of, Subject } from 'rxjs';
import { filter, throttleTime } from 'rxjs/operators';
import { share } from '../../../connection';
import type {
AggregateOptions,
AggregateResult,
IndexerDocument,
IndexerSchema,
Query,
SearchOptions,
SearchResult,
} from '../../../storage';
import { IndexerStorageBase } from '../../../storage';
import { fromPromise } from '../../../utils/from-promise';
import { backoffRetry, exhaustMapWithTrailing } from '../../idb/indexer/utils';
import {
NativeDBConnection,
type NativeIndexQuery,
type NativeIndexSearchOptions,
type SqliteNativeDBOptions,
} from '../db';
import { createNode } from './node-builder';
const SQLITE_INDEXER_VERSION_OFFSET = 1;
const NATIVE_INDEXER_MAX_LIMIT = 0xffffffff;
export class SqliteIndexerStorage extends IndexerStorageBase {
static readonly identifier = 'SqliteIndexerStorage';
override readonly recommendRefreshInterval = 30 * 1000; // 5 seconds
readonly connection: NativeDBConnection;
readonly isReadonly = false;
private readonly tableUpdate$ = new Subject<string>();
constructor(options: SqliteNativeDBOptions) {
super();
this.connection = share(new NativeDBConnection(options));
}
private watchTableUpdated(table: string) {
return this.tableUpdate$.asObservable().pipe(filter(t => t === table));
}
async search<T extends keyof IndexerSchema, const O extends SearchOptions<T>>(
table: T,
query: Query<T>,
options?: O
): Promise<SearchResult<T, O>> {
const limit = options?.pagination?.limit ?? 10;
const skip = options?.pagination?.skip ?? 0;
const result = await this.connection.apis.indexSearch(
String(table),
toNativeQuery(query),
toNativeOptions(options, limit, skip)
);
const nodes = result.hits.map(hit => createNode(hit, options ?? {}));
return {
pagination: {
count: result.total,
limit,
skip,
hasMore: result.total > skip + limit,
},
nodes,
};
}
async aggregate<
T extends keyof IndexerSchema,
const O extends AggregateOptions<T>,
>(
table: T,
query: Query<T>,
field: keyof IndexerSchema[T],
options?: O
): Promise<AggregateResult<T, O>> {
const limit = options?.pagination?.limit ?? 10;
const skip = options?.pagination?.skip ?? 0;
const hitLimit = options?.hits?.pagination?.limit ?? 3;
const hitSkip = options?.hits?.pagination?.skip ?? 0;
const result = await this.connection.apis.indexAggregate(
String(table),
toNativeQuery(query),
String(field),
toNativeLimit(limit),
skip,
options?.hits
? toNativeOptions(options.hits, hitLimit, hitSkip)
: undefined
);
const hitsOptions = options?.hits;
const buckets = result.buckets.map(bucket => ({
key: bucket.key,
count: bucket.count,
score: bucket.score,
...(hitsOptions
? {
hits: {
pagination: {
count: bucket.count,
limit: hitLimit,
skip: hitSkip,
hasMore: bucket.count > hitSkip + hitLimit,
},
nodes: bucket.hits.map(hit => createNode(hit, hitsOptions)),
},
}
: {}),
}));
return {
pagination: {
count: result.total,
limit,
skip,
hasMore: result.total > skip + limit,
},
buckets,
} as AggregateResult<T, O>;
}
search$<T extends keyof IndexerSchema, const O extends SearchOptions<T>>(
table: T,
query: Query<T>,
options?: O
): Observable<SearchResult<T, O>> {
return merge(of(1), this.watchTableUpdated(table)).pipe(
throttleTime(3000, undefined, { leading: true, trailing: true }),
exhaustMapWithTrailing(() => {
return fromPromise(async () => {
return await this.search(table, query, options);
}).pipe(backoffRetry());
})
);
}
aggregate$<
T extends keyof IndexerSchema,
const O extends AggregateOptions<T>,
>(
table: T,
query: Query<T>,
field: keyof IndexerSchema[T],
options?: O
): Observable<AggregateResult<T, O>> {
return merge(of(1), this.watchTableUpdated(table)).pipe(
throttleTime(3000, undefined, { leading: true, trailing: true }),
exhaustMapWithTrailing(() => {
return fromPromise(async () => {
return await this.aggregate(table, query, field, options);
}).pipe(backoffRetry());
})
);
}
async deleteByQuery<T extends keyof IndexerSchema>(
table: T,
query: Query<T>
): Promise<void> {
await this.connection.apis.indexDeleteByQuery(
String(table),
toNativeQuery(query)
);
}
async insert<T extends keyof IndexerSchema>(
table: T,
document: IndexerDocument<T>
): Promise<void> {
await this.connection.apis.indexUpsert(String(table), {
id: document.id,
fields: [...document.fields].map(([field, values]) => ({
field: String(field),
values,
})),
});
this.tableUpdate$.next(table);
}
async delete<T extends keyof IndexerSchema>(
table: T,
id: string
): Promise<void> {
await this.connection.apis.indexDelete(String(table), id);
this.tableUpdate$.next(table);
}
async update<T extends keyof IndexerSchema>(
table: T,
document: IndexerDocument<T>
): Promise<void> {
// Update is essentially insert (overwrite)
return this.insert(table, document);
}
async refresh<T extends keyof IndexerSchema>(_table: T): Promise<void> {
// No-op for memory index
}
async refreshIfNeed(): Promise<void> {
await this.connection.apis.indexFlush();
}
async indexVersion(): Promise<number> {
return (
(await this.connection.apis.indexVersion()) +
SQLITE_INDEXER_VERSION_OFFSET
);
}
}
function toNativeQuery(query: Query<any>): NativeIndexQuery {
switch (query.type) {
case 'match':
return { kind: 'match', field: String(query.field), value: query.match };
case 'exists':
return { kind: 'exists', field: String(query.field) };
case 'all':
return { kind: 'all' };
case 'boolean':
return {
kind: 'boolean',
occur: query.occur,
clauses: query.queries.map(toNativeQuery),
};
case 'boost':
return {
kind: 'boost',
boost: query.boost,
clauses: [toNativeQuery(query.query)],
};
}
}
function toNativeOptions(
options: SearchOptions<any> | undefined,
limit: number,
offset: number
): NativeIndexSearchOptions {
const highlights = options?.highlights?.map(item => String(item.field)) ?? [];
return {
limit: toNativeLimit(limit),
offset,
fields: [
...new Set([...(options?.fields?.map(String) ?? []), ...highlights]),
],
highlights,
};
}
function toNativeLimit(limit: number) {
return limit === Infinity ? NATIVE_INDEXER_MAX_LIMIT : limit;
}

View File

@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest';
import type { NativeIndexHit } from '../db';
import { createNode } from './node-builder';
function hit(fields: NativeIndexHit['fields']): NativeIndexHit {
return { id: 'doc-id', score: 1, fields, highlights: [] };
}
describe('sqlite indexer native result mapping', () => {
it.each([
['string', ['summary'], 'summary'],
['array', ['one', 'two'], ['one', 'two']],
['missing', [], ''],
])('maps %s stored values', (_, values, expected) => {
const node = createNode(hit([{ field: 'title', values }]), {
fields: ['title'],
});
expect(node.fields.title).toEqual(expected);
});
it('formats native highlight spans', () => {
const native = hit([{ field: 'title', values: ['hello search'] }]);
native.highlights = [
{
field: 'title',
values: [{ valueIndex: 0, spans: [{ start: 6, end: 12 }] }],
},
];
const node = createNode(native, {
highlights: [{ field: 'title', before: '<b>', end: '</b>' }],
});
expect(node.highlights.title).toEqual(['hello <b>search</b>']);
});
});

View File

@@ -0,0 +1,44 @@
import { type SearchOptions } from '../../../storage';
import { highlighter } from '../../idb/indexer/highlighter';
import type { NativeIndexHit } from '../db';
export function createNode(hit: NativeIndexHit, options: SearchOptions<any>) {
const node: any = { id: hit.id, score: hit.score };
const fields = new Map(hit.fields.map(field => [field.field, field.values]));
if (options.fields) {
node.fields = Object.fromEntries(
options.fields.map(field => {
const values = fields.get(String(field)) ?? [];
return [String(field), values.length > 1 ? values : (values[0] ?? '')];
})
);
}
if (options.highlights) {
const highlights = new Map(
hit.highlights.map(item => [item.field, item.values])
);
node.highlights = Object.fromEntries(
options.highlights.map(option => {
const field = String(option.field);
const source = fields.get(field) ?? [];
const fragments = (highlights.get(field) ?? []).flatMap(value => {
const text = source[value.valueIndex];
if (!text) return [];
const fragment = highlighter(
text,
option.before,
option.end,
value.spans.map(span => [span.start, span.end]),
{ maxPrefix: 20, maxLength: 50 }
);
return fragment ? [fragment] : [];
});
return [field, fragments];
})
);
}
return node;
}

View File

@@ -0,0 +1,65 @@
import { DummyConnection } from '../../../connection';
import { BlobStorageBase } from '../../../storage';
import type { SpaceType } from '../../../utils/universal-id';
import { apis } from './db';
/**
* @deprecated readonly
*/
export class SqliteV1BlobStorage extends BlobStorageBase {
static identifier = 'SqliteV1BlobStorage';
override connection = new DummyConnection();
override readonly isReadonly = true;
constructor(private readonly options: { type: SpaceType; id: string }) {
super();
}
private get db() {
if (!apis) {
throw new Error('Not in electron context.');
}
return apis;
}
override async get(key: string) {
const data: Uint8Array | null = await this.db.getBlob(
this.options.type,
this.options.id,
key
);
if (!data) {
return null;
}
return {
key,
data,
mime: '',
createdAt: new Date(),
};
}
override async list() {
const keys = await this.db.getBlobKeys(this.options.type, this.options.id);
return keys.map(key => ({
key,
mime: '',
size: 0,
createdAt: new Date(),
}));
}
override async delete() {
// no more deletes
}
override async set() {
// no more writes
}
override async release() {
// no more writes
}
}

View File

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

View File

@@ -0,0 +1,144 @@
import { DummyConnection } from '../../../connection';
import {
type DocRecord,
DocStorageBase,
type DocUpdate,
} from '../../../storage';
import { getIdConverter, type IdConverter } from '../../../utils/id-converter';
import { isEmptyUpdate } from '../../../utils/is-empty-update';
import type { SpaceType } from '../../../utils/universal-id';
import { apis } from './db';
/**
* We use a fixed timestamp in v1 because the v1 should never be changed.
* This date is chosen because it is large enough to overwrite some previous error data.
* In our sync storage, only a larger timestamp can overwrite smaller one.
*/
const CONST_TIMESTAMP = new Date(1893456000000);
/**
* @deprecated readonly
*/
export class SqliteV1DocStorage extends DocStorageBase<{
type: SpaceType;
id: string;
}> {
static identifier = 'SqliteV1DocStorage';
cachedIdConverter: Promise<IdConverter> | null = null;
override connection = new DummyConnection();
constructor(options: { type: SpaceType; id: string }) {
super({ ...options, readonlyMode: true });
}
private get db() {
if (!apis) {
throw new Error('Not in electron context.');
}
return apis;
}
override async pushDocUpdate(update: DocUpdate) {
// no more writes
return { docId: update.docId, timestamp: CONST_TIMESTAMP };
}
override async getDoc(docId: string) {
const idConverter = await this.getIdConverter();
const bin = await this.db.getDocAsUpdates(
this.options.type,
this.options.id,
idConverter.newIdToOldId(docId)
);
if (isEmptyUpdate(bin)) {
return null;
}
return {
docId,
bin,
timestamp: CONST_TIMESTAMP,
};
}
override async getDocTimestamps() {
const timestamps = await this.db.getDocTimestamps(
this.options.type,
this.options.id
);
if (!timestamps) {
return {};
}
const idConverter = await this.getIdConverter();
return timestamps.reduce(
(ret, { docId }) => {
ret[idConverter.oldIdToNewId(docId ?? this.options.id)] =
CONST_TIMESTAMP;
return ret;
},
{} as Record<string, Date>
);
}
override async deleteDoc() {
return;
}
protected override async getDocSnapshot() {
return null;
}
override async getDocTimestamp() {
return null;
}
protected override async setDocSnapshot(): Promise<boolean> {
return false;
}
protected override async getDocUpdates(): Promise<DocRecord[]> {
return [];
}
protected override async markUpdatesMerged(): Promise<number> {
return 0;
}
private async getIdConverter() {
if (this.cachedIdConverter) {
return await this.cachedIdConverter;
}
this.cachedIdConverter = getIdConverter(
{
getDocBuffer: async id => {
if (!this.db) {
return null;
}
const updates = await this.db.getDocAsUpdates(
this.options.type,
this.options.id,
id
);
if (isEmptyUpdate(updates)) {
return null;
}
if (!updates) {
return null;
}
return updates;
},
},
this.spaceId
);
return await this.cachedIdConverter;
}
}

View File

@@ -0,0 +1,12 @@
import type { StorageConstructor } from '../..';
import { SqliteV1BlobStorage } from './blob';
import { SqliteV1DocStorage } from './doc';
export * from './blob';
export { bindNativeDBV1Apis } from './db';
export * from './doc';
export const sqliteV1Storages = [
SqliteV1DocStorage,
SqliteV1BlobStorage,
] satisfies StorageConstructor[];