@mengine/storage 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,250 @@
1
+ import { EventBus, MANUALLY_STOP, Task } from "@mengine/utils";
2
+ import { LoroDoc, VersionVector } from "loro-crdt";
3
+ //#region src/lock.ts
4
+ var SingletonLocker = class {
5
+ lockedResource = /* @__PURE__ */ new Map();
6
+ constructor() {}
7
+ async lock(domain, resource) {
8
+ const key = `${domain}:${resource}`;
9
+ let lock = this.lockedResource.get(key);
10
+ if (!lock) {
11
+ lock = new Lock();
12
+ this.lockedResource.set(key, lock);
13
+ }
14
+ await lock.acquire();
15
+ return lock;
16
+ }
17
+ };
18
+ var Lock = class {
19
+ inner = Promise.resolve();
20
+ release = () => {};
21
+ async acquire() {
22
+ let release = null;
23
+ const nextLock = new Promise((resolve) => {
24
+ release = resolve;
25
+ });
26
+ await this.inner;
27
+ this.inner = nextLock;
28
+ this.release = release;
29
+ }
30
+ [Symbol.asyncDispose]() {
31
+ this.release();
32
+ return Promise.resolve();
33
+ }
34
+ };
35
+ //#endregion
36
+ //#region src/utils.ts
37
+ function mergeLoroUpdates(updates) {
38
+ const doc = new LoroDoc();
39
+ for (const update of updates) doc.import(update);
40
+ return doc.export({ mode: "snapshot" });
41
+ }
42
+ function diffLoroUpdate(snapshot, knownVersion) {
43
+ const doc = new LoroDoc();
44
+ doc.import(snapshot);
45
+ return {
46
+ missing: doc.export({
47
+ mode: "update",
48
+ from: knownVersion ? VersionVector.decode(knownVersion) : void 0
49
+ }),
50
+ version: doc.version().encode()
51
+ };
52
+ }
53
+ //#endregion
54
+ //#region src/base.ts
55
+ var BaseDocStorage = class {
56
+ options;
57
+ get isReadonly() {
58
+ return this.options.readonlyMode ?? false;
59
+ }
60
+ storageType = "doc";
61
+ locker = new SingletonLocker();
62
+ event = new EventBus();
63
+ constructor(options) {
64
+ this.options = options;
65
+ }
66
+ async getDoc(docId) {
67
+ const lock = this.isReadonly ? void 0 : await this.lockDocForUpdate(docId);
68
+ try {
69
+ const snapshot = await this.getDocSnapshot(docId);
70
+ const updates = await this.getDocUpdates(docId);
71
+ if (updates.length) {
72
+ const newSnapshot = this.mergeUpdates(snapshot, updates);
73
+ if (!this.isReadonly) {
74
+ await this.setDocSnapshot(newSnapshot, snapshot);
75
+ await this.markUpdatesMerged(docId, updates);
76
+ }
77
+ return newSnapshot;
78
+ }
79
+ return snapshot;
80
+ } finally {
81
+ await lock?.[Symbol.asyncDispose]();
82
+ }
83
+ }
84
+ async getDocDiff(docId, knownVersion) {
85
+ const snapshot = await this.getDoc(docId);
86
+ if (!snapshot) return null;
87
+ const { missing, version } = this.diffUpdates(snapshot.data, knownVersion);
88
+ return {
89
+ docId,
90
+ missing,
91
+ version
92
+ };
93
+ }
94
+ subscribeDocUpdate(callback) {
95
+ return this.event.on("update", ({ update, origin }) => {
96
+ callback(update, origin);
97
+ });
98
+ }
99
+ /**
100
+ * Apply doc updates to a snapshot.
101
+ */
102
+ mergeUpdates(snapshot, updates) {
103
+ return {
104
+ docId: snapshot?.docId ?? updates[0].docId,
105
+ data: (this.options.mergeUpdates ?? mergeLoroUpdates)([...snapshot ? [snapshot.data] : [], ...updates.map((update) => update.data)]),
106
+ createdAt: snapshot?.createdAt ?? /* @__PURE__ */ new Date(),
107
+ updatedAt: updates.at(-1)?.createdAt ?? /* @__PURE__ */ new Date()
108
+ };
109
+ }
110
+ diffUpdates(snapshot, knownVersion) {
111
+ return (this.options.diffUpdates ?? diffLoroUpdate)(snapshot, knownVersion);
112
+ }
113
+ async lockDocForUpdate(docId) {
114
+ return this.locker.lock(`Snapshotting`, docId);
115
+ }
116
+ };
117
+ //#endregion
118
+ //#region src/connection/auto-reconnect.ts
119
+ var AutoReconnectConnection = class {
120
+ event = new EventBus();
121
+ _inner = void 0;
122
+ _status = "idle";
123
+ _error = void 0;
124
+ retryDelay = 3e3;
125
+ connectingTimeout = 15e3;
126
+ refCount = 0;
127
+ connectingAbort;
128
+ reconnectingAbort;
129
+ constructor() {}
130
+ get shareId() {}
131
+ get maybeConnection() {
132
+ return this._inner;
133
+ }
134
+ get inner() {
135
+ if (this._inner === void 0) throw new Error(`Connection ${this.constructor.name} has not been established.`);
136
+ return this._inner;
137
+ }
138
+ set inner(inner) {
139
+ this._inner = inner;
140
+ }
141
+ get status() {
142
+ return this._status;
143
+ }
144
+ get error() {
145
+ return this._error;
146
+ }
147
+ set error(error) {
148
+ this.handleError(error);
149
+ }
150
+ setStatus(status, error) {
151
+ const shouldEmit = status !== this._status || error !== this._error;
152
+ this._status = status;
153
+ if (error || status === "connected") this._error = error;
154
+ if (shouldEmit) this.emitStatusChanged(status, this._error);
155
+ }
156
+ innerConnect() {
157
+ if (this.status !== "connecting") {
158
+ this.setStatus("connecting");
159
+ const connectingAbort = new AbortController();
160
+ this.connectingAbort = connectingAbort;
161
+ const signal = connectingAbort.signal;
162
+ const timeout = setTimeout(() => {
163
+ if (!signal.aborted) this.handleError(/* @__PURE__ */ new Error("connecting timeout"));
164
+ }, this.connectingTimeout);
165
+ this.doConnect(signal).then((value) => {
166
+ clearTimeout(timeout);
167
+ if (!signal.aborted) {
168
+ this._inner = value;
169
+ this.setStatus("connected");
170
+ } else try {
171
+ this.doDisconnect(value);
172
+ } catch (error) {
173
+ console.error("failed to disconnect", error);
174
+ }
175
+ }).catch((error) => {
176
+ if (!signal.aborted) {
177
+ clearTimeout(timeout);
178
+ console.error("failed to connect", error);
179
+ this.handleError(error);
180
+ }
181
+ });
182
+ }
183
+ }
184
+ innerDisconnect() {
185
+ this.connectingAbort?.abort(MANUALLY_STOP);
186
+ this.reconnectingAbort?.abort(MANUALLY_STOP);
187
+ try {
188
+ if (this._inner) this.doDisconnect(this._inner);
189
+ } catch (error) {
190
+ console.error("failed to disconnect", error);
191
+ }
192
+ this.reconnectingAbort = void 0;
193
+ this.connectingAbort = void 0;
194
+ this._inner = void 0;
195
+ }
196
+ handleError(reason) {
197
+ console.error("connection error, will reconnect", reason);
198
+ this.innerDisconnect();
199
+ if (this.status === "closed") return;
200
+ this.setStatus("error", reason);
201
+ this.reconnectingAbort = new AbortController();
202
+ const signal = this.reconnectingAbort.signal;
203
+ const timeout = setTimeout(() => {
204
+ if (!signal.aborted) this.innerConnect();
205
+ }, this.retryDelay);
206
+ signal.addEventListener("abort", () => {
207
+ clearTimeout(timeout);
208
+ });
209
+ }
210
+ connect() {
211
+ this.refCount++;
212
+ if (this.refCount === 1) this.innerConnect();
213
+ }
214
+ disconnect(force) {
215
+ if (force) this.refCount = 0;
216
+ else this.refCount = Math.max(this.refCount - 1, 0);
217
+ if (this.refCount === 0) {
218
+ this.innerDisconnect();
219
+ this.setStatus("closed");
220
+ }
221
+ }
222
+ waitForConnected() {
223
+ return new Task((resolve, _reject, scope) => {
224
+ if (this.status === "connected") {
225
+ resolve();
226
+ return;
227
+ }
228
+ const off = this.onStatusChanged((status) => {
229
+ if (status === "connected") {
230
+ resolve();
231
+ off();
232
+ }
233
+ });
234
+ scope.disposer.add(off);
235
+ });
236
+ }
237
+ onStatusChanged(cb) {
238
+ return this.event.on("statusChanged", (data) => {
239
+ cb(data.status, data.error);
240
+ });
241
+ }
242
+ emitStatusChanged = (status, error) => {
243
+ this.event.emit("statusChanged", {
244
+ status,
245
+ error
246
+ });
247
+ };
248
+ };
249
+ //#endregion
250
+ export { BaseDocStorage as n, AutoReconnectConnection as t };
@@ -0,0 +1,191 @@
1
+ import { EventBus, Task } from "@mengine/utils";
2
+
3
+ //#region src/connection/api.d.ts
4
+ type ConnectionStatus = 'idle' | 'connecting' | 'connected' | 'error' | 'closed';
5
+ interface Connection<T = any> {
6
+ readonly shareId?: string;
7
+ readonly status: ConnectionStatus;
8
+ readonly error?: Error;
9
+ readonly inner: T;
10
+ connect(): void;
11
+ disconnect(): void;
12
+ waitForConnected(): Task<void>;
13
+ onStatusChanged(cb: (status: ConnectionStatus, error?: Error) => void): () => void;
14
+ }
15
+ declare class DummyConnection implements Connection<undefined> {
16
+ readonly status: ConnectionStatus;
17
+ readonly inner: undefined;
18
+ connect(): void;
19
+ disconnect(): void;
20
+ waitForConnected(): Task<void>;
21
+ onStatusChanged(_cb: (status: ConnectionStatus, error?: Error) => void): () => void;
22
+ }
23
+ //#endregion
24
+ //#region src/connection/auto-reconnect.d.ts
25
+ declare abstract class AutoReconnectConnection<T = any> implements Connection<T> {
26
+ private readonly event;
27
+ private _inner;
28
+ private _status;
29
+ private _error;
30
+ retryDelay: number;
31
+ connectingTimeout: number;
32
+ private refCount;
33
+ private connectingAbort?;
34
+ private reconnectingAbort?;
35
+ constructor();
36
+ get shareId(): string | undefined;
37
+ get maybeConnection(): T | undefined;
38
+ get inner(): T;
39
+ private set inner(value);
40
+ get status(): ConnectionStatus;
41
+ get error(): Error | undefined;
42
+ protected set error(error: Error | undefined);
43
+ private setStatus;
44
+ protected abstract doConnect(signal?: AbortSignal): Promise<T>;
45
+ protected abstract doDisconnect(conn: T): void;
46
+ private innerConnect;
47
+ private innerDisconnect;
48
+ private handleError;
49
+ connect(): void;
50
+ disconnect(force?: boolean): void;
51
+ waitForConnected(): Task<void>;
52
+ onStatusChanged(cb: (status: ConnectionStatus, error?: Error) => void): () => void;
53
+ private readonly emitStatusChanged;
54
+ }
55
+ //#endregion
56
+ //#region src/connection/shared-connection.d.ts
57
+ declare function share<T extends Connection>(conn: T): T;
58
+ //#endregion
59
+ //#region src/api.d.ts
60
+ interface DocSnapshotRecord {
61
+ docId: string;
62
+ data: Uint8Array;
63
+ createdAt: Date;
64
+ updatedAt: Date;
65
+ }
66
+ interface DocDiff {
67
+ docId: string;
68
+ version: Uint8Array;
69
+ missing: Uint8Array;
70
+ }
71
+ interface DocUpdate {
72
+ docId: string;
73
+ data: Uint8Array;
74
+ }
75
+ interface DocUpdateRecord extends DocUpdate {
76
+ seq: number;
77
+ createdAt: Date;
78
+ }
79
+ interface DocStorageOptions {
80
+ /**
81
+ * open as readonly mode.
82
+ */
83
+ readonlyMode?: boolean;
84
+ /**
85
+ * Merge updates into a single update(snapshot).
86
+ */
87
+ mergeUpdates?: (updates: Uint8Array[]) => Uint8Array;
88
+ /**
89
+ * Diff the snapshot with the given version.
90
+ */
91
+ diffUpdates?: (snapshot: Uint8Array, knownVersion?: Uint8Array) => {
92
+ missing: Uint8Array;
93
+ version: Uint8Array;
94
+ };
95
+ }
96
+ interface DocStorage {
97
+ readonly isReadonly: boolean;
98
+ readonly connection: Connection;
99
+ /**
100
+ * Get a doc record with latest binary.
101
+ */
102
+ getDoc(docId: string): Promise<DocSnapshotRecord | null>;
103
+ /**
104
+ * Get a diff with the given state vector.
105
+ */
106
+ getDocDiff(docId: string, knownVersion?: Uint8Array): Promise<DocDiff | null>;
107
+ /**
108
+ * Push updates into storage
109
+ */
110
+ pushDocUpdate(update: DocUpdate, origin: unknown): Promise<void>;
111
+ /**
112
+ * Delete a specific doc data with all snapshots and updates
113
+ */
114
+ deleteDoc(docId: string): Promise<void>;
115
+ /**
116
+ * Subscribe to doc updates
117
+ */
118
+ subscribeDocUpdate(callback: (update: DocUpdate, origin: unknown) => void): () => void;
119
+ }
120
+ //#endregion
121
+ //#region src/lock.d.ts
122
+ interface Locker {
123
+ lock(domain: string, resource: string): Promise<AsyncDisposable>;
124
+ }
125
+ //#endregion
126
+ //#region src/base.d.ts
127
+ type DocStorageEvents = {
128
+ update: [{
129
+ update: DocUpdate;
130
+ origin: unknown;
131
+ }];
132
+ };
133
+ declare abstract class BaseDocStorage<Opts = {}> implements DocStorage {
134
+ protected readonly options: Opts & DocStorageOptions;
135
+ get isReadonly(): boolean;
136
+ abstract readonly connection: Connection;
137
+ readonly storageType = "doc";
138
+ protected locker: Locker;
139
+ protected readonly event: EventBus<DocStorageEvents>;
140
+ constructor(options: Opts & DocStorageOptions);
141
+ getDoc(docId: string): Promise<DocSnapshotRecord | null>;
142
+ getDocDiff(docId: string, knownVersion?: Uint8Array): Promise<DocDiff | null>;
143
+ abstract pushDocUpdate(update: DocUpdate, origin: unknown): Promise<void>;
144
+ abstract deleteDoc(docId: string): Promise<void>;
145
+ subscribeDocUpdate(callback: (update: DocUpdate, origin: unknown) => void): () => void;
146
+ /**
147
+ * Get a doc snapshot from storage
148
+ */
149
+ protected abstract getDocSnapshot(docId: string): Promise<DocSnapshotRecord | null>;
150
+ /**
151
+ * Set the doc snapshot into storage
152
+ *
153
+ * @safety
154
+ * be careful when implementing this method.
155
+ *
156
+ * It might be called with outdated snapshot when running in multi-thread environment.
157
+ *
158
+ * A common solution is update the snapshot record is DB only when the coming one's timestamp is newer.
159
+ *
160
+ * @example
161
+ * ```ts
162
+ * await using _lock = await this.lockDocForUpdate(docId);
163
+ * // set snapshot
164
+ *
165
+ * ```
166
+ */
167
+ protected abstract setDocSnapshot(snapshot: DocSnapshotRecord, prevSnapshot: DocSnapshotRecord | null): Promise<boolean>;
168
+ /**
169
+ * Get all updates of a doc that haven't been merged into snapshot.
170
+ *
171
+ * Updates queue design exists for a performance concern:
172
+ * A huge amount of write time will be saved if we don't merge updates into snapshot immediately.
173
+ * Updates will be merged into snapshot when the latest doc is requested.
174
+ */
175
+ protected abstract getDocUpdates(docId: string): Promise<DocUpdateRecord[]>;
176
+ /**
177
+ * Mark updates as merged into snapshot.
178
+ */
179
+ protected abstract markUpdatesMerged(docId: string, updates: DocUpdateRecord[]): Promise<number>;
180
+ /**
181
+ * Apply doc updates to a snapshot.
182
+ */
183
+ protected mergeUpdates(snapshot: DocSnapshotRecord | null, updates: DocUpdateRecord[]): DocSnapshotRecord;
184
+ protected diffUpdates(snapshot: Uint8Array, knownVersion?: Uint8Array): {
185
+ missing: Uint8Array;
186
+ version: Uint8Array;
187
+ };
188
+ protected lockDocForUpdate(docId: string): Promise<AsyncDisposable>;
189
+ }
190
+ //#endregion
191
+ export { DocStorage as a, DocUpdateRecord as c, Connection as d, ConnectionStatus as f, DocSnapshotRecord as i, share as l, Locker as n, DocStorageOptions as o, DummyConnection as p, DocDiff as r, DocUpdate as s, BaseDocStorage as t, AutoReconnectConnection as u };
package/dist/idb.d.ts ADDED
@@ -0,0 +1,119 @@
1
+ import { c as DocUpdateRecord, i as DocSnapshotRecord, n as Locker, s as DocUpdate, t as BaseDocStorage, u as AutoReconnectConnection } from "./base-B0LSXWTK.js";
2
+ import { DBSchema, IDBPDatabase } from "idb";
3
+
4
+ //#region src/impls/idb/schema.d.ts
5
+ /**
6
+ IndexedDB
7
+ > DB(medeo-engine)
8
+ > Table(Snapshots)
9
+ > Table(Updates)
10
+
11
+ Table(Snapshots)
12
+ | docId | data | seq | createdAt | updatedAt |
13
+ |-------|------|-----|-----------|-----------|
14
+ | str | bin | num | Date | Date |
15
+
16
+ Table(Updates)
17
+ | id | docId | data | createdAt |
18
+ |----|-------|------|-----------|
19
+ |auto| str | bin | Date |
20
+
21
+ Table(Locks)
22
+ | key | lock |
23
+ |-----|------|
24
+ | str | Date |
25
+ */
26
+ interface DocStorageSchema extends DBSchema {
27
+ snapshots: {
28
+ key: string;
29
+ value: {
30
+ docId: string;
31
+ data: Uint8Array;
32
+ createdAt: Date;
33
+ updatedAt: Date;
34
+ };
35
+ };
36
+ updates: {
37
+ key: [string, number];
38
+ value: {
39
+ docId: string;
40
+ seq: number;
41
+ data: Uint8Array;
42
+ createdAt: Date;
43
+ };
44
+ indexes: {
45
+ docId: string;
46
+ };
47
+ };
48
+ sequences: {
49
+ key: string;
50
+ value: {
51
+ docId: string;
52
+ seq: number;
53
+ };
54
+ };
55
+ locks: {
56
+ key: string;
57
+ value: {
58
+ key: string;
59
+ lockedAt: Date;
60
+ };
61
+ };
62
+ }
63
+ //#endregion
64
+ //#region src/impls/idb/connection.d.ts
65
+ declare class IDBConnection extends AutoReconnectConnection<{
66
+ db: IDBPDatabase<DocStorageSchema>;
67
+ channel: BroadcastChannel;
68
+ }> {
69
+ readonly dbName: string;
70
+ constructor(dbName: string);
71
+ get shareId(): string;
72
+ doConnect(): Promise<{
73
+ db: IDBPDatabase<DocStorageSchema>;
74
+ channel: BroadcastChannel;
75
+ }>;
76
+ doDisconnect(db: {
77
+ db: IDBPDatabase<DocStorageSchema>;
78
+ channel: BroadcastChannel;
79
+ }): void;
80
+ handleVersionChange: (e: IDBVersionChangeEvent) => void;
81
+ }
82
+ //#endregion
83
+ //#region src/impls/idb/lock.d.ts
84
+ declare class IndexedDBLocker implements Locker {
85
+ private readonly dbConnection;
86
+ get db(): import("idb").IDBPDatabase<DocStorageSchema>;
87
+ get channel(): BroadcastChannel;
88
+ constructor(dbConnection: IDBConnection);
89
+ lock(domain: string, resource: string): Promise<{
90
+ [Symbol.asyncDispose]: () => Promise<void>;
91
+ }>;
92
+ }
93
+ //#endregion
94
+ //#region src/impls/idb/index.d.ts
95
+ interface ChannelMessage {
96
+ type: 'update';
97
+ update: DocUpdate;
98
+ origin?: unknown;
99
+ }
100
+ declare class IndexedDBDocStorage extends BaseDocStorage<{
101
+ dbName: string;
102
+ }> {
103
+ static readonly identifier = "IndexedDBDocStorage";
104
+ readonly connection: IDBConnection;
105
+ protected readonly locker: IndexedDBLocker;
106
+ get db(): import("idb").IDBPDatabase<DocStorageSchema>;
107
+ get channel(): BroadcastChannel;
108
+ pushDocUpdate(update: DocUpdate, origin: unknown): Promise<void>;
109
+ protected getDocSnapshot(docId: string): Promise<DocSnapshotRecord | null>;
110
+ deleteDoc(docId: string): Promise<void>;
111
+ protected setDocSnapshot(snapshot: DocSnapshotRecord): Promise<boolean>;
112
+ protected getDocUpdates(docId: string): Promise<DocUpdateRecord[]>;
113
+ protected markUpdatesMerged(docId: string, updates: DocUpdateRecord[]): Promise<number>;
114
+ private docUpdateListener;
115
+ subscribeDocUpdate(callback: (update: DocUpdate, origin: unknown) => void): () => void;
116
+ handleChannelMessage: (event: MessageEvent<ChannelMessage>) => void;
117
+ }
118
+ //#endregion
119
+ export { IndexedDBDocStorage };