@dxos/echo-pipeline 0.4.10-main.fd4f2a3 → 0.4.10-main.fd8ea31

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.
Files changed (68) hide show
  1. package/dist/lib/browser/{chunk-RA6MLCZM.mjs → chunk-KMWJLYEQ.mjs} +68 -35
  2. package/dist/lib/browser/chunk-KMWJLYEQ.mjs.map +7 -0
  3. package/dist/lib/browser/index.mjs +305 -231
  4. package/dist/lib/browser/index.mjs.map +4 -4
  5. package/dist/lib/browser/meta.json +1 -1
  6. package/dist/lib/browser/testing/index.mjs +10 -2
  7. package/dist/lib/browser/testing/index.mjs.map +4 -4
  8. package/dist/lib/node/{chunk-KGIYLJBT.cjs → chunk-YZA42CKA.cjs} +70 -37
  9. package/dist/lib/node/chunk-YZA42CKA.cjs.map +7 -0
  10. package/dist/lib/node/index.cjs +327 -256
  11. package/dist/lib/node/index.cjs.map +4 -4
  12. package/dist/lib/node/meta.json +1 -1
  13. package/dist/lib/node/testing/index.cjs +20 -13
  14. package/dist/lib/node/testing/index.cjs.map +4 -4
  15. package/dist/types/src/automerge/automerge-host.d.ts +19 -17
  16. package/dist/types/src/automerge/automerge-host.d.ts.map +1 -1
  17. package/dist/types/src/automerge/automerge-repo.test.d.ts +2 -0
  18. package/dist/types/src/automerge/automerge-repo.test.d.ts.map +1 -0
  19. package/dist/types/src/automerge/index.d.ts +1 -0
  20. package/dist/types/src/automerge/index.d.ts.map +1 -1
  21. package/dist/types/src/automerge/level.test.d.ts +2 -0
  22. package/dist/types/src/automerge/level.test.d.ts.map +1 -0
  23. package/dist/types/src/automerge/leveldb-storage-adapter.d.ts +30 -0
  24. package/dist/types/src/automerge/leveldb-storage-adapter.d.ts.map +1 -0
  25. package/dist/types/src/automerge/local-host-network-adapter.d.ts +8 -1
  26. package/dist/types/src/automerge/local-host-network-adapter.d.ts.map +1 -1
  27. package/dist/types/src/automerge/migrations.d.ts +7 -0
  28. package/dist/types/src/automerge/migrations.d.ts.map +1 -0
  29. package/dist/types/src/automerge/reference.d.ts +1 -1
  30. package/dist/types/src/automerge/reference.d.ts.map +1 -1
  31. package/dist/types/src/automerge/storage-adapter.test.d.ts +2 -0
  32. package/dist/types/src/automerge/storage-adapter.test.d.ts.map +1 -0
  33. package/dist/types/src/automerge/types.d.ts +6 -0
  34. package/dist/types/src/automerge/types.d.ts.map +1 -1
  35. package/dist/types/src/metadata/metadata-store.d.ts +2 -1
  36. package/dist/types/src/metadata/metadata-store.d.ts.map +1 -1
  37. package/dist/types/src/space/control-pipeline.d.ts +3 -1
  38. package/dist/types/src/space/control-pipeline.d.ts.map +1 -1
  39. package/dist/types/src/space/space-manager.d.ts +3 -1
  40. package/dist/types/src/space/space-manager.d.ts.map +1 -1
  41. package/dist/types/src/space/space.d.ts +2 -1
  42. package/dist/types/src/space/space.d.ts.map +1 -1
  43. package/dist/types/src/testing/index.d.ts +1 -0
  44. package/dist/types/src/testing/index.d.ts.map +1 -1
  45. package/dist/types/src/testing/level.d.ts +3 -0
  46. package/dist/types/src/testing/level.d.ts.map +1 -0
  47. package/dist/types/src/testing/test-agent-builder.d.ts.map +1 -1
  48. package/package.json +33 -30
  49. package/src/automerge/automerge-host.test.ts +22 -8
  50. package/src/automerge/automerge-host.ts +53 -114
  51. package/src/automerge/automerge-repo.test.ts +29 -0
  52. package/src/automerge/index.ts +1 -0
  53. package/src/automerge/level.test.ts +82 -0
  54. package/src/automerge/leveldb-storage-adapter.ts +117 -0
  55. package/src/automerge/local-host-network-adapter.ts +19 -13
  56. package/src/automerge/migrations.ts +41 -0
  57. package/src/automerge/reference.ts +1 -1
  58. package/src/automerge/storage-adapter.test.ts +90 -0
  59. package/src/automerge/types.ts +6 -3
  60. package/src/metadata/metadata-store.ts +17 -8
  61. package/src/space/control-pipeline.ts +11 -1
  62. package/src/space/space-manager.ts +4 -0
  63. package/src/space/space.ts +11 -1
  64. package/src/testing/index.ts +1 -0
  65. package/src/testing/level.ts +11 -0
  66. package/src/testing/test-agent-builder.ts +1 -0
  67. package/dist/lib/browser/chunk-RA6MLCZM.mjs.map +0 -7
  68. package/dist/lib/node/chunk-KGIYLJBT.cjs.map +0 -7
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dxos/echo-pipeline",
3
- "version": "0.4.10-main.fd4f2a3",
3
+ "version": "0.4.10-main.fd8ea31",
4
4
  "description": "ECHO database.",
