@comapeo/core 2.0.1 → 2.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.
Files changed (71) hide show
  1. package/dist/blob-store/index.d.ts +5 -8
  2. package/dist/blob-store/index.d.ts.map +1 -1
  3. package/dist/constants.d.ts +2 -1
  4. package/dist/constants.d.ts.map +1 -1
  5. package/dist/core-manager/index.d.ts +10 -0
  6. package/dist/core-manager/index.d.ts.map +1 -1
  7. package/dist/datastore/index.d.ts +5 -4
  8. package/dist/datastore/index.d.ts.map +1 -1
  9. package/dist/generated/extensions.d.ts +31 -0
  10. package/dist/generated/extensions.d.ts.map +1 -1
  11. package/dist/index.d.ts +2 -0
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/lib/drizzle-helpers.d.ts +6 -0
  14. package/dist/lib/drizzle-helpers.d.ts.map +1 -0
  15. package/dist/lib/error.d.ts +37 -0
  16. package/dist/lib/error.d.ts.map +1 -0
  17. package/dist/lib/get-own.d.ts +9 -0
  18. package/dist/lib/get-own.d.ts.map +1 -0
  19. package/dist/lib/is-hostname-ip-address.d.ts +17 -0
  20. package/dist/lib/is-hostname-ip-address.d.ts.map +1 -0
  21. package/dist/lib/ws-core-replicator.d.ts +11 -0
  22. package/dist/lib/ws-core-replicator.d.ts.map +1 -0
  23. package/dist/mapeo-manager.d.ts +18 -22
  24. package/dist/mapeo-manager.d.ts.map +1 -1
  25. package/dist/mapeo-project.d.ts +448 -15
  26. package/dist/mapeo-project.d.ts.map +1 -1
  27. package/dist/member-api.d.ts +40 -1
  28. package/dist/member-api.d.ts.map +1 -1
  29. package/dist/schema/client.d.ts +17 -5
  30. package/dist/schema/client.d.ts.map +1 -1
  31. package/dist/schema/project.d.ts +210 -0
  32. package/dist/schema/project.d.ts.map +1 -1
  33. package/dist/sync/peer-sync-controller.d.ts.map +1 -1
  34. package/dist/sync/sync-api.d.ts +28 -2
  35. package/dist/sync/sync-api.d.ts.map +1 -1
  36. package/dist/types.d.ts +3 -2
  37. package/dist/types.d.ts.map +1 -1
  38. package/drizzle/client/0001_chubby_cargill.sql +12 -0
  39. package/drizzle/client/meta/0001_snapshot.json +208 -0
  40. package/drizzle/client/meta/_journal.json +7 -0
  41. package/drizzle/project/0001_medical_wendell_rand.sql +22 -0
  42. package/drizzle/project/meta/0001_snapshot.json +1267 -0
  43. package/drizzle/project/meta/_journal.json +7 -0
  44. package/package.json +9 -5
  45. package/src/blob-store/index.js +3 -2
  46. package/src/constants.js +4 -1
  47. package/src/core-manager/index.js +58 -2
  48. package/src/datastore/README.md +1 -2
  49. package/src/datastore/index.js +4 -5
  50. package/src/fastify-plugins/blobs.js +1 -0
  51. package/src/generated/extensions.d.ts +31 -0
  52. package/src/generated/extensions.js +150 -0
  53. package/src/generated/extensions.ts +181 -0
  54. package/src/index.js +10 -0
  55. package/src/invite-api.js +1 -1
  56. package/src/lib/drizzle-helpers.js +79 -0
  57. package/src/lib/error.js +47 -0
  58. package/src/lib/get-own.js +10 -0
  59. package/src/lib/is-hostname-ip-address.js +26 -0
  60. package/src/lib/ws-core-replicator.js +47 -0
  61. package/src/mapeo-manager.js +71 -43
  62. package/src/mapeo-project.js +153 -43
  63. package/src/member-api.js +253 -2
  64. package/src/schema/client.js +4 -3
  65. package/src/schema/project.js +7 -0
  66. package/src/sync/peer-sync-controller.js +1 -0
  67. package/src/sync/sync-api.js +171 -3
  68. package/src/types.ts +4 -3
  69. package/dist/lib/timing-safe-equal.d.ts +0 -15
  70. package/dist/lib/timing-safe-equal.d.ts.map +0 -1
  71. package/src/lib/timing-safe-equal.js +0 -34
