@dxos/teleport-extension-object-sync 0.8.4-main.fffef41 → 0.9.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.
- package/LICENSE +102 -5
- package/dist/lib/{node-esm → neutral}/index.mjs +214 -185
- package/dist/lib/neutral/index.mjs.map +7 -0
- package/dist/lib/neutral/meta.json +1 -0
- package/dist/types/src/blob-store.d.ts +11 -1
- package/dist/types/src/blob-store.d.ts.map +1 -1
- package/dist/types/src/blob-sync-extension.d.ts +4 -4
- package/dist/types/src/blob-sync-extension.d.ts.map +1 -1
- package/dist/types/src/blob-sync.d.ts +4 -4
- package/dist/types/src/blob-sync.d.ts.map +1 -1
- package/dist/types/src/index.d.ts +1 -0
- package/dist/types/src/index.d.ts.map +1 -1
- package/dist/types/src/sqlite-blob-store.d.ts +31 -0
- package/dist/types/src/sqlite-blob-store.d.ts.map +1 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +23 -19
- package/src/blob-store.ts +12 -1
- package/src/blob-sync-extension.ts +4 -4
- package/src/blob-sync.node.test.ts +0 -1
- package/src/blob-sync.ts +4 -4
- package/src/index.ts +1 -0
- package/src/sqlite-blob-store.ts +228 -0
- package/dist/lib/browser/index.mjs +0 -647
- package/dist/lib/browser/index.mjs.map +0 -7
- package/dist/lib/browser/meta.json +0 -1
- package/dist/lib/node-esm/index.mjs.map +0 -7
- package/dist/lib/node-esm/meta.json +0 -1
package/package.json
CHANGED
|
@@ -1,41 +1,45 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dxos/teleport-extension-object-sync",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Teleport extension to synchronize opaque data objects.",
|
|
5
5
|
"homepage": "https://dxos.org",
|
|
6
6
|
"bugs": "https://github.com/dxos/dxos/issues",
|
|
7
|
-
"
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/dxos/dxos"
|
|
10
|
+
},
|
|
11
|
+
"license": "FSL-1.1-Apache-2.0",
|
|
8
12
|
"author": "DXOS.org",
|
|
9
|
-
"sideEffects":
|
|
13
|
+
"sideEffects": false,
|
|
10
14
|
"type": "module",
|
|
11
15
|
"exports": {
|
|
12
16
|
".": {
|
|
13
17
|
"source": "./src/index.ts",
|
|
14
18
|
"types": "./dist/types/src/index.d.ts",
|
|
15
|
-
"
|
|
16
|
-
"node": "./dist/lib/node-esm/index.mjs"
|
|
19
|
+
"default": "./dist/lib/neutral/index.mjs"
|
|
17
20
|
}
|
|
18
21
|
},
|
|
19
22
|
"types": "dist/types/src/index.d.ts",
|
|
20
|
-
"typesVersions": {
|
|
21
|
-
"*": {}
|
|
22
|
-
},
|
|
23
23
|
"files": [
|
|
24
24
|
"dist",
|
|
25
25
|
"src"
|
|
26
26
|
],
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@
|
|
29
|
-
"
|
|
30
|
-
"@dxos/async": "0.
|
|
31
|
-
"@dxos/
|
|
32
|
-
"@dxos/
|
|
33
|
-
"@dxos/
|
|
34
|
-
"@dxos/
|
|
35
|
-
"@dxos/
|
|
36
|
-
"@dxos/
|
|
37
|
-
"@dxos/
|
|
38
|
-
"@dxos/
|
|
28
|
+
"@effect/sql": "0.51.1",
|
|
29
|
+
"effect": "3.21.3",
|
|
30
|
+
"@dxos/async": "0.9.0",
|
|
31
|
+
"@dxos/context": "0.9.0",
|
|
32
|
+
"@dxos/invariant": "0.9.0",
|
|
33
|
+
"@dxos/crypto": "0.9.0",
|
|
34
|
+
"@dxos/keys": "0.9.0",
|
|
35
|
+
"@dxos/effect": "0.9.0",
|
|
36
|
+
"@dxos/log": "0.9.0",
|
|
37
|
+
"@dxos/node-std": "0.9.0",
|
|
38
|
+
"@dxos/random-access-storage": "0.9.0",
|
|
39
|
+
"@dxos/protocols": "0.9.0",
|
|
40
|
+
"@dxos/sql-sqlite": "0.9.0",
|
|
41
|
+
"@dxos/teleport": "0.9.0",
|
|
42
|
+
"@dxos/util": "0.9.0"
|
|
39
43
|
},
|
|
40
44
|
"publishConfig": {
|
|
41
45
|
"access": "public"
|
package/src/blob-store.ts
CHANGED
|
@@ -21,9 +21,20 @@ export type GetOptions = {
|
|
|
21
21
|
|
|
22
22
|
export const DEFAULT_CHUNK_SIZE = 4096;
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Shared public API for blob store implementations.
|
|
26
|
+
*/
|
|
27
|
+
export interface BlobStoreApi {
|
|
28
|
+
getMeta(id: Uint8Array): Promise<BlobMeta | undefined>;
|
|
29
|
+
get(id: Uint8Array, options?: GetOptions): Promise<Uint8Array>;
|
|
30
|
+
set(data: Uint8Array): Promise<BlobMeta>;
|
|
31
|
+
setChunk(chunk: BlobChunk): Promise<BlobMeta>;
|
|
32
|
+
list(): Promise<BlobMeta[]>;
|
|
33
|
+
}
|
|
34
|
+
|
|
24
35
|
const BlobMetaCodec = schema.getCodecForType('dxos.echo.blob.BlobMeta');
|
|
25
36
|
|
|
26
|
-
export class BlobStore {
|
|
37
|
+
export class BlobStore implements BlobStoreApi {
|
|
27
38
|
constructor(private readonly _directory: Directory) {}
|
|
28
39
|
|
|
29
40
|
@synchronized
|
|
@@ -14,10 +14,10 @@ import { type BlobChunk, type BlobSyncService, type WantList } from '@dxos/proto
|
|
|
14
14
|
import { type ExtensionContext, RpcExtension } from '@dxos/teleport';
|
|
15
15
|
import { BitField } from '@dxos/util';
|
|
16
16
|
|
|
17
|
-
import { type
|
|
17
|
+
import { type BlobStoreApi } from './blob-store';
|
|
18
18
|
|
|
19
|
-
export type
|
|
20
|
-
blobStore:
|
|
19
|
+
export type BlobSyncExtensionProps = {
|
|
20
|
+
blobStore: BlobStoreApi;
|
|
21
21
|
onOpen: () => Promise<void>;
|
|
22
22
|
onClose: () => Promise<void>;
|
|
23
23
|
onAbort: () => Promise<void>;
|
|
@@ -88,7 +88,7 @@ export class BlobSyncExtension extends RpcExtension<ServiceBundle, ServiceBundle
|
|
|
88
88
|
public remoteWantList: WantList = { blobs: [] };
|
|
89
89
|
|
|
90
90
|
constructor(
|
|
91
|
-
private readonly _params:
|
|
91
|
+
private readonly _params: BlobSyncExtensionProps, // to not conflict with the base class
|
|
92
92
|
) {
|
|
93
93
|
super({
|
|
94
94
|
exposed: {
|
package/src/blob-sync.ts
CHANGED
|
@@ -11,11 +11,11 @@ import { BlobMeta } from '@dxos/protocols/proto/dxos/echo/blob';
|
|
|
11
11
|
import { type WantList } from '@dxos/protocols/proto/dxos/mesh/teleport/blobsync';
|
|
12
12
|
import { BitField, ComplexMap } from '@dxos/util';
|
|
13
13
|
|
|
14
|
-
import { type
|
|
14
|
+
import { type BlobStoreApi } from './blob-store';
|
|
15
15
|
import { BlobSyncExtension } from './blob-sync-extension';
|
|
16
16
|
|
|
17
|
-
export type
|
|
18
|
-
blobStore:
|
|
17
|
+
export type BlobSyncProps = {
|
|
18
|
+
blobStore: BlobStoreApi;
|
|
19
19
|
};
|
|
20
20
|
|
|
21
21
|
type DownloadRequest = {
|
|
@@ -36,7 +36,7 @@ export class BlobSync {
|
|
|
36
36
|
|
|
37
37
|
private readonly _extensions = new Set<BlobSyncExtension>();
|
|
38
38
|
|
|
39
|
-
constructor(private readonly _params:
|
|
39
|
+
constructor(private readonly _params: BlobSyncProps) {}
|
|
40
40
|
|
|
41
41
|
async open(): Promise<void> {}
|
|
42
42
|
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import * as SqlClient from '@effect/sql/SqlClient';
|
|
6
|
+
import type * as SqlError from '@effect/sql/SqlError';
|
|
7
|
+
import * as Effect from 'effect/Effect';
|
|
8
|
+
|
|
9
|
+
import { synchronized } from '@dxos/async';
|
|
10
|
+
import { subtleCrypto } from '@dxos/crypto';
|
|
11
|
+
import { RuntimeProvider } from '@dxos/effect';
|
|
12
|
+
import { invariant } from '@dxos/invariant';
|
|
13
|
+
import { log } from '@dxos/log';
|
|
14
|
+
import { schema } from '@dxos/protocols/proto';
|
|
15
|
+
import { BlobMeta } from '@dxos/protocols/proto/dxos/echo/blob';
|
|
16
|
+
import { type BlobChunk } from '@dxos/protocols/proto/dxos/mesh/teleport/blobsync';
|
|
17
|
+
import { SqlTransaction } from '@dxos/sql-sqlite';
|
|
18
|
+
import { BitField, arrayToBuffer } from '@dxos/util';
|
|
19
|
+
|
|
20
|
+
import { DEFAULT_CHUNK_SIZE, type BlobStoreApi, type GetOptions } from './blob-store';
|
|
21
|
+
|
|
22
|
+
const BlobMetaCodec = schema.getCodecForType('dxos.echo.blob.BlobMeta');
|
|
23
|
+
|
|
24
|
+
// SqlTransaction.SqlTransaction is the Tag class exported from the SqlTransaction namespace.
|
|
25
|
+
type SqlTransactionTag = SqlTransaction.SqlTransaction;
|
|
26
|
+
|
|
27
|
+
export type SqliteBlobStoreProps = {
|
|
28
|
+
runtime: RuntimeProvider.RuntimeProvider<SqlClient.SqlClient | SqlTransactionTag>;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* SQLite-backed BlobStore.
|
|
33
|
+
* Stores blob metadata and data in `blobs_meta` and `blobs_data` tables.
|
|
34
|
+
*/
|
|
35
|
+
export class SqliteBlobStore implements BlobStoreApi {
|
|
36
|
+
readonly #runtime: RuntimeProvider.RuntimeProvider<SqlClient.SqlClient | SqlTransactionTag>;
|
|
37
|
+
|
|
38
|
+
constructor({ runtime }: SqliteBlobStoreProps) {
|
|
39
|
+
this.#runtime = runtime;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Creates the blobs_meta and blobs_data tables if they do not exist.
|
|
44
|
+
*/
|
|
45
|
+
readonly migrate: Effect.Effect<void, SqlError.SqlError, SqlClient.SqlClient | SqlTransactionTag> = Effect.fn(
|
|
46
|
+
'SqliteBlobStore.migrate',
|
|
47
|
+
)(() =>
|
|
48
|
+
Effect.gen(function* () {
|
|
49
|
+
const sql = yield* SqlClient.SqlClient;
|
|
50
|
+
yield* sql`CREATE TABLE IF NOT EXISTS blobs_meta (
|
|
51
|
+
id TEXT PRIMARY KEY,
|
|
52
|
+
meta BLOB NOT NULL
|
|
53
|
+
)`;
|
|
54
|
+
yield* sql`CREATE TABLE IF NOT EXISTS blobs_data (
|
|
55
|
+
id TEXT PRIMARY KEY,
|
|
56
|
+
data BLOB NOT NULL
|
|
57
|
+
)`;
|
|
58
|
+
log('blobs tables ready');
|
|
59
|
+
}).pipe(Effect.withSpan('SqliteBlobStore.migrate')),
|
|
60
|
+
)();
|
|
61
|
+
|
|
62
|
+
@synchronized
|
|
63
|
+
async getMeta(id: Uint8Array): Promise<BlobMeta | undefined> {
|
|
64
|
+
return this.#getMeta(id);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
@synchronized
|
|
68
|
+
async get(id: Uint8Array, options: GetOptions = {}): Promise<Uint8Array> {
|
|
69
|
+
const metadata = await this.#getMeta(id);
|
|
70
|
+
if (!metadata) {
|
|
71
|
+
throw new Error('Blob not available');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const { offset = 0, length = metadata.length } = options;
|
|
75
|
+
if (
|
|
76
|
+
!Number.isInteger(offset) ||
|
|
77
|
+
!Number.isInteger(length) ||
|
|
78
|
+
offset < 0 ||
|
|
79
|
+
length < 0 ||
|
|
80
|
+
offset + length > metadata.length
|
|
81
|
+
) {
|
|
82
|
+
throw new Error('Invalid range');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (metadata.state === BlobMeta.State.FULLY_PRESENT) {
|
|
86
|
+
const data = await this.#getData(id);
|
|
87
|
+
if (!data) {
|
|
88
|
+
throw new Error('Blob data missing');
|
|
89
|
+
}
|
|
90
|
+
return data.subarray(offset, offset + length);
|
|
91
|
+
} else if (options.offset === undefined && options.length === undefined) {
|
|
92
|
+
throw new Error('Blob not available');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const beginChunk = Math.floor(offset / metadata.chunkSize);
|
|
96
|
+
const endChunk = Math.ceil((offset + length) / metadata.chunkSize);
|
|
97
|
+
invariant(metadata.bitfield, 'Bitfield not present');
|
|
98
|
+
invariant(metadata.bitfield.length * 8 >= endChunk, 'Invalid bitfield length');
|
|
99
|
+
|
|
100
|
+
const present = BitField.count(metadata.bitfield, beginChunk, endChunk) === endChunk - beginChunk;
|
|
101
|
+
if (!present) {
|
|
102
|
+
throw new Error('Blob not available');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const data = await this.#getData(id);
|
|
106
|
+
if (!data) {
|
|
107
|
+
throw new Error('Blob data missing');
|
|
108
|
+
}
|
|
109
|
+
return data.subarray(offset, offset + length);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
@synchronized
|
|
113
|
+
async list(): Promise<BlobMeta[]> {
|
|
114
|
+
const rows = await RuntimeProvider.runPromise(this.#runtime)(
|
|
115
|
+
Effect.gen(function* () {
|
|
116
|
+
const sql = yield* SqlClient.SqlClient;
|
|
117
|
+
return yield* sql<{ id: string; meta: Uint8Array }>`SELECT id, meta FROM blobs_meta`;
|
|
118
|
+
}),
|
|
119
|
+
);
|
|
120
|
+
return rows.map((row) => BlobMetaCodec.decode(row.meta));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
@synchronized
|
|
124
|
+
async set(data: Uint8Array): Promise<BlobMeta> {
|
|
125
|
+
const id = new Uint8Array(await subtleCrypto.digest('SHA-256', data as Uint8Array<ArrayBuffer>));
|
|
126
|
+
const bitfield = BitField.ones(Math.ceil(data.length / DEFAULT_CHUNK_SIZE));
|
|
127
|
+
const meta: BlobMeta = {
|
|
128
|
+
id,
|
|
129
|
+
state: BlobMeta.State.FULLY_PRESENT,
|
|
130
|
+
length: data.length,
|
|
131
|
+
chunkSize: DEFAULT_CHUNK_SIZE,
|
|
132
|
+
bitfield,
|
|
133
|
+
created: new Date(),
|
|
134
|
+
updated: new Date(),
|
|
135
|
+
};
|
|
136
|
+
const idHex = arrayToBuffer(id).toString('hex');
|
|
137
|
+
const encodedMeta = arrayToBuffer(BlobMetaCodec.encode(meta));
|
|
138
|
+
await RuntimeProvider.runPromise(this.#runtime)(
|
|
139
|
+
Effect.gen(function* () {
|
|
140
|
+
const sql = yield* SqlClient.SqlClient;
|
|
141
|
+
yield* sql`INSERT OR REPLACE INTO blobs_meta (id, meta) VALUES (${idHex}, ${encodedMeta})`;
|
|
142
|
+
yield* sql`INSERT OR REPLACE INTO blobs_data (id, data) VALUES (${idHex}, ${data})`;
|
|
143
|
+
}),
|
|
144
|
+
);
|
|
145
|
+
return meta;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
@synchronized
|
|
149
|
+
async setChunk(chunk: BlobChunk): Promise<BlobMeta> {
|
|
150
|
+
const idHex = arrayToBuffer(chunk.id).toString('hex');
|
|
151
|
+
|
|
152
|
+
let meta = await this.#getMeta(chunk.id);
|
|
153
|
+
if (!meta) {
|
|
154
|
+
invariant(chunk.totalLength, 'totalLength is not present');
|
|
155
|
+
meta = {
|
|
156
|
+
id: chunk.id,
|
|
157
|
+
state: BlobMeta.State.PARTIALLY_PRESENT,
|
|
158
|
+
length: chunk.totalLength,
|
|
159
|
+
chunkSize: chunk.chunkSize ?? DEFAULT_CHUNK_SIZE,
|
|
160
|
+
created: new Date(),
|
|
161
|
+
};
|
|
162
|
+
meta.bitfield = BitField.zeros(Math.ceil(meta.length / meta.chunkSize));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (chunk.chunkSize && chunk.chunkSize !== meta.chunkSize) {
|
|
166
|
+
throw new Error('Invalid chunk size');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
invariant(meta.bitfield, 'Bitfield not present');
|
|
170
|
+
invariant(chunk.chunkOffset !== undefined, 'chunkOffset is not present');
|
|
171
|
+
|
|
172
|
+
if (chunk.chunkOffset < 0 || chunk.chunkOffset + chunk.payload.length > meta.length) {
|
|
173
|
+
throw new Error('Invalid chunk range');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Write chunk into existing or new data blob.
|
|
177
|
+
const existingData = (await this.#getData(chunk.id)) ?? new Uint8Array(meta.length);
|
|
178
|
+
const newData = Buffer.from(existingData);
|
|
179
|
+
Buffer.from(chunk.payload).copy(newData, chunk.chunkOffset);
|
|
180
|
+
|
|
181
|
+
BitField.set(meta.bitfield, Math.floor(chunk.chunkOffset / meta.chunkSize), true);
|
|
182
|
+
|
|
183
|
+
const totalChunks = Math.ceil(meta.length / meta.chunkSize);
|
|
184
|
+
if (BitField.count(meta.bitfield, 0, totalChunks) === totalChunks) {
|
|
185
|
+
meta.state = BlobMeta.State.FULLY_PRESENT;
|
|
186
|
+
}
|
|
187
|
+
meta.updated = new Date();
|
|
188
|
+
|
|
189
|
+
const encodedMeta = arrayToBuffer(BlobMetaCodec.encode(meta));
|
|
190
|
+
const id = chunk.id;
|
|
191
|
+
await RuntimeProvider.runPromise(this.#runtime)(
|
|
192
|
+
Effect.gen(function* () {
|
|
193
|
+
const sql = yield* SqlClient.SqlClient;
|
|
194
|
+
yield* sql`INSERT OR REPLACE INTO blobs_meta (id, meta) VALUES (${idHex}, ${encodedMeta})`;
|
|
195
|
+
yield* sql`INSERT OR REPLACE INTO blobs_data (id, data) VALUES (${idHex}, ${newData})`;
|
|
196
|
+
}),
|
|
197
|
+
);
|
|
198
|
+
return meta;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async #getMeta(id: Uint8Array): Promise<BlobMeta | undefined> {
|
|
202
|
+
const idHex = arrayToBuffer(id).toString('hex');
|
|
203
|
+
const rows = await RuntimeProvider.runPromise(this.#runtime)(
|
|
204
|
+
Effect.gen(function* () {
|
|
205
|
+
const sql = yield* SqlClient.SqlClient;
|
|
206
|
+
return yield* sql<{ meta: Uint8Array }>`SELECT meta FROM blobs_meta WHERE id = ${idHex}`;
|
|
207
|
+
}),
|
|
208
|
+
);
|
|
209
|
+
if (rows.length === 0) {
|
|
210
|
+
return undefined;
|
|
211
|
+
}
|
|
212
|
+
return BlobMetaCodec.decode(rows[0].meta);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async #getData(id: Uint8Array): Promise<Uint8Array | undefined> {
|
|
216
|
+
const idHex = arrayToBuffer(id).toString('hex');
|
|
217
|
+
const rows = await RuntimeProvider.runPromise(this.#runtime)(
|
|
218
|
+
Effect.gen(function* () {
|
|
219
|
+
const sql = yield* SqlClient.SqlClient;
|
|
220
|
+
return yield* sql<{ data: Uint8Array }>`SELECT data FROM blobs_data WHERE id = ${idHex}`;
|
|
221
|
+
}),
|
|
222
|
+
);
|
|
223
|
+
if (rows.length === 0) {
|
|
224
|
+
return undefined;
|
|
225
|
+
}
|
|
226
|
+
return rows[0].data;
|
|
227
|
+
}
|
|
228
|
+
}
|