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
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:
152
packages/common/nbstore/src/impls/broadcast-channel/awareness.ts
Normal file
152
packages/common/nbstore/src/impls/broadcast-channel/awareness.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { StorageConstructor } from '..';
|
||||
import { BroadcastChannelAwarenessStorage } from './awareness';
|
||||
|
||||
export const broadcastChannelStorages = [
|
||||
BroadcastChannelAwarenessStorage,
|
||||
] satisfies StorageConstructor[];
|
||||
231
packages/common/nbstore/src/impls/cloud/awareness.ts
Normal file
231
packages/common/nbstore/src/impls/cloud/awareness.ts
Normal 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();
|
||||
};
|
||||
}
|
||||
}
|
||||
104
packages/common/nbstore/src/impls/cloud/blob-source-registry.ts
Normal file
104
packages/common/nbstore/src/impls/cloud/blob-source-registry.ts
Normal 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)}`;
|
||||
}
|
||||
}
|
||||
325
packages/common/nbstore/src/impls/cloud/blob-writer.ts
Normal file
325
packages/common/nbstore/src/impls/cloud/blob-writer.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
358
packages/common/nbstore/src/impls/cloud/blob.ts
Normal file
358
packages/common/nbstore/src/impls/cloud/blob.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
102
packages/common/nbstore/src/impls/cloud/doc-static.ts
Normal file
102
packages/common/nbstore/src/impls/cloud/doc-static.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
388
packages/common/nbstore/src/impls/cloud/doc.ts
Normal file
388
packages/common/nbstore/src/impls/cloud/doc.ts
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
98
packages/common/nbstore/src/impls/cloud/http.ts
Normal file
98
packages/common/nbstore/src/impls/cloud/http.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
21
packages/common/nbstore/src/impls/cloud/index.ts
Normal file
21
packages/common/nbstore/src/impls/cloud/index.ts
Normal 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[];
|
||||
150
packages/common/nbstore/src/impls/cloud/indexer.ts
Normal file
150
packages/common/nbstore/src/impls/cloud/indexer.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
329
packages/common/nbstore/src/impls/cloud/socket.ts
Normal file
329
packages/common/nbstore/src/impls/cloud/socket.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
36
packages/common/nbstore/src/impls/idb/blob-sync.ts
Normal file
36
packages/common/nbstore/src/impls/idb/blob-sync.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
96
packages/common/nbstore/src/impls/idb/blob.ts
Normal file
96
packages/common/nbstore/src/impls/idb/blob.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
59
packages/common/nbstore/src/impls/idb/db.ts
Normal file
59
packages/common/nbstore/src/impls/idb/db.ts
Normal 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
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
149
packages/common/nbstore/src/impls/idb/doc-sync.ts
Normal file
149
packages/common/nbstore/src/impls/idb/doc-sync.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
209
packages/common/nbstore/src/impls/idb/doc.ts
Normal file
209
packages/common/nbstore/src/impls/idb/doc.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
28
packages/common/nbstore/src/impls/idb/index.ts
Normal file
28
packages/common/nbstore/src/impls/idb/index.ts
Normal 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[];
|
||||
45
packages/common/nbstore/src/impls/idb/indexer-sync.ts
Normal file
45
packages/common/nbstore/src/impls/idb/indexer-sync.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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>...'
|
||||
);
|
||||
});
|
||||
@@ -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 }]);
|
||||
}
|
||||
});
|
||||
62
packages/common/nbstore/src/impls/idb/indexer/bm25.ts
Normal file
62
packages/common/nbstore/src/impls/idb/indexer/bm25.ts
Normal 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))))
|
||||
);
|
||||
};
|
||||
544
packages/common/nbstore/src/impls/idb/indexer/data-struct.ts
Normal file
544
packages/common/nbstore/src/impls/idb/indexer/data-struct.ts
Normal 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]);
|
||||
}
|
||||
}
|
||||
77
packages/common/nbstore/src/impls/idb/indexer/highlighter.ts
Normal file
77
packages/common/nbstore/src/impls/idb/indexer/highlighter.ts
Normal 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;
|
||||
}
|
||||
227
packages/common/nbstore/src/impls/idb/indexer/index.ts
Normal file
227
packages/common/nbstore/src/impls/idb/indexer/index.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
537
packages/common/nbstore/src/impls/idb/indexer/inverted-index.ts
Normal file
537
packages/common/nbstore/src/impls/idb/indexer/inverted-index.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
105
packages/common/nbstore/src/impls/idb/indexer/match.ts
Normal file
105
packages/common/nbstore/src/impls/idb/indexer/match.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
162
packages/common/nbstore/src/impls/idb/indexer/tokenizer.ts
Normal file
162
packages/common/nbstore/src/impls/idb/indexer/tokenizer.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
75
packages/common/nbstore/src/impls/idb/indexer/utils.ts
Normal file
75
packages/common/nbstore/src/impls/idb/indexer/utils.ts
Normal 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));
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
83
packages/common/nbstore/src/impls/idb/lock.ts
Normal file
83
packages/common/nbstore/src/impls/idb/lock.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
279
packages/common/nbstore/src/impls/idb/schema.ts
Normal file
279
packages/common/nbstore/src/impls/idb/schema.ts
Normal 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,
|
||||
};
|
||||
78
packages/common/nbstore/src/impls/idb/v1/blob.ts
Normal file
78
packages/common/nbstore/src/impls/idb/v1/blob.ts
Normal 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
|
||||
}
|
||||
}
|
||||
77
packages/common/nbstore/src/impls/idb/v1/db.ts
Normal file
77
packages/common/nbstore/src/impls/idb/v1/db.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
201
packages/common/nbstore/src/impls/idb/v1/doc.ts
Normal file
201
packages/common/nbstore/src/impls/idb/v1/doc.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
11
packages/common/nbstore/src/impls/idb/v1/index.ts
Normal file
11
packages/common/nbstore/src/impls/idb/v1/index.ts
Normal 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[];
|
||||
27
packages/common/nbstore/src/impls/index.ts
Normal file
27
packages/common/nbstore/src/impls/index.ts
Normal 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;
|
||||
};
|
||||
};
|
||||
32
packages/common/nbstore/src/impls/sqlite/blob-sync.ts
Normal file
32
packages/common/nbstore/src/impls/sqlite/blob-sync.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
38
packages/common/nbstore/src/impls/sqlite/blob.ts
Normal file
38
packages/common/nbstore/src/impls/sqlite/blob.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
244
packages/common/nbstore/src/impls/sqlite/db.ts
Normal file
244
packages/common/nbstore/src/impls/sqlite/db.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
69
packages/common/nbstore/src/impls/sqlite/doc-sync.ts
Normal file
69
packages/common/nbstore/src/impls/sqlite/doc-sync.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
207
packages/common/nbstore/src/impls/sqlite/doc.ts
Normal file
207
packages/common/nbstore/src/impls/sqlite/doc.ts
Normal 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;
|
||||
}
|
||||
32
packages/common/nbstore/src/impls/sqlite/index.ts
Normal file
32
packages/common/nbstore/src/impls/sqlite/index.ts
Normal 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[];
|
||||
43
packages/common/nbstore/src/impls/sqlite/indexer-sync.ts
Normal file
43
packages/common/nbstore/src/impls/sqlite/indexer-sync.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
255
packages/common/nbstore/src/impls/sqlite/indexer/index.ts
Normal file
255
packages/common/nbstore/src/impls/sqlite/indexer/index.ts
Normal 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;
|
||||
}
|
||||
@@ -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>']);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
65
packages/common/nbstore/src/impls/sqlite/v1/blob.ts
Normal file
65
packages/common/nbstore/src/impls/sqlite/v1/blob.ts
Normal 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
|
||||
}
|
||||
}
|
||||
25
packages/common/nbstore/src/impls/sqlite/v1/db.ts
Normal file
25
packages/common/nbstore/src/impls/sqlite/v1/db.ts
Normal 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;
|
||||
}
|
||||
144
packages/common/nbstore/src/impls/sqlite/v1/doc.ts
Normal file
144
packages/common/nbstore/src/impls/sqlite/v1/doc.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
12
packages/common/nbstore/src/impls/sqlite/v1/index.ts
Normal file
12
packages/common/nbstore/src/impls/sqlite/v1/index.ts
Normal 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[];
|
||||
Reference in New Issue
Block a user