@@ -66,6 +66,20 @@ export function haveExtension_NamespaceToNumber(object: HaveExtension_Namespace)
66
66
  }
67
67
  }
68
68
 
69
+ /** A map of blob types and variants that a peer intends to download */
70
+ export interface DownloadIntentExtension {
71
+ downloadIntents: { [key: string]: DownloadIntentExtension_DownloadIntent };
72
+ }
73
+
74
+ export interface DownloadIntentExtension_DownloadIntent {
75
+ variants: string[];
76
+ }
77
+
78
+ export interface DownloadIntentExtension_DownloadIntentsEntry {
79
+ key: string;
80
+ value: DownloadIntentExtension_DownloadIntent | undefined;
81
+ }
82
+
69
83
  function createBaseProjectExtension(): ProjectExtension {
70
84
  return { authCoreKeys: [] };
71
85
  }
@@ -194,6 +208,173 @@ export const HaveExtension = {
194
208
  },
195
209
  };
196
210
 
211
+ function createBaseDownloadIntentExtension(): DownloadIntentExtension {
212
+ return { downloadIntents: {} };
213
+ }
214
+
215
+ export const DownloadIntentExtension = {
216
+ encode(message: DownloadIntentExtension, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer {
217
+ Object.entries(message.downloadIntents).forEach(([key, value]) => {
218
+ DownloadIntentExtension_DownloadIntentsEntry.encode({ key: key as any, value }, writer.uint32(10).fork())
219
+ .ldelim();
220
+ });
221
+ return writer;
222
+ },
223
+
224
+ decode(input: _m0.Reader | Uint8Array, length?: number): DownloadIntentExtension {
225
+ const reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input);
226
+ let end = length === undefined ? reader.len : reader.pos + length;
227
+ const message = createBaseDownloadIntentExtension();
228
+ while (reader.pos < end) {
229
+ const tag = reader.uint32();
230
+ switch (tag >>> 3) {
231
+ case 1:
232
+ if (tag !== 10) {
233
+ break;
234
+ }
235
+
236
+ const entry1 = DownloadIntentExtension_DownloadIntentsEntry.decode(reader, reader.uint32());
237
+ if (entry1.value !== undefined) {
238
+ message.downloadIntents[entry1.key] = entry1.value;
239
+ }
240
+ continue;
241
+ }
242
+ if ((tag & 7) === 4 || tag === 0) {
243
+ break;
244
+ }
245
+ reader.skipType(tag & 7);
246
+ }
247
+ return message;
248
+ },
249
+
250
+ create<I extends Exact<DeepPartial<DownloadIntentExtension>, I>>(base?: I): DownloadIntentExtension {
251
+ return DownloadIntentExtension.fromPartial(base ?? ({} as any));
252
+ },
253
+ fromPartial<I extends Exact<DeepPartial<DownloadIntentExtension>, I>>(object: I): DownloadIntentExtension {
254
+ const message = createBaseDownloadIntentExtension();
255
+ message.downloadIntents = Object.entries(object.downloadIntents ?? {}).reduce<
256
+ { [key: string]: DownloadIntentExtension_DownloadIntent }
257
+ >((acc, [key, value]) => {
258
+ if (value !== undefined) {
259
+ acc[key] = DownloadIntentExtension_DownloadIntent.fromPartial(value);
260
+ }
261
+ return acc;
262
+ }, {});
263
+ return message;
264
+ },
265
+ };
266
+
267
+ function createBaseDownloadIntentExtension_DownloadIntent(): DownloadIntentExtension_DownloadIntent {
268
+ return { variants: [] };
269
+ }
270
+
271
+ export const DownloadIntentExtension_DownloadIntent = {
272
+ encode(message: DownloadIntentExtension_DownloadIntent, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer {
273
+ for (const v of message.variants) {
274
+ writer.uint32(10).string(v!);
275
+ }
276
+ return writer;
277
+ },
278
+
279
+ decode(input: _m0.Reader | Uint8Array, length?: number): DownloadIntentExtension_DownloadIntent {
280
+ const reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input);
281
+ let end = length === undefined ? reader.len : reader.pos + length;
282
+ const message = createBaseDownloadIntentExtension_DownloadIntent();
283
+ while (reader.pos < end) {
284
+ const tag = reader.uint32();
285
+ switch (tag >>> 3) {
286
+ case 1:
287
+ if (tag !== 10) {
288
+ break;
289
+ }
290
+
291
+ message.variants.push(reader.string());
292
+ continue;
293
+ }
294
+ if ((tag & 7) === 4 || tag === 0) {
295
+ break;
296
+ }
297
+ reader.skipType(tag & 7);
298
+ }
299
+ return message;
300
+ },
301
+
302
+ create<I extends Exact<DeepPartial<DownloadIntentExtension_DownloadIntent>, I>>(
303
+ base?: I,
304
+ ): DownloadIntentExtension_DownloadIntent {
305
+ return DownloadIntentExtension_DownloadIntent.fromPartial(base ?? ({} as any));
306
+ },
307
+ fromPartial<I extends Exact<DeepPartial<DownloadIntentExtension_DownloadIntent>, I>>(
308
+ object: I,
309
+ ): DownloadIntentExtension_DownloadIntent {
310
+ const message = createBaseDownloadIntentExtension_DownloadIntent();
311
+ message.variants = object.variants?.map((e) => e) || [];
312
+ return message;
313
+ },
314
+ };
315
+
316
+ function createBaseDownloadIntentExtension_DownloadIntentsEntry(): DownloadIntentExtension_DownloadIntentsEntry {
317
+ return { key: "", value: undefined };
318
+ }
319
+
320
+ export const DownloadIntentExtension_DownloadIntentsEntry = {
321
+ encode(message: DownloadIntentExtension_DownloadIntentsEntry, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer {
322
+ if (message.key !== "") {
323
+ writer.uint32(10).string(message.key);
324
+ }
325
+ if (message.value !== undefined) {
326
+ DownloadIntentExtension_DownloadIntent.encode(message.value, writer.uint32(18).fork()).ldelim();
327
+ }
328
+ return writer;
329
+ },
330
+
331
+ decode(input: _m0.Reader | Uint8Array, length?: number): DownloadIntentExtension_DownloadIntentsEntry {
332
+ const reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input);
333
+ let end = length === undefined ? reader.len : reader.pos + length;
334
+ const message = createBaseDownloadIntentExtension_DownloadIntentsEntry();
335
+ while (reader.pos < end) {
336
+ const tag = reader.uint32();
337
+ switch (tag >>> 3) {
338
+ case 1:
339
+ if (tag !== 10) {
340
+ break;
341
+ }
342
+
343
+ message.key = reader.string();
344
+ continue;
345
+ case 2:
346
+ if (tag !== 18) {
347
+ break;
348
+ }
349
+
350
+ message.value = DownloadIntentExtension_DownloadIntent.decode(reader, reader.uint32());
351
+ continue;
352
+ }
353
+ if ((tag & 7) === 4 || tag === 0) {
354
+ break;
355
+ }
356
+ reader.skipType(tag & 7);
357
+ }
358
+ return message;
359
+ },
360
+
361
+ create<I extends Exact<DeepPartial<DownloadIntentExtension_DownloadIntentsEntry>, I>>(
362
+ base?: I,
363
+ ): DownloadIntentExtension_DownloadIntentsEntry {
364
+ return DownloadIntentExtension_DownloadIntentsEntry.fromPartial(base ?? ({} as any));
365
+ },
366
+ fromPartial<I extends Exact<DeepPartial<DownloadIntentExtension_DownloadIntentsEntry>, I>>(
367
+ object: I,
368
+ ): DownloadIntentExtension_DownloadIntentsEntry {
369
+ const message = createBaseDownloadIntentExtension_DownloadIntentsEntry();
370
+ message.key = object.key ?? "";
371
+ message.value = (object.value !== undefined && object.value !== null)
372
+ ? DownloadIntentExtension_DownloadIntent.fromPartial(object.value)
373
+ : undefined;
374
+ return message;
375
+ },
376
+ };
377
+
197
378
  declare const self: any | undefined;