5
5
  "homepage": "https://dxos.org",
6
6
  "bugs": "https://github.com/dxos/dxos/issues",
@@ -35,36 +35,39 @@
35
35
  "src"
36
36
  ],
37
37
  "dependencies": {
38
+ "abstract-level": "^1.0.2",
38
39
  "crc-32": "^1.2.2",
39
- "@dxos/async": "0.4.10-main.fd4f2a3",
40
- "@dxos/codec-protobuf": "0.4.10-main.fd4f2a3",
41
- "@dxos/context": "0.4.10-main.fd4f2a3",
42
- "@dxos/credentials": "0.4.10-main.fd4f2a3",
43
- "@dxos/debug": "0.4.10-main.fd4f2a3",
44
- "@dxos/crypto": "0.4.10-main.fd4f2a3",
45
- "@dxos/echo-db": "0.4.10-main.fd4f2a3",
46
- "@dxos/feed-store": "0.4.10-main.fd4f2a3",
47
- "@dxos/hypercore": "0.4.10-main.fd4f2a3",
48
- "@dxos/invariant": "0.4.10-main.fd4f2a3",
49
- "@dxos/keys": "0.4.10-main.fd4f2a3",
50
- "@dxos/keyring": "0.4.10-main.fd4f2a3",
51
- "@dxos/automerge": "0.4.10-main.fd4f2a3",
52
- "@dxos/log": "0.4.10-main.fd4f2a3",
53
- "@dxos/messaging": "0.4.10-main.fd4f2a3",
54
- "@dxos/network-manager": "0.4.10-main.fd4f2a3",
55
- "@dxos/node-std": "0.4.10-main.fd4f2a3",
56
- "@dxos/protocols": "0.4.10-main.fd4f2a3",
57
- "@dxos/random-access-storage": "0.4.10-main.fd4f2a3",
58
- "@dxos/rpc": "0.4.10-main.fd4f2a3",
59
- "@dxos/teleport": "0.4.10-main.fd4f2a3",
60
- "@dxos/teleport-extension-automerge-replicator": "0.4.10-main.fd4f2a3",
61
- "@dxos/teleport-extension-gossip": "0.4.10-main.fd4f2a3",
62
- "@dxos/teleport-extension-object-sync": "0.4.10-main.fd4f2a3",
63
- "@dxos/timeframe": "0.4.10-main.fd4f2a3",
64
- "@dxos/teleport-extension-replicator": "0.4.10-main.fd4f2a3",
65
- "@dxos/tracing": "0.4.10-main.fd4f2a3",
66
- "@dxos/typings": "0.4.10-main.fd4f2a3",
67
- "@dxos/util": "0.4.10-main.fd4f2a3"
40
+ "level": "^8.0.1",
41
+ "level-transcoder": "^1.0.1",
42
+ "@dxos/async": "0.4.10-main.fd8ea31",
43
+ "@dxos/automerge": "0.4.10-main.fd8ea31",
44
+ "@dxos/codec-protobuf": "0.4.10-main.fd8ea31",
45
+ "@dxos/context": "0.4.10-main.fd8ea31",
46
+ "@dxos/credentials": "0.4.10-main.fd8ea31",
47
+ "@dxos/debug": "0.4.10-main.fd8ea31",
48
+ "@dxos/crypto": "0.4.10-main.fd8ea31",
49
+ "@dxos/echo-schema": "0.4.10-main.fd8ea31",
50
+ "@dxos/feed-store": "0.4.10-main.fd8ea31",
51
+ "@dxos/keyring": "0.4.10-main.fd8ea31",
52
+ "@dxos/hypercore": "0.4.10-main.fd8ea31",
53
+ "@dxos/invariant": "0.4.10-main.fd8ea31",
54
+ "@dxos/keys": "0.4.10-main.fd8ea31",
55
+ "@dxos/log": "0.4.10-main.fd8ea31",
56
+ "@dxos/messaging": "0.4.10-main.fd8ea31",
57
+ "@dxos/network-manager": "0.4.10-main.fd8ea31",
58
+ "@dxos/protocols": "0.4.10-main.fd8ea31",
59
+ "@dxos/node-std": "0.4.10-main.fd8ea31",
60
+ "@dxos/random-access-storage": "0.4.10-main.fd8ea31",
61
+ "@dxos/rpc": "0.4.10-main.fd8ea31",
62
+ "@dxos/teleport-extension-automerge-replicator": "0.4.10-main.fd8ea31",
63
+ "@dxos/teleport": "0.4.10-main.fd8ea31",
64
+ "@dxos/teleport-extension-gossip": "0.4.10-main.fd8ea31",
65
+ "@dxos/teleport-extension-object-sync": "0.4.10-main.fd8ea31",
66
+ "@dxos/teleport-extension-replicator": "0.4.10-main.fd8ea31",
67
+ "@dxos/timeframe": "0.4.10-main.fd8ea31",
68
+ "@dxos/tracing": "0.4.10-main.fd8ea31",
69
+ "@dxos/util": "0.4.10-main.fd8ea31",
70
+ "@dxos/typings": "0.4.10-main.fd8ea31"
68
71
  },