198
379
  declare const window: any | undefined;
199
380
  declare const global: any | undefined;
package/src/index.js CHANGED
@@ -3,9 +3,19 @@ import {
3
3
  COORDINATOR_ROLE_ID,
4
4
  MEMBER_ROLE_ID,
5
5
  } from './roles.js'
6
+ import { kProjectReplicate } from './mapeo-project.js'
6
7
  export { plugin as CoMapeoMapsFastifyPlugin } from './fastify-plugins/maps.js'
7
8
  export { FastifyController } from './fastify-controller.js'
8
9
  export { MapeoManager } from './mapeo-manager.js'
10
+ /** @import { MapeoProject } from './mapeo-project.js' */
11
+
12
+ /**
13
+ * @param {MapeoProject} project
14
+ * @param {Parameters<MapeoProject.prototype[kProjectReplicate]>} args
15
+ * @returns {ReturnType<MapeoProject.prototype[kProjectReplicate]>}
16
+ */
17
+ export const replicateProject = (project, ...args) =>
18
+ project[kProjectReplicate](...args)
9
19
 
10
20
  export const roles = /** @type {const} */ ({
11
21
  CREATOR_ROLE_ID,
package/src/invite-api.js CHANGED
@@ -3,7 +3,7 @@ import { pEvent } from 'p-event'
3
3
  import { InviteResponse_Decision } from './generated/rpc.js'
4
4
  import { assert, keyToId, noop } from './utils.js'
5
5
  import HashMap from './lib/hashmap.js'
6
- import timingSafeEqual from './lib/timing-safe-equal.js'
6
+ import timingSafeEqual from 'string-timing-safe-equal'
7
7
  import { Logger } from './logger.js'
8
8
  /** @import { MapBuffers } from './types.js' */
9
9
  /**
@@ -0,0 +1,79 @@
1
+ import { sql } from 'drizzle-orm'
2
+ import { assert } from '../utils.js'
3
+ import { migrate as drizzleMigrate } from 'drizzle-orm/better-sqlite3/migrator'
4
+ import { DRIZZLE_MIGRATIONS_TABLE } from '../constants.js'
5
+ /** @import { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3' */
6
+
7
+ /**
8
+ * @param {unknown} queryResult
9
+ * @returns {number}
10
+ */
11
+ const getNumberResult = (queryResult) => {
12
+ assert(
13
+ queryResult &&
14
+ typeof queryResult === 'object' &&
15
+ 'result' in queryResult &&
16
+ typeof queryResult.result === 'number',
17
+ 'expected query to return proper result'
18
+ )
19
+ return queryResult.result
20
+ }
21
+
22
+ /**
23
+ * Get the number of rows in a table using `SELECT COUNT(*)`.
24
+ * Returns 0 if the table doesn't exist.
25
+ *
26
+ * @param {BetterSQLite3Database} db
27
+ * @param {string} tableName
28
+ * @returns {number}
29
+ */
30
+ const safeCountTableRows = (db, tableName) =>
31
+ db.transaction((tx) => {
32
+ const existsQuery = sql`
33
+ SELECT EXISTS (
34
+ SELECT 1
35
+ FROM sqlite_schema
36
+ WHERE type IS 'table'
37
+ AND name IS ${tableName}
38
+ ) AS result
39
+ `
40
+ const existsResult = tx.get(existsQuery)
41
+ const exists = getNumberResult(existsResult)
42
+ if (!exists) return 0
43
+
44
+ const countQuery = sql`
45
+ SELECT COUNT(*) AS result
46
+ FROM ${sql.identifier(tableName)}
47
+ `
48
+ const countResult = tx.get(countQuery)
49
+ return getNumberResult(countResult)
50
+ })
51
+
52
+ /**
53
+ * @internal
54
+ * @typedef {'initialized database' | 'migrated' | 'no migration'} MigrationResult
55
+ */
56
+
57
+ /**
58
+ * Wrapper around Drizzle's migration function. Returns what happened during
59
+ * migration; did a migration occur?
60
+ *
61
+ * @param {BetterSQLite3Database} db
62
+ * @param {object} options
63
+ * @param {string} options.migrationsFolder
64
+ * @returns {MigrationResult}
65
+ */
66
+ export const migrate = (db, { migrationsFolder }) => {
67
+ const migrationsBefore = safeCountTableRows(db, DRIZZLE_MIGRATIONS_TABLE)
68
+ drizzleMigrate(db, {
69
+ migrationsFolder,
70
+ migrationsTable: DRIZZLE_MIGRATIONS_TABLE,
71
+ })
72
+ const migrationsAfter = safeCountTableRows(db, DRIZZLE_MIGRATIONS_TABLE)
73
+
74
+ if (migrationsAfter === migrationsBefore) return 'no migration'
75
+
76
+ if (migrationsBefore === 0) return 'initialized database'
77
+
78
+ return 'migrated'
79
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Create an `Error` with a `code` property.
3
+ *
4
+ * @example
5
+ * const err = new ErrorWithCode('INVALID_DATA', 'data was invalid')
6
+ * err.message
7
+ * // => 'data was invalid'
8
+ * err.code
9
+ * // => 'INVALID_DATA'
10
+ */
11
+ export class ErrorWithCode extends Error {
12
+ /**
13
+ * @param {string} code
14
+ * @param {string} message
15
+ * @param {object} [options]
16
+ * @param {unknown} [options.cause]
17
+ */
18
+ constructor(code, message, options) {
19
+ super(message, options)
20
+ /** @readonly */ this.code = code
21
+ }
22
+ }
23
+
24
+ /**
25
+ * Get the error message from an object if possible.
26
+ * Otherwise, stringify the argument.
27
+ *
28
+ * @param {unknown} maybeError
29
+ * @returns {string}
30
+ * @example
31
+ * try {
32
+ * // do something
33
+ * } catch (err) {
34
+ * console.error(getErrorMessage(err))
35
+ * }
36
+ */
37
+ export function getErrorMessage(maybeError) {
38
+ if (maybeError && typeof maybeError === 'object' && 'message' in maybeError) {
39
+ try {
40
+ const { message } = maybeError
41
+ if (typeof message === 'string') return message
42
+ } catch (_err) {
43
+ // Ignored
44
+ }
45
+ }
46
+ return 'unknown error'
47
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * @template {object} T
3
+ * @template {keyof T} K
4
+ * @param {T} obj
5
+ * @param {K} key
6
+ * @returns {undefined | T[K]}
7
+ */
8
+ export function getOwn(obj, key) {
9
+ return Object.hasOwn(obj, key) ? obj[key] : undefined
10
+ }
@@ -0,0 +1,26 @@
1
+ import { isIPv4, isIPv6 } from 'node:net'
2
+
3
+ /**
4
+ * Is this hostname an IP address?
5
+ *
6
+ * @param {string} hostname
7
+ * @returns {boolean}
8
+ * @example
9
+ * isHostnameIpAddress('100.64.0.42')
10
+ * // => false
11
+ *
12
+ * isHostnameIpAddress('[2001:0db8:85a3:0000:0000:8a2e:0370:7334]')
13
+ * // => true
14
+ *
15
+ * isHostnameIpAddress('example.com')
16
+ * // => false
17
+ */
18
+ export function isHostnameIpAddress(hostname) {
19
+ if (isIPv4(hostname)) return true
20
+
21
+ if (hostname.startsWith('[') && hostname.endsWith(']')) {
22
+ return isIPv6(hostname.slice(1, -1))
23
+ }
24
+
25
+ return false
26
+ }
@@ -0,0 +1,47 @@
1
+ import { pipeline } from 'node:stream/promises'
2
+ import { Transform } from 'node:stream'
3
+ import { createWebSocketStream } from 'ws'
4
+ /** @import { WebSocket } from 'ws' */
5
+ /** @import { ReplicationStream } from '../types.js' */
6
+
7
+ /**
8
+ * @param {WebSocket} ws
9
+ * @param {ReplicationStream} replicationStream
10
+ * @returns {Promise<void>}
11
+ */
12
+ export function wsCoreReplicator(ws, replicationStream) {
13
+ // This is purely to satisfy typescript at its worst. `pipeline` expects a
14
+ // NodeJS ReadWriteStream, but our replicationStream is a streamx Duplex
15
+ // stream. The difference is that streamx does not implement the
16
+ // `setEncoding`, `unpipe`, `wrap` or `isPaused` methods. The `pipeline`
17
+ // function does not depend on any of these methods (I have read through the
18
+ // NodeJS source code at cebf21d (v22.9.0) to confirm this), so we can safely
19
+ // cast the stream to a NodeJS ReadWriteStream.
20
+ const _replicationStream = /** @type {NodeJS.ReadWriteStream} */ (
21
+ /** @type {unknown} */ (replicationStream)
22
+ )
23
+ return pipeline(
24
+ _replicationStream,
25
+ wsSafetyTransform(ws),
26
+ createWebSocketStream(ws),
27
+ _replicationStream
28
+ )
29
+ }
30
+
31
+ /**
32
+ * Avoid writing data to a closing or closed websocket, which would result in an
33
+ * error. Instead we drop the data and wait for the stream close/end events to
34
+ * propagate and close the streams cleanly.
35
+ *
36
+ * @param {WebSocket} ws
37
+ */
38
+ function wsSafetyTransform(ws) {
39
+ return new Transform({
40
+ transform(chunk, encoding, callback) {
41
+ if (ws.readyState === ws.CLOSED || ws.readyState === ws.CLOSING) {
42
+ return callback()
43
+ }
44
+ callback(null, chunk)
45
+ },
46
+ })
47
+ }
@@ -16,10 +16,11 @@ import {
16
16
  kBlobStore,
17
17
  kClearDataIfLeft,
18
18
  kProjectLeave,
19
+ kSetIsArchiveDevice,
19
20
  kSetOwnDeviceInfo,
20
21
  } from './mapeo-project.js'
21
22
  import {
22
- localDeviceInfoTable,
23
+ deviceSettingsTable,
23
24
  projectKeysTable,
24
25
  projectSettingsTable,
25
26
  } from './schema/client.js'
@@ -44,7 +45,6 @@ import { LocalPeers } from './local-peers.js'
44
45
  import { InviteApi } from './invite-api.js'
45
46
  import { LocalDiscovery } from './discovery/local-discovery.js'
46
47
  import { Roles } from './roles.js'
47
- import NoiseSecretStream from '@hyperswarm/secret-stream'
48
48
  import { Logger } from './logger.js'
49
49
  import {
50
50
  kSyncState,
@@ -52,6 +52,7 @@ import {
52
52
  kRescindFullStopRequest,
53
53
  } from './sync/sync-api.js'
54
54
  /** @import { ProjectSettingsValue as ProjectValue } from '@comapeo/schema' */
55
+ /** @import NoiseSecretStream from '@hyperswarm/secret-stream' */
55
56
  /** @import { SetNonNullable } from 'type-fest' */
56
57
  /** @import { CoreStorage, Namespace } from './types.js' */
57
58
  /** @import { DeviceInfoParam } from './schema/client.js' */
@@ -79,9 +80,6 @@ export const DEFAULT_FALLBACK_MAP_FILE_PATH = require.resolve(
79
80
  export const DEFAULT_ONLINE_STYLE_URL =
80
81
  'https://demotiles.maplibre.org/style.json'
81
82
 
82
- export const kRPC = Symbol('rpc')
83
- export const kManagerReplicate = Symbol('replicate manager')
84
-
85
83
  /**
86
84
  * @typedef {Omit<import('./local-peers.js').PeerInfo, 'protomux'>} PublicPeerInfo
87
85
  */
@@ -221,33 +219,10 @@ export class MapeoManager extends TypedEmitter {
221
219
  this.#localDiscovery.on('connection', this.#replicate.bind(this))
222
220
  }
223
221
 
224
- /**
225
- * MapeoRPC instance, used for tests
226
- */
227
- get [kRPC]() {
228
- return this.#localPeers
229
- }
230
-
231
222
  get deviceId() {
232
223
  return this.#deviceId
233
224
  }
234
225
 
235
- /**
236
- * Create a Mapeo replication stream. This replication connects the Mapeo RPC
237
- * channel and allows invites. All active projects will sync automatically to
238
- * this replication stream. Only use for local (trusted) connections, because
239
- * the RPC channel key is public. To sync a specific project without
240
- * connecting RPC, use project[kProjectReplication].
241
- *
242
- * @param {boolean} isInitiator
243
- */
244
- [kManagerReplicate](isInitiator) {
245
- const noiseStream = new NoiseSecretStream(isInitiator, undefined, {
246
- keyPair: this.#keyManager.getIdentityKeypair(),
247
- })
248
- return this.#replicate(noiseStream)
249
- }
250
-
251
226
  /**
252
227
  * @param {'blobs' | 'icons' | 'maps'} mediaType
253
228
  * @returns {Promise<string>}
@@ -507,6 +482,7 @@ export class MapeoManager extends TypedEmitter {
507
482
  async #createProjectInstance(projectKeys) {
508
483
  validateProjectKeys(projectKeys)
509
484
  const projectId = keyToId(projectKeys.projectKey)
485
+ const isArchiveDevice = this.getIsArchiveDevice()
510
486
  const project = new MapeoProject({
511
487
  ...this.#projectStorage(projectId),
512
488
  ...projectKeys,
@@ -517,6 +493,7 @@ export class MapeoManager extends TypedEmitter {
517
493
  localPeers: this.#localPeers,
518
494
  logger: this.#loggerBase,
519
495
  getMediaBaseUrl: this.#getMediaBaseUrl.bind(this),
496
+ isArchiveDevice,
520
497
  })
521
498
  await project[kClearDataIfLeft]()
522
499
  return project
@@ -579,7 +556,7 @@ export class MapeoManager extends TypedEmitter {
579
556
  * downloaded their proof of project membership and the project config.
580
557
  *
581
558
  * @param {Pick<import('./generated/rpc.js').ProjectJoinDetails, 'projectKey' | 'encryptionKeys'> & { projectName: string }} projectJoinDetails
582
- * @param {{ waitForSync?: boolean }} [opts] For internal use in tests, set opts.waitForSync = false to not wait for sync during addProject()
559
+ * @param {{ waitForSync?: boolean }} [opts] Set opts.waitForSync = false to not wait for sync during addProject()
583
560
  * @returns {Promise<string>}
584
561
  */
585
562
  addProject = async (
@@ -733,9 +710,7 @@ export class MapeoManager extends TypedEmitter {
733
710
  }
734
711
 
735
712
  /**
736
- * @typedef {Exclude<
737
- * import('./schema/client.js').DeviceInfoParam['deviceType'],
738
- * 'selfHostedServer'>} RPCDeviceType
713
+ * @typedef {import('./schema/client.js').DeviceInfoParam['deviceType']} RPCDeviceType
739
714
  */
740
715
 
741
716
  /**
@@ -746,10 +721,10 @@ export class MapeoManager extends TypedEmitter {
746
721
  async setDeviceInfo(deviceInfo) {
747
722
  const values = { deviceId: this.#deviceId, deviceInfo }
748
723
  this.#db
749
- .insert(localDeviceInfoTable)
724
+ .insert(deviceSettingsTable)
750
725
  .values(values)
751
726
  .onConflictDoUpdate({
752
- target: localDeviceInfoTable.deviceId,
727
+ target: deviceSettingsTable.deviceId,
753
728
  set: values,
754
729
  })
755
730
  .run()
@@ -762,13 +737,22 @@ export class MapeoManager extends TypedEmitter {
762
737
  })
763
738
  )
764
739
 
765
- await Promise.all(
766
- this.#localPeers.peers
767
- .filter(({ status }) => status === 'connected')
768
- .map((peer) =>
769
- this.#localPeers.sendDeviceInfo(peer.deviceId, deviceInfo)
770
- )
771
- )
740
+ if (deviceInfo.deviceType !== 'selfHostedServer') {
741
+ // We have to make a copy of this because TypeScript can't guarantee that
742
+ // `deviceInfo` won't be mutated by the time it gets to the
743
+ // `sendDeviceInfo` call below.
744
+ const deviceInfoToSend = {
745
+ ...deviceInfo,
746
+ deviceType: deviceInfo.deviceType,
747
+ }
748
+ await Promise.all(
749
+ this.#localPeers.peers
750
+ .filter(({ status }) => status === 'connected')
751
+ .map((peer) =>
752
+ this.#localPeers.sendDeviceInfo(peer.deviceId, deviceInfoToSend)
753
+ )
754
+ )
755
+ }
772
756
 
773
757
  this.#l.log('set device info %o', deviceInfo)
774
758
  }
@@ -784,8 +768,8 @@ export class MapeoManager extends TypedEmitter {
784
768
  getDeviceInfo() {
785
769
  const row = this.#db
786
770
  .select()
787
- .from(localDeviceInfoTable)
788
- .where(eq(localDeviceInfoTable.deviceId, this.#deviceId))
771
+ .from(deviceSettingsTable)
772
+ .where(eq(deviceSettingsTable.deviceId, this.#deviceId))
789
773
  .get()
790
774
  return {
791
775
  deviceId: this.#deviceId,
@@ -794,6 +778,50 @@ export class MapeoManager extends TypedEmitter {
794
778
  }
795
779
  }
796
780
 
781
+ /**
782
+ * Set whether this device is an archive device. Archive devices will download
783
+ * all media during sync, where-as non-archive devices will not download media
784
+ * original variants, and only download preview and thumbnail variants.
785
+ * @param {boolean} isArchiveDevice
786
+ * @returns {void}
787
+ */
788
+ setIsArchiveDevice(isArchiveDevice) {
789
+ const values = { deviceId: this.#deviceId, isArchiveDevice }
790
+ const result = this.#db
791
+ .insert(deviceSettingsTable)
792
+ .values(values)
793
+ .onConflictDoUpdate({
794
+ target: deviceSettingsTable.deviceId,
795
+ set: values,
796
+ })
797
+ .run()
798
+ if (!result || result.changes === 0) {
799
+ throw new Error('Failed to set isArchiveDevice')
800
+ }
801
+ for (const project of this.#activeProjects.values()) {
802
+ project[kSetIsArchiveDevice](isArchiveDevice)
803
+ }
804
+ }
805
+
806
+ /**
807
+ * Get whether this device is an archive device. Archive devices will download
808
+ * all media during sync, where-as non-archive devices will not download media
809
+ * original variants, and only download preview and thumbnail variants.
810
+ * @returns {boolean} isArchiveDevice
811
+ */
812
+ getIsArchiveDevice() {
813
+ const row = this.#db
814
+ .select()
815
+ .from(deviceSettingsTable)
816
+ .where(eq(deviceSettingsTable.deviceId, this.#deviceId))
817
+ .get()
818
+ if (typeof row?.isArchiveDevice === 'boolean') {
819
+ return row.isArchiveDevice
820
+ } else {
821
+ return true
822
+ }
823
+ }
824
+
797
825
  /**
798
826
  * @returns {InviteApi}
799
827
  */