69
72
  "devDependencies": {
70
73
  "fast-check": "^3.15.1",
@@ -25,35 +25,50 @@ import { arrayToBuffer, bufferToArray } from '@dxos/util';
25
25
  import { AutomergeHost } from './automerge-host';
26
26
  import { AutomergeStorageAdapter } from './automerge-storage-adapter';
27
27
  import { MeshNetworkAdapter } from './mesh-network-adapter';
28
+ import { createTestLevel } from '../testing';
28
29
 
29
30
  describe('AutomergeHost', () => {
30
- test('can create documents', () => {
31
- const host = new AutomergeHost({ directory: createStorage({ type: StorageType.RAM }).createDirectory() });
31
+ test('can create documents', async () => {
32
+ const level = createTestLevel();
33
+ await level.open();
34
+ afterTest(() => level.close());
35
+ const host = new AutomergeHost({
36
+ db: level.sublevel('automerge'),
37
+ });
38
+ await host.open();
39
+ afterTest(() => host.close());
32
40
 
33
41
  const handle = host.repo.create();
34
42
  handle.change((doc: any) => {
35
43
  doc.text = 'Hello world';
36
44
  });
45
+ await host.repo.flush();
37
46
  expect(handle.docSync().text).toEqual('Hello world');
38
47
  });
39
48
 
40
49
  test('changes are preserved in storage', async () => {
41
- const storageDirectory = createStorage({ type: StorageType.RAM }).createDirectory();
50
+ const level = createTestLevel();
51
+ await level.open();
52
+ afterTest(() => level.close());
42
53
 
43
- const host = new AutomergeHost({ directory: storageDirectory });
54
+ const host = new AutomergeHost({ db: level.sublevel('automerge') });
55
+ await host.open();
44
56
  const handle = host.repo.create();
45
57
  handle.change((doc: any) => {
46
58
  doc.text = 'Hello world';
47
59
  });
48
60
  const url = handle.url;
49
61
 
50
- // TODO(dmaretskyi): Is there a way to know when automerge has finished saving?
51
- await sleep(100);
62
+ await host.repo.flush();
63
+ await host.close();
52
64
 
53
- const host2 = new AutomergeHost({ directory: storageDirectory });
65
+ const host2 = new AutomergeHost({ db: level.sublevel('automerge') });
66
+ await host2.open();
67
+ afterTest(() => host2.close());
54
68
  const handle2 = host2.repo.find(url);
55
69
  await handle2.whenReady();
56
70
  expect(handle2.docSync().text).toEqual('Hello world');
71
+ await host2.repo.flush();
57
72
  });
58
73
 
59
74
  test('basic networking', async () => {
@@ -168,7 +183,6 @@ describe('AutomergeHost', () => {
168
183
  const secondHandle = host.create();
169
184
  secondHandle.change((doc: any) => (doc.text = 'Hello world'));
170
185
  await host.find(secondHandle.url).whenReady();
171
- // await sleep(100);
172
186
  allowedDocs.push(secondHandle.documentId);
173
187
 
174
188
  {
@@ -2,83 +2,82 @@
2
2
  // Copyright 2023 DXOS.org
3
3
  //
4
4
 
5
- import { next as automerge, getHeads } from '@dxos/automerge/automerge';
6
- import {
7
- Repo,
8
- type PeerId,
9
- type DocumentId,
10
- type StorageKey,
11
- type DocHandle,
12
- type DocHandleChangePayload,
13
- } from '@dxos/automerge/automerge-repo';
14
- import { IndexedDBStorageAdapter } from '@dxos/automerge/automerge-repo-storage-indexeddb';
5
+ import { asyncTimeout } from '@dxos/async';
6
+ import { next as automerge } from '@dxos/automerge/automerge';
7
+ import { Repo, type DocumentId, type PeerId, type StorageAdapterInterface } from '@dxos/automerge/automerge-repo';
15
8
  import { type Stream } from '@dxos/codec-protobuf';
16
- import { Context } from '@dxos/context';
9
+ import { Context, type Lifecycle } from '@dxos/context';
17
10
  import { PublicKey } from '@dxos/keys';
18
11
  import { log } from '@dxos/log';
19
- import { idCodec } from '@dxos/protocols';
20
12
  import {
21
13
  type FlushRequest,
22
14
  type HostInfo,
23
15
  type SyncRepoRequest,
24
16
  type SyncRepoResponse,
25
17
  } from '@dxos/protocols/proto/dxos/echo/service';
26
- import { StorageType, type Directory } from '@dxos/random-access-storage';
18
+ import { type Directory } from '@dxos/random-access-storage';
27
19
  import { type AutomergeReplicator } from '@dxos/teleport-extension-automerge-replicator';
28
20
  import { trace } from '@dxos/tracing';
29
21
  import { ComplexMap, ComplexSet, defaultMap, mapValues } from '@dxos/util';
30
22
 
31
- import { AutomergeStorageAdapter } from './automerge-storage-adapter';
32
- import { AutomergeStorageWrapper } from './automerge-storage–wrapper';
23
+ import { LevelDBStorageAdapter, type StorageCallbacks } from './leveldb-storage-adapter';
33
24
  import { LocalHostNetworkAdapter } from './local-host-network-adapter';
34
25
  import { MeshNetworkAdapter } from './mesh-network-adapter';
26
+ import { levelMigration } from './migrations';
27
+ import { type SubLevelDB } from './types';
35
28
 
29
+ // TODO: Remove
36
30
  export type { DocumentId };
37
31
 
38
- export interface MetadataMethods {
39
- markDirty(idToLastHash: Map<string, string>): Promise<void>;
40
- }
41
-
42
32
  export type AutomergeHostParams = {
43
- directory: Directory;
44
- metadata?: MetadataMethods;
33
+ db: SubLevelDB;
34
+ /**
35
+ * For migration purposes.
36
+ */
37
+ directory?: Directory;
38
+ storageCallbacks?: StorageCallbacks;
45
39
  };
46
40
 
47
41
  @trace.resource()
48
42
  export class AutomergeHost {
49
43
  private readonly _ctx = new Context();
50
- private readonly _repo: Repo;
51
- private readonly _meshNetwork: MeshNetworkAdapter;
52
- private readonly _clientNetwork: LocalHostNetworkAdapter;
53
- private readonly _storage: AutomergeStorageWrapper;
44
+ private readonly _directory?: Directory;
45
+ private readonly _db: SubLevelDB;
46
+ private readonly _storageCallbacks?: StorageCallbacks;
47
+
48
+ private _repo!: Repo;
49
+ private _meshNetwork!: MeshNetworkAdapter;
50
+ private _clientNetwork!: LocalHostNetworkAdapter;
51
+ private _storage!: StorageAdapterInterface & Lifecycle;
54
52
 
55
53
  @trace.info()
56
- private readonly _peerId: string;
54
+ private _peerId!: string;
57
55
 
58
56
  /**
59
57
  * spaceKey -> deviceKey[]
60
58
  */
61
59
  private readonly _authorizedDevices = new ComplexMap<PublicKey, ComplexSet<PublicKey>>(PublicKey.hash);
62
60
 
63
- private readonly _updatingMetadata = new Map<string, Promise<void>>();
64
- private readonly _metadata?: MetadataMethods;
65
-
66
61
  public _requestedDocs = new Set<string>();
67
62
 
68
- constructor({ directory, metadata }: AutomergeHostParams) {
69
- this._metadata = metadata;
70
- this._meshNetwork = new MeshNetworkAdapter();
71
- this._clientNetwork = new LocalHostNetworkAdapter();
63
+ constructor({ directory, db, storageCallbacks }: AutomergeHostParams) {
64
+ this._directory = directory;
65
+ this._db = db;
66
+ this._storageCallbacks = storageCallbacks;
67
+ }
72
68
 
73
- this._storage = new AutomergeStorageWrapper({
74
- storage:
75
- // TODO(mykola): Delete specific handling of IDB storage.
76
- directory.type === StorageType.IDB
77
- ? new IndexedDBStorageAdapter(directory.path, 'data')
78
- : new AutomergeStorageAdapter(directory),
79
- callbacks: { beforeSave: (params) => this._beforeSave(params) },
69
+ async open() {
70
+ // TODO(mykola): remove this before 0.6 release.
71
+ this._directory && (await levelMigration({ db: this._db, directory: this._directory }));
72
+ this._storage = new LevelDBStorageAdapter({
73
+ db: this._db,
74
+ callbacks: this._storageCallbacks,
80
75
  });
76
+ await this._storage.open?.();
81
77
  this._peerId = `host-${PublicKey.random().toHex()}` as PeerId;
78
+
79
+ this._meshNetwork = new MeshNetworkAdapter();
80
+ this._clientNetwork = new LocalHostNetworkAdapter();
82
81
  this._repo = new Repo({
83
82
  peerId: this._peerId as PeerId,
84
83
  network: [this._clientNetwork, this._meshNetwork],
@@ -138,59 +137,17 @@ export class AutomergeHost {
138
137
  this._clientNetwork.ready();
139
138
  this._meshNetwork.ready();
140
139
 
141
- {
142
- const listener = ({ handle }: { handle: DocHandle<any> }) => this._onDocument(handle);
143
- this._repo.on('document', listener);
144
- this._ctx.onDispose(() => {
145
- this._repo.off('document', listener);
146
- Object.values(this._repo.handles).forEach((handle) => handle.off('change'));
147
- });
148
- }
149
- }
150
-
151
- get repo(): Repo {
152
- return this._repo;
140
+ await this._clientNetwork.whenConnected();
153
141
  }
154
142
 
155
- private async _beforeSave(path: StorageKey) {
156
- const id = path[0];
157
- if (this._updatingMetadata.has(id)) {
158
- return this._updatingMetadata.get(id);
159
- }
160
- }
161
-
162
- private _onDocument(handle: DocHandle<any>) {
163
- const listener = (event: DocHandleChangePayload<any>) => this._onUpdate(event);
164
- handle.on('change', listener);
143
+ async close() {
144
+ await this._storage.close?.();
145
+ await this._clientNetwork.close();
146
+ await this._ctx.dispose();
165
147
  }
166
148
 
167
- private _onUpdate(event: DocHandleChangePayload<any>) {
168
- if (this._metadata == null) {
169
- return;
170
- }
171
-
172
- const objectIds = getInlineChanges(event);
173
- if (objectIds.length === 0) {
174
- return;
175
- }
176
-
177
- const heads = getHeads(event.doc);
178
- const lastAvailableHash = heads.join('');
179
- if (!lastAvailableHash) {
180
- return;
181
- }
182
-
183
- const encodedIds = objectIds.map((objectId) => idCodec.encode({ documentId: event.handle.documentId, objectId }));
184
- const idToLastHash = new Map(encodedIds.map((id) => [id, lastAvailableHash]));
185
- const markingDirtyPromise = this._metadata
186
- .markDirty(idToLastHash)
187
- .then(() => {
188
- this._updatingMetadata.delete(event.handle.documentId);
189
- })
190
- .catch((err: Error) => {
191
- this._ctx.disposed && log.catch(err);
192
- });
193
- this._updatingMetadata.set(event.handle.documentId, markingDirtyPromise);
149
+ get repo(): Repo {
150
+ return this._repo;
194
151
  }
195
152
 
196
153
  @trace.info({ depth: null })
@@ -224,12 +181,6 @@ export class AutomergeHost {
224
181
  return this._repo.peers;
225
182
  }
226
183
 
227
- async close() {
228
- await this._storage.close();
229
- await this._clientNetwork.close();
230
- await this._ctx.dispose();
231
- }
232
-
233
184
  //
234
185
  // Methods for client-services.
235
186
  //
@@ -237,7 +188,13 @@ export class AutomergeHost {
237
188
  async flush({ documentIds }: FlushRequest): Promise<void> {
238
189
  // Note: Wait for all requested documents to be loaded/synced from thin-client.
239
190
  await Promise.all(documentIds?.map((id) => this._repo.find(id as DocumentId).whenReady()) ?? []);
240
- await this._repo.flush(documentIds as DocumentId[]);
191
+
192
+ // TODO(dmaretskyi): Workaround until the flush issue gets resolved.
193
+ try {
194
+ await asyncTimeout(this._repo.flush(documentIds as DocumentId[]), 500);
195
+ } catch (err) {
196
+ log.warn('flush error', { documentIds, err });
197
+ }
241
198
  }
242
199
 
243
200
  syncRepo(request: SyncRepoRequest): Stream<SyncRepoResponse> {
@@ -266,24 +223,6 @@ export class AutomergeHost {
266
223
  }
267
224
  }
268
225
 
269
- // TODO(mykola): Reconcile with `getInlineAndLinkChanges` in AutomergeDB.
270
- const getInlineChanges = (event: DocHandleChangePayload<any>) => {
271
- const inlineChangedObjectIds = new Set<string>();
272
- for (const { path } of event.patches) {
273
- if (path.length < 2) {
274
- continue;
275
- }
276
- switch (path[0]) {
277
- case 'objects':
278
- if (path.length >= 2) {
279
- inlineChangedObjectIds.add(path[1]);
280
- }
281
- break;
282
- }
283
- }
284
- return [...inlineChangedObjectIds];
285
- };
286
-
287
226
  export const getSpaceKeyFromDoc = (doc: any): string | null => {
288
227
  // experimental_spaceKey is set on old documents, new ones are created with doc.access.spaceKey
289
228
  const rawSpaceKey = doc.access?.spaceKey ?? doc.experimental_spaceKey;
@@ -0,0 +1,29 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import { Repo } from '@dxos/automerge/automerge-repo';
6
+ import { randomBytes } from '@dxos/crypto';
7
+ import { StorageType, createStorage } from '@dxos/random-access-storage';
8
+ import { describe, test } from '@dxos/test';
9
+
10
+ import { AutomergeStorageAdapter } from './automerge-storage-adapter';
11
+
12
+ describe('AutomergeRepo', () => {
13
+ // Currently failing
14
+ test.skip('flush', async () => {
15
+ const repo = new Repo({
16
+ network: [],
17
+ storage: new AutomergeStorageAdapter(createStorage({ type: StorageType.NODE }).createDirectory()),
18
+ });
19
+ const handle = repo.create<{ field?: string }>();
20
+
21
+ for (let i = 0; i < 10; i++) {
22
+ const p = repo.flush([handle.documentId]);
23
+ handle.change((doc: any) => {
24
+ doc.field += randomBytes(1024).toString('hex');
25
+ });
26
+ await p;
27
+ }
28
+ });
29
+ });
@@ -5,6 +5,7 @@
5
5
  export * from './automerge-host';
6
6
  export * from './automerge-storage-adapter';
7
7
  export * from './automerge-doc-loader';
8
+ export * from './leveldb-storage-adapter';
8
9
  export * from './local-host-network-adapter';
9
10
  export * from './mesh-network-adapter';
10
11
  export * from './types';
@@ -0,0 +1,82 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import { expect } from 'chai';
6
+ import { Level } from 'level';
7
+
8
+ import { PublicKey } from '@dxos/keys';
9
+ import { describe, openAndClose, test } from '@dxos/test';
10
+
11
+ import { type SubLevelDB } from './types';
12
+ import { createTestLevel } from '../testing';
13
+
14
+ describe('Level', () => {
15
+ test('missing keys', async () => {
16
+ const level = createTestLevel();
17
+ await openAndClose(level);
18
+
19
+ expect(() => level.get('missing')).to.throw;
20
+ });
21
+
22
+ test('data persistance after reload', async () => {
23
+ const path = `/tmp/dxos-${PublicKey.random().toHex()}`;
24
+ const level = new Level<string, string>(path);
25
+ await level.open();
26
+
27
+ const key = 'name';
28
+ const value = 'Rich';
29
+ {
30
+ await level.put(key, value);
31
+ expect(await level.get(key)).to.equal(value);
32
+ }
33
+
34
+ await level.close();
35
+
36
+ {
37
+ const level = new Level<string, string>(path);
38
+ await level.open();
39
+ expect(await level.get(key)).to.equal(value);
40
+ await level.clear();
41
+ expect(() => level.get(key)).to.throw;
42
+ }
43
+ });
44
+
45
+ test('batch different sublevels', async () => {
46
+ const level = createTestLevel();
47
+ await openAndClose(level);
48
+
49
+ const first: SubLevelDB = level.sublevel('first');
50
+ const second: SubLevelDB = level.sublevel('second');
51
+
52
+ const batch = first.batch();
53
+
54
+ const key = 'key';
55
+ const value = 'first-level-value';
56
+ batch.put(key, value, { sublevel: second });
57
+ await batch.write();
58
+
59
+ expect(() => first.get(key)).to.throw;
60
+ expect(await level.sublevel('second').get(key)).to.equal(value);
61
+ expect(await second.get(key)).to.equal(value);
62
+ expect(await second.iterator().all()).to.deep.equal([[key, value]]);
63
+ });
64
+
65
+ test('sublevel prefixes', () => {
66
+ const level = createTestLevel();
67
+ expect(level.prefixKey('', 'utf8')).to.equal('');
68
+
69
+ {
70
+ const sublevel = level.sublevel('sublevel');
71
+ expect(sublevel.prefixKey('', 'utf8')).to.equal('!sublevel!');
72
+ }
73
+
74
+ {
75
+ const sublevel = level.sublevel('');
76
+ expect(sublevel.prefixKey('', 'utf8')).to.equal('!!');
77
+
78
+ const sublevel2 = sublevel.sublevel('');
79
+ expect(sublevel2.prefixKey('', 'utf8')).to.equal('!!!!');
80
+ }
81
+ });
82
+ });
@@ -0,0 +1,117 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ // s
4
+
5
+ import { type MixedEncoding } from 'level-transcoder';
6
+
7
+ import { type StorageAdapterInterface, type Chunk, type StorageKey } from '@dxos/automerge/automerge-repo';
8
+ import { LifecycleState, Resource } from '@dxos/context';
9
+ import { type MaybePromise } from '@dxos/util';
10
+
11
+ import { type BatchLevel, type SubLevelDB } from './types';
12
+
13
+ export type LevelDBStorageAdapterParams = {
14
+ db: SubLevelDB;
15
+ callbacks?: StorageCallbacks;
16
+ };
17
+
18
+ export type BeforeSaveParams = { path: StorageKey; batch: BatchLevel };
19
+
20
+ export interface StorageCallbacks {
21
+ beforeSave(params: BeforeSaveParams): MaybePromise<void>;
22
+ afterSave(path: StorageKey): MaybePromise<void>;
23
+ }
24
+
25
+ export class LevelDBStorageAdapter extends Resource implements StorageAdapterInterface {
26
+ constructor(private readonly _params: LevelDBStorageAdapterParams) {
27
+ super();
28
+ }
29
+
30
+ async load(keyArray: StorageKey): Promise<Uint8Array | undefined> {
31
+ try {
32
+ if (this._lifecycleState !== LifecycleState.OPEN) {
33
+ // TODO(mykola): this should be an error.
34
+ return undefined;
35
+ }
36
+ return await this._params.db.get<StorageKey, Uint8Array>(keyArray, { ...encodingOptions });
37
+ } catch (err: any) {
38
+ if (isLevelDbNotFoundError(err)) {
39
+ return undefined;
40
+ }
41
+ throw err;
42
+ }
43
+ }
44
+
45
+ async save(keyArray: StorageKey, binary: Uint8Array): Promise<void> {
46
+ if (this._lifecycleState !== LifecycleState.OPEN) {
47
+ return undefined;
48
+ }
49
+ const batch = this._params.db.batch();
50
+
51
+ await this._params.callbacks?.beforeSave?.({ path: keyArray, batch });
52
+ batch.put<StorageKey, Uint8Array>(keyArray, Buffer.from(binary), {
53
+ ...encodingOptions,
54
+ });
55
+ await batch.write();
56
+
57
+ await this._params.callbacks?.afterSave?.(keyArray);
58
+ }
59
+
60
+ async remove(keyArray: StorageKey): Promise<void> {
61
+ if (this._lifecycleState !== LifecycleState.OPEN) {
62
+ return undefined;
63
+ }
64
+ await this._params.db.del<StorageKey>(keyArray, { ...encodingOptions });
65
+ }
66
+
67
+ async loadRange(keyPrefix: StorageKey): Promise<Chunk[]> {
68
+ if (this._lifecycleState !== LifecycleState.OPEN) {
69
+ return [];
70
+ }
71
+ const result: Chunk[] = [];
72
+ for await (const [key, value] of this._params.db.iterator<StorageKey, Uint8Array>({
73
+ gte: keyPrefix,
74
+ lte: [...keyPrefix, '\uffff'],
75
+ ...encodingOptions,
76
+ })) {
77
+ result.push({
78
+ key,
79
+ data: value,
80
+ });
81
+ }
82
+ return result;
83
+ }
84
+
85
+ async removeRange(keyPrefix: StorageKey): Promise<void> {
86
+ if (this._lifecycleState !== LifecycleState.OPEN) {
87
+ return undefined;
88
+ }
89
+ const batch = this._params.db.batch();
90
+
91
+ for await (const [key] of this._params.db.iterator<StorageKey, Uint8Array>({
92
+ gte: keyPrefix,
93
+ lte: [...keyPrefix, '\uffff'],
94
+ ...encodingOptions,
95
+ })) {
96
+ batch.del<StorageKey>(key, { ...encodingOptions });
97
+ }
98
+ await batch.write();
99
+ }
100
+ }
101
+
102
+ const keyEncoder: MixedEncoding<StorageKey, Uint8Array, StorageKey> = {
103
+ encode: (key: StorageKey): Uint8Array =>
104
+ Buffer.from(key.map((k) => k.replaceAll('%', '%25').replaceAll('-', '%2D')).join('-')),
105
+ decode: (key: Uint8Array): StorageKey =>
106
+ Buffer.from(key)
107
+ .toString()
108
+ .split('-')
109
+ .map((k) => k.replaceAll('%2D', '-').replaceAll('%25', '%')),
110
+ };
111
+
112
+ export const encodingOptions = {
113
+ keyEncoding: keyEncoder,
114
+ valueEncoding: 'buffer',
115
+ };
116
+
117
+ const isLevelDbNotFoundError = (err: any): boolean => err.code === 'LEVEL_NOT_FOUND';