@comapeo/core 4.4.0 → 5.0.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 (72) hide show
  1. package/dist/blob-store/downloader.d.ts +5 -2
  2. package/dist/blob-store/downloader.d.ts.map +1 -1
  3. package/dist/constants.d.ts +0 -1
  4. package/dist/constants.d.ts.map +1 -1
  5. package/dist/datatype/index.d.ts +1 -1
  6. package/dist/datatype/index.d.ts.map +1 -1
  7. package/dist/discovery/local-discovery.d.ts.map +1 -1
  8. package/dist/import-categories.d.ts +19 -0
  9. package/dist/import-categories.d.ts.map +1 -0
  10. package/dist/intl/iso639.d.ts +4 -0
  11. package/dist/intl/iso639.d.ts.map +1 -0
  12. package/dist/intl/parse-bcp-47.d.ts +22 -0
  13. package/dist/intl/parse-bcp-47.d.ts.map +1 -0
  14. package/dist/invite/invite-api.d.ts.map +1 -1
  15. package/dist/lib/drizzle-helpers.d.ts +19 -1
  16. package/dist/lib/drizzle-helpers.d.ts.map +1 -1
  17. package/dist/mapeo-manager.d.ts +15 -9
  18. package/dist/mapeo-manager.d.ts.map +1 -1
  19. package/dist/mapeo-project.d.ts +4968 -3017
  20. package/dist/mapeo-project.d.ts.map +1 -1
  21. package/dist/schema/client.d.ts +246 -232
  22. package/dist/schema/client.d.ts.map +1 -1
  23. package/dist/schema/comapeo-to-drizzle.d.ts +65 -0
  24. package/dist/schema/comapeo-to-drizzle.d.ts.map +1 -0
  25. package/dist/schema/json-schema-to-drizzle.d.ts +18 -0
  26. package/dist/schema/json-schema-to-drizzle.d.ts.map +1 -0
  27. package/dist/schema/project.d.ts +2711 -1835
  28. package/dist/schema/project.d.ts.map +1 -1
  29. package/dist/schema/types.d.ts +73 -66
  30. package/dist/schema/types.d.ts.map +1 -1
  31. package/dist/translation-api.d.ts +111 -189
  32. package/dist/translation-api.d.ts.map +1 -1
  33. package/dist/utils.d.ts +10 -0
  34. package/dist/utils.d.ts.map +1 -1
  35. package/drizzle/client/0004_glorious_shape.sql +1 -0
  36. package/drizzle/client/meta/0000_snapshot.json +13 -9
  37. package/drizzle/client/meta/0001_snapshot.json +13 -9
  38. package/drizzle/client/meta/0002_snapshot.json +13 -9
  39. package/drizzle/client/meta/0003_snapshot.json +13 -9
  40. package/drizzle/client/meta/0004_snapshot.json +239 -0
  41. package/drizzle/client/meta/_journal.json +7 -0
  42. package/drizzle/project/meta/0000_snapshot.json +43 -24
  43. package/drizzle/project/meta/0001_snapshot.json +47 -26
  44. package/drizzle/project/meta/0002_snapshot.json +47 -26
  45. package/package.json +16 -8
  46. package/src/constants.js +0 -3
  47. package/src/datatype/index.js +8 -5
  48. package/src/discovery/local-discovery.js +3 -2
  49. package/src/import-categories.js +364 -0
  50. package/src/index-writer/index.js +1 -1
  51. package/src/intl/iso639.js +8118 -0
  52. package/src/intl/parse-bcp-47.js +91 -0
  53. package/src/invite/invite-api.js +2 -0
  54. package/src/lib/drizzle-helpers.js +70 -18
  55. package/src/mapeo-manager.js +138 -88
  56. package/src/mapeo-project.js +56 -218
  57. package/src/roles.js +1 -1
  58. package/src/schema/client.js +22 -28
  59. package/src/schema/comapeo-to-drizzle.js +57 -0
  60. package/src/schema/{schema-to-drizzle.js → json-schema-to-drizzle.js} +25 -25
  61. package/src/schema/project.js +24 -37
  62. package/src/schema/types.ts +138 -99
  63. package/src/translation-api.js +64 -12
  64. package/src/utils.js +13 -0
  65. package/dist/config-import.d.ts +0 -74
  66. package/dist/config-import.d.ts.map +0 -1
  67. package/dist/schema/schema-to-drizzle.d.ts +0 -20
  68. package/dist/schema/schema-to-drizzle.d.ts.map +0 -1
  69. package/dist/schema/utils.d.ts +0 -55
  70. package/dist/schema/utils.d.ts.map +0 -1
  71. package/src/config-import.js +0 -603
  72. package/src/schema/utils.js +0 -51
@@ -0,0 +1,91 @@
1
+ import { parse as simpleParseBcp47 } from 'bcp-47'
2
+ import { bcp47Normalize } from 'bcp-47-normalize'
3
+ import { iso6391To6393, iso6393 } from './iso639.js'
4
+ import { iso31661 } from 'iso-3166'
5
+ import { iso31661Alpha3ToAlpha2 } from 'iso-3166'
6
+ import { unM49 as unM49Array } from 'un-m49'
7
+
8
+ /**
9
+ * Map of UN M.49 country codes to their corresponding ISO 3166-1 alpha-2 country codes.
10
+ */
11
+ const unM49ToIso31661Alpha2 = new Map()
12
+
13
+ for (const region of unM49Array) {
14
+ if (!region.iso3166) continue
15
+ const alpha2 = iso31661Alpha3ToAlpha2[region.iso3166]
16
+ if (!alpha2) continue
17
+ unM49ToIso31661Alpha2.set(region.code, alpha2)
18
+ }
19
+
20
+ /**
21
+ * Normalizes the region subtag of the IETF language tag validating that the
22
+ * subtag is a valid UN M.49 geographical region code or a valid ISO 3166-1
23
+ * alpha-2 country code, and using the ISO 3166-1 alpha-2 country code if one
24
+ * exists for countries represented by UN M.49 codes.
25
+ *
26
+ * @param {string} subtag - A region subtag from an IETF BCP 47 language tag.
27
+ * @returns {string | null} - The normalized region subtag, or null if the input is not valid.
28
+ */
29
+ function normalizeRegionSubtag(subtag) {
30
+ if (!unM49.has(subtag) && !iso31661Alpha2.has(subtag)) {
31
+ return null
32
+ }
33
+ if (unM49ToIso31661Alpha2.has(subtag)) {
34
+ subtag = unM49ToIso31661Alpha2.get(subtag)
35
+ }
36
+ return subtag.toUpperCase()
37
+ }
38
+
39
+ /**
40
+ * Set of all valid UN M.49 geographical region codes.
41
+ */
42
+ const unM49 = new Set(unM49Array.map((region) => region.code))
43
+
44
+ /**
45
+ * Set of all valid ISO 3166-1 alpha-2 country codes.
46
+ */
47
+ const iso31661Alpha2 = new Set(iso31661.map((country) => country.alpha2))
48
+
49
+ /**
50
+ * Normalize a primary language subtag to ISO 639-3 if possible.
51
+ * @param {string} subtag - The primary language subtag to normalize.
52
+ * @returns {string | null} - The normalized ISO 639-3 language code, or null if the input is not valid.
53
+ */
54
+ function normalizePrimaryLanguageSubtag(subtag) {
55
+ if (subtag.length === 2) {
56
+ return iso6391To6393.get(subtag) || null
57
+ } else if (subtag.length === 3) {
58
+ return iso6393.has(subtag) ? subtag : null
59
+ }
60
+ // Only support ISO 639-1 and ISO 639-3 codes as primary language subtags
61
+ return null
62
+ }
63
+
64
+ /**
65
+ * A stricter parsing of BCP 47 language tags than the one provided by the
66
+ * `bcp-47` package, which does not check if the subtags are valid, and does not
67
+ * normalize the subtag. Primary language subtags other than ISO 639-1 or ISO
68
+ * 639-3 are ignored (parses as null). Subtags other than primary language and
69
+ * region are ignored. ISO 639-1 codes are converted to their ISO 639-3
70
+ * equivalent. UN M.49 region codes are converted to their ISO 3166-1 alpha-2
71
+ * equivalent if one exists.
72
+ *
73
+ * Will throw an error if the input is not a valid BCP 47 language tag, but will
74
+ * return language: null without throwing if the primary language subtag does
75
+ * not match our stricter criteria of requiring an ISO 639-1 or ISO 639-3
76
+ * subtag.
77
+ *
78
+ * @param {string} languageTag - A BCP 47 language tag.
79
+ * @returns {{language: string | null | undefined, region: string | null | undefined}} - The parsed and normalized language and region subtags, or null if the input is not valid.
80
+ */
81
+ export function parseBcp47(languageTag) {
82
+ const normalized = bcp47Normalize(languageTag)
83
+ const { language, region } = simpleParseBcp47(normalized)
84
+ if (!language) {
85
+ throw new Error(`Invalid BCP 47 language tag: ${languageTag}`)
86
+ }
87
+ return {
88
+ language: normalizePrimaryLanguageSubtag(language),
89
+ region: region && normalizeRegionSubtag(region),
90
+ }
91
+ }
@@ -126,6 +126,7 @@ export class InviteApi extends TypedEmitter {
126
126
  projectName,
127
127
  projectColor,
128
128
  projectDescription,
129
+ sendStats,
129
130
  } = inviteRpcMessage
130
131
  const invite = { ...inviteRpcMessage, receivedAt: Date.now() }
131
132
 
@@ -161,6 +162,7 @@ export class InviteApi extends TypedEmitter {
161
162
  projectName,
162
163
  projectColor,
163
164
  projectDescription,
165
+ sendStats,
164
166
  })
165
167
  }),
166
168
  },
@@ -1,4 +1,6 @@
1
1
  import { sql } from 'drizzle-orm'
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
2
4
  import { assert } from '../utils.js'
3
5
  import { migrate as drizzleMigrate } from 'drizzle-orm/better-sqlite3/migrator'
4
6
  import { DRIZZLE_MIGRATIONS_TABLE } from '../constants.js'
@@ -20,33 +22,31 @@ const getNumberResult = (queryResult) => {
20
22
  }
21
23
 
22
24
  /**
23
- * Get the number of rows in a table using `SELECT COUNT(*)`.
24
- * Returns 0 if the table doesn't exist.
25
+ * Get the latest migration time, or 0 if no migrations have been run or the
26
+ * migrations table has not been created yet.
25
27
  *
26
28
  * @param {BetterSQLite3Database} db
27
- * @param {string} tableName
28
29
  * @returns {number}
29
30
  */
30
- const safeCountTableRows = (db, tableName) =>
31
+ const safeGetLatestMigrationMillis = (db) =>
31
32
  db.transaction((tx) => {
32
33
  const existsQuery = sql`
33
34
  SELECT EXISTS (
34
35
  SELECT 1
35
36
  FROM sqlite_schema
36
37
  WHERE type IS 'table'
37
- AND name IS ${tableName}
38
+ AND name IS ${DRIZZLE_MIGRATIONS_TABLE}
38
39
  ) AS result
39
40
  `
40
41
  const existsResult = tx.get(existsQuery)
41
42
  const exists = getNumberResult(existsResult)
42
43
  if (!exists) return 0
43
44
 
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)
45
+ const latestMigrationQuery = sql`SELECT created_at as result FROM ${sql.identifier(
46
+ DRIZZLE_MIGRATIONS_TABLE
47
+ )} ORDER BY created_at DESC LIMIT 1`
48
+
49
+ return getNumberResult(tx.get(latestMigrationQuery))
50
50
  })
51
51
 
52
52
  /**
@@ -55,25 +55,77 @@ const safeCountTableRows = (db, tableName) =>
55
55
  */
56
56
 
57
57
  /**
58
- * Wrapper around Drizzle's migration function. Returns what happened during
59
- * migration; did a migration occur?
58
+ * Migrate db with optional JS migration functions for each migration step.
59
+ * Useful if some code needs to run for a particular migration step, to avoid
60
+ * running it unnecessarily for every new instance.
61
+ *
62
+ * Returns what happened during migration; did a migration occur?
60
63
  *
61
64
  * @param {BetterSQLite3Database} db
62
65
  * @param {object} options
63
66
  * @param {string} options.migrationsFolder
67
+ * @param {Record<string, (db: BetterSQLite3Database) => void>} [options.migrationFns]
64
68
  * @returns {MigrationResult}
65
69
  */
66
- export const migrate = (db, { migrationsFolder }) => {
67
- const migrationsBefore = safeCountTableRows(db, DRIZZLE_MIGRATIONS_TABLE)
70
+ export function migrate(db, { migrationsFolder, migrationFns = {} }) {
71
+ const journal = /** @type {unknown} */ (
72
+ JSON.parse(
73
+ fs.readFileSync(
74
+ path.join(migrationsFolder, 'meta/_journal.json'),
75
+ 'utf-8'
76
+ )
77
+ )
78
+ )
79
+ // Drizzle _could_ decide to change the journal format in the future, but this
80
+ // assertion will ensure that tests fail if they do.
81
+ assertValidJournal(journal)
82
+
83
+ const prevMigrationMillis = safeGetLatestMigrationMillis(db)
84
+
68
85
  drizzleMigrate(db, {
69
86
  migrationsFolder,
70
87
  migrationsTable: DRIZZLE_MIGRATIONS_TABLE,
71
88
  })
72
- const migrationsAfter = safeCountTableRows(db, DRIZZLE_MIGRATIONS_TABLE)
73
89
 
74
- if (migrationsAfter === migrationsBefore) return 'no migration'
90
+ for (const entry of journal.entries) {
91
+ if (entry.when <= prevMigrationMillis) continue
92
+ const fn = migrationFns[entry.tag]
93
+ if (fn) fn(db)
94
+ }
95
+
96
+ const lastMigrationMillis = safeGetLatestMigrationMillis(db)
97
+
98
+ if (lastMigrationMillis === prevMigrationMillis) return 'no migration'
75
99
 
76
- if (migrationsBefore === 0) return 'initialized database'
100
+ if (prevMigrationMillis === 0) return 'initialized database'
77
101
 
78
102
  return 'migrated'
79
103
  }
104
+
105
+ /**
106
+ * Assert that the migration journal is the expected format.
107
+ * @param {unknown} journal
108
+ * @returns {asserts journal is { version: '5', entries: { tag: string, when: number }[] }}
109
+ */
110
+ function assertValidJournal(journal) {
111
+ assert(journal && typeof journal === 'object', 'invalid journal')
112
+ assert(
113
+ 'version' in journal && journal.version === '5',
114
+ 'unexpected journal version'
115
+ )
116
+ assert(
117
+ 'entries' in journal &&
118
+ Array.isArray(journal.entries) &&
119
+ journal.entries.every(
120
+ /** @param {unknown} m */
121
+ (m) =>
122
+ m &&
123
+ typeof m === 'object' &&
124
+ 'tag' in m &&
125
+ typeof m.tag === 'string' &&
126
+ 'when' in m &&
127
+ typeof m.when === 'number'
128
+ ),
129
+ 'invalid entries in journal'
130
+ )
131
+ }
@@ -4,7 +4,6 @@ import { KeyManager } from '@mapeo/crypto'
4
4
  import Database from 'better-sqlite3'
5
5
  import { eq } from 'drizzle-orm'
6
6
  import { drizzle } from 'drizzle-orm/better-sqlite3'
7
- import { migrate } from 'drizzle-orm/better-sqlite3/migrator'
8
7
  import Hypercore from 'hypercore'
9
8
  import { TypedEmitter } from 'tiny-typed-emitter'
10
9
  import pTimeout from 'p-timeout'
@@ -14,7 +13,7 @@ import { IndexWriter } from './index-writer/index.js'
14
13
  import {
15
14
  MapeoProject,
16
15
  kBlobStore,
17
- kClearDataIfLeft,
16
+ kClearData,
18
17
  kProjectLeave,
19
18
  kSetIsArchiveDevice,
20
19
  kSetOwnDeviceInfo,
@@ -38,7 +37,6 @@ import {
38
37
  projectKeyToProjectInviteId,
39
38
  projectKeyToPublicId,
40
39
  } from './utils.js'
41
- import { UNIX_EPOCH_DATE } from './constants.js'
42
40
  import { openedNoiseSecretStream } from './lib/noise-secret-stream-helpers.js'
43
41
  import { omit } from './lib/omit.js'
44
42
  import { RandomAccessFilePool } from './core-manager/random-access-file-pool.js'
@@ -58,16 +56,20 @@ import {
58
56
  } from './sync/sync-api.js'
59
57
  import { NotFoundError } from './errors.js'
60
58
  import { WebSocket } from 'ws'
59
+ import { excludeKeys } from 'filter-obj'
60
+ import { migrate } from './lib/drizzle-helpers.js'
61
61
 
62
62
  /** @import NoiseSecretStream from '@hyperswarm/secret-stream' */
63
63
  /** @import { SetNonNullable } from 'type-fest' */
64
64
  /** @import { ProjectJoinDetails, } from './generated/rpc.js' */
65
65
  /** @import { CoreStorage, Namespace } from './types.js' */
66
66
  /** @import { DeviceInfoParam, ProjectInfo } from './schema/client.js' */
67
+ /** @import { ProjectSettings, ProjectSettingsValue } from '@comapeo/schema' */
67
68
 
68
69
  /** @typedef {SetNonNullable<ProjectKeys, 'encryptionKeys'>} ValidatedProjectKeys */
69
- /** @typedef {Pick<ProjectJoinDetails, 'projectKey' | 'encryptionKeys'> & { projectName: string, projectColor?: string, projectDescription?: string }} ProjectToAddDetails */
70
- /** @typedef {{ projectId: string, createdAt?: string, updatedAt?: string, name?: string, projectColor?: string, projectDescription?: string }} ListedProject */
70
+ /** @typedef {Pick<ProjectJoinDetails, 'projectKey' | 'encryptionKeys'> & { projectName: string, projectColor?: string, projectDescription?: string, sendStats?: boolean }} ProjectToAddDetails */
71
+ /** @typedef {Pick<ProjectSettings, 'createdAt' | 'updatedAt' | 'name' | 'projectColor' | 'projectDescription' | 'sendStats'>} ListedProjectSettings */
72
+ /** @typedef {ListedProjectSettings & { status: 'joined', projectId: string } | ProjectInfo & { status: 'joining' | 'left', projectId: string }} ListedProject */
71
73
 
72
74
  const CLIENT_SQLITE_FILE_NAME = 'client.db'
73
75
 
@@ -173,7 +175,12 @@ export class MapeoManager extends TypedEmitter {
173
175
  )
174
176
  sqlite.pragma('journal_mode=WAL')
175
177
  this.#db = drizzle(sqlite)
176
- migrate(this.#db, { migrationsFolder: clientMigrationsFolder })
178
+ migrate(this.#db, {
179
+ migrationsFolder: clientMigrationsFolder,
180
+ migrationFns: {
181
+ '0004_glorious_shape': this.#migrateLeftProjects.bind(this),
182
+ },
183
+ })
177
184
 
178
185
  this.#localPeers = new LocalPeers({ logger })
179
186
  this.#localPeers.on('peers', (peers) => {
@@ -338,22 +345,55 @@ export class MapeoManager extends TypedEmitter {
338
345
  }
339
346
 
340
347
  /**
341
- * @param {Object} opts
342
- * @param {string} opts.projectId
343
- * @param {string} opts.projectPublicId
344
- * @param {Readonly<Buffer>} opts.projectInviteId
345
- * @param {ProjectKeys} opts.projectKeys
346
- * @param {Readonly<ProjectInfo>} [opts.projectInfo]
348
+ * The migration in file `0005_military_paper_doll.sql` adds a new
349
+ * `hasLeftProject` that is used to track if a user has left a project.
350
+ * Previously we did not have a way to track if a user had left a project,
351
+ * other than checking their own role within a project. However, we can also
352
+ * use the presence of an encryption key for the data cores as an indication
353
+ * of whether a user has left, because we delete this as part of the leave
354
+ * process, and we do not, prior to this migration, have any code which
355
+ * deletes that for other reasons.
347
356
  */
348
- #saveToProjectKeysTable({
349
- projectId,
350
- projectPublicId,
351
- projectInviteId,
352
- projectKeys,
353
- projectInfo,
354
- }) {
357
+ #migrateLeftProjects() {
358
+ const existingProjectKeys = this.#db
359
+ .select({
360
+ projectId: projectKeysTable.projectId,
361
+ hasLeftProject: projectKeysTable.hasLeftProject,
362
+ keysCipher: projectKeysTable.keysCipher,
363
+ })
364
+ .from(projectKeysTable)
365
+ .all()
366
+ /** @type {{ projectId: string, hasLeftProject: boolean }[]} */
367
+ const toMigrate = []
368
+ for (const {
369
+ projectId,
370
+ hasLeftProject,
371
+ keysCipher,
372
+ } of existingProjectKeys) {
373
+ if (hasLeftProject) continue
374
+ const projectKeys = this.#decodeProjectKeysCipher(keysCipher, projectId)
375
+ if (projectKeys.encryptionKeys && !projectKeys.encryptionKeys.data) {
376
+ toMigrate.push({ projectId, hasLeftProject: true })
377
+ }
378
+ }
379
+ if (toMigrate.length === 0) return
380
+ this.#db.transaction((tx) => {
381
+ for (const { projectId, hasLeftProject } of toMigrate) {
382
+ tx.update(projectKeysTable)
383
+ .set({ hasLeftProject })
384
+ .where(eq(projectKeysTable.projectId, projectId))
385
+ .run()
386
+ }
387
+ })
388
+ }
389
+
390
+ /**
391
+ * Helper to encrypt project keys when saving to the projectKeys table
392
+ * @param {Omit<typeof projectKeysTable.$inferInsert, 'keysCipher'> & { projectKeys: ProjectKeys }} opts
393
+ */
394
+ #saveToProjectKeysTable({ projectKeys, ...values }) {
355
395
  const encoded = ProjectKeys.encode(projectKeys).finish()
356
- const nonce = projectIdToNonce(projectId)
396
+ const nonce = projectIdToNonce(values.projectId)
357
397
 
358
398
  const keysCipher = this.#keyManager.encryptLocalMessage(
359
399
  Buffer.from(encoded.buffer, encoded.byteOffset, encoded.byteLength),
@@ -363,15 +403,12 @@ export class MapeoManager extends TypedEmitter {
363
403
  this.#db
364
404
  .insert(projectKeysTable)
365
405
  .values({
366
- projectId,
367
- projectPublicId,
368
- projectInviteId,
369
406
  keysCipher,
370
- projectInfo,
407
+ ...values,
371
408
  })
372
409
  .onConflictDoUpdate({
373
410
  target: projectKeysTable.projectId,
374
- set: { projectPublicId, projectInviteId, keysCipher, projectInfo },
411
+ set: { keysCipher, ...values },
375
412
  })
376
413
  .run()
377
414
  }
@@ -481,6 +518,7 @@ export class MapeoManager extends TypedEmitter {
481
518
  .select({
482
519
  projectId: projectKeysTable.projectId,
483
520
  keysCipher: projectKeysTable.keysCipher,
521
+ hasLeftProject: projectKeysTable.hasLeftProject,
484
522
  })
485
523
  .from(projectKeysTable)
486
524
  .where(eq(projectKeysTable.projectPublicId, projectPublicId))
@@ -498,6 +536,13 @@ export class MapeoManager extends TypedEmitter {
498
536
  )
499
537
 
500
538
  const project = await this.#createProjectInstance(projectKeys)
539
+ // The leaveProject action could fail before completing, which would mean
540
+ // that data would not be cleared. To be safe, attempt to clear data when
541
+ // trying to create a project instance if we have marked the project as
542
+ // "left".
543
+ if (projectKeysTableResult.hasLeftProject) {
544
+ await project[kClearData]()
545
+ }
501
546
 
502
547
  project.once('close', () => {
503
548
  this.#activeProjects.delete(projectPublicId)
@@ -526,15 +571,22 @@ export class MapeoManager extends TypedEmitter {
526
571
  getMediaBaseUrl: this.#getMediaBaseUrl.bind(this),
527
572
  isArchiveDevice,
528
573
  makeWebsocket: this.#makeWebsocket,
574
+ getFallbackProjectInfo: () => {
575
+ return this.#db
576
+ .select({ projectInfo: projectKeysTable.projectInfo })
577
+ .from(projectKeysTable)
578
+ .where(eq(projectKeysTable.projectId, projectId))
579
+ .get()?.projectInfo
580
+ },
529
581
  })
530
- await project[kClearDataIfLeft]()
531
582
  return project
532
583
  }
533
584
 
534
585
  /**
586
+ * @param {{ includeLeft?: boolean }} [opts]
535
587
  * @returns {Promise<Array<ListedProject>>}
536
588
  */
537
- async listProjects() {
589
+ async listProjects({ includeLeft = false } = {}) {
538
590
  // We use the project keys table as the source of truth for projects that exist
539
591
  // because we will always update this table when doing a create or add
540
592
  // whereas the project table will only have projects that have been created, or added + synced
@@ -543,21 +595,27 @@ export class MapeoManager extends TypedEmitter {
543
595
  projectId: projectKeysTable.projectId,
544
596
  projectPublicId: projectKeysTable.projectPublicId,
545
597
  projectInfo: projectKeysTable.projectInfo,
598
+ hasLeftProject: projectKeysTable.hasLeftProject,
546
599
  })
547
600
  .from(projectKeysTable)
548
601
  .all()
549
602
 
550
- const allProjectsResult = this.#db
551
- .select({
552
- projectId: projectSettingsTable.docId,
553
- createdAt: projectSettingsTable.createdAt,
554
- updatedAt: projectSettingsTable.updatedAt,
555
- name: projectSettingsTable.name,
556
- projectColor: projectSettingsTable.projectColor,
557
- projectDescription: projectSettingsTable.projectDescription,
558
- })
559
- .from(projectSettingsTable)
560
- .all()
603
+ const allProjectSettingsResult =
604
+ /** @satisfies {Array<ListedProjectSettings>} */ (
605
+ this.#db
606
+ .select({
607
+ docId: projectSettingsTable.docId,
608
+ createdAt: projectSettingsTable.createdAt,
609
+ updatedAt: projectSettingsTable.updatedAt,
610
+ name: projectSettingsTable.name,
611
+ projectColor: projectSettingsTable.projectColor,
612
+ projectDescription: projectSettingsTable.projectDescription,
613
+ sendStats: projectSettingsTable.sendStats,
614
+ })
615
+ .from(projectSettingsTable)
616
+ .all()
617
+ .map((p) => deNullify(p))
618
+ )
561
619
 
562
620
  /** @type {Array<ListedProject>} */
563
621
  const result = []
@@ -566,26 +624,31 @@ export class MapeoManager extends TypedEmitter {
566
624
  projectId,
567
625
  projectPublicId,
568
626
  projectInfo,
627
+ hasLeftProject,
569
628
  } of allProjectKeysResult) {
570
- const existingProject = allProjectsResult.find(
571
- (p) => p.projectId === projectId
629
+ const projectSettings = allProjectSettingsResult.find(
630
+ (p) => p.docId === projectId
572
631
  )
573
632
 
574
- if (!existingProject) continue
575
-
576
- result.push(
577
- deNullify({
633
+ if (hasLeftProject && includeLeft) {
634
+ result.push({
635
+ ...projectInfo,
578
636
  projectId: projectPublicId,
579
- createdAt: ignoreUnixDate(existingProject?.createdAt),
580
- updatedAt: ignoreUnixDate(existingProject?.updatedAt),
581
- name: existingProject?.name || projectInfo.name,
582
- projectColor:
583
- existingProject?.projectColor || projectInfo.projectColor,
584
- projectDescription:
585
- existingProject?.projectDescription ||
586
- projectInfo.projectDescription,
637
+ status: 'left',
587
638
  })
588
- )
639
+ } else if (projectSettings) {
640
+ result.push({
641
+ ...excludeKeys(projectSettings, ['docId']),
642
+ projectId: projectPublicId,
643
+ status: 'joined',
644
+ })
645
+ } else if (!hasLeftProject) {
646
+ result.push({
647
+ ...projectInfo,
648
+ projectId: projectPublicId,
649
+ status: 'joining',
650
+ })
651
+ }
589
652
  }
590
653
 
591
654
  return result
@@ -607,6 +670,7 @@ export class MapeoManager extends TypedEmitter {
607
670
  projectName,
608
671
  projectColor,
609
672
  projectDescription,
673
+ sendStats = false,
610
674
  },
611
675
  { waitForSync = true } = {}
612
676
  ) => {
@@ -631,6 +695,7 @@ export class MapeoManager extends TypedEmitter {
631
695
  .get()
632
696
 
633
697
  if (projectExists) {
698
+ // TODO: Define behavior for adding a project that the user has left
634
699
  throw new Error(`Project with ID ${projectPublicId} already exists`)
635
700
  }
636
701
 
@@ -645,7 +710,12 @@ export class MapeoManager extends TypedEmitter {
645
710
  projectKey,
646
711
  encryptionKeys,
647
712
  },
648
- projectInfo: { name: projectName, projectColor, projectDescription },
713
+ projectInfo: {
714
+ name: projectName,
715
+ projectColor,
716
+ projectDescription,
717
+ sendStats,
718
+ },
649
719
  })
650
720
 
651
721
  // Any errors from here we need to remove project from db because it has not
@@ -653,24 +723,6 @@ export class MapeoManager extends TypedEmitter {
653
723
  let project = null
654
724
  try {
655
725
  project = await this.getProject(projectPublicId)
656
-
657
- /** @type {import('drizzle-orm').InferInsertModel<typeof projectSettingsTable>} */
658
- const settingsDoc = {
659
- schemaName: 'projectSettings',
660
- docId: projectId,
661
- versionId: 'unknown',
662
- originalVersionId: 'unknown',
663
- createdAt: UNIX_EPOCH_DATE,
664
- updatedAt: UNIX_EPOCH_DATE,
665
- deleted: false,
666
- sendStats: false,
667
- links: [],
668
- forks: [],
669
- name: projectName,
670
- projectDescription,
671
- }
672
-
673
- await this.#db.insert(projectSettingsTable).values([settingsDoc])
674
726
  this.#activeProjects.set(projectPublicId, project)
675
727
  } catch (e) {
676
728
  // Only happens if getProject or the the DB insert fails
@@ -740,6 +792,9 @@ export class MapeoManager extends TypedEmitter {
740
792
  // in the config store - defining the name of the project.
741
793
  // TODO: Enforce adding a project name in the invite method
742
794
  const isConfigSynced = configState.want === 0 && configState.have > 0
795
+ if (isRoleSynced && ownRole.sync.config === 'blocked' && isAuthSynced) {
796
+ return true
797
+ }
743
798
  if (
744
799
  isRoleSynced &&
745
800
  isProjectSettingsSynced &&
@@ -751,6 +806,7 @@ export class MapeoManager extends TypedEmitter {
751
806
  this.#l.log(
752
807
  'Pending initial sync: role %s, projectSettings %o, auth %o, config %o',
753
808
  isRoleSynced,
809
+ isProjectSettingsSynced,
754
810
  isAuthSynced,
755
811
  isConfigSynced
756
812
  )
@@ -759,12 +815,15 @@ export class MapeoManager extends TypedEmitter {
759
815
  /** @param {import('./sync/sync-state.js').State} syncState */
760
816
  const onSyncState = (syncState) => {
761
817
  clearTimeout(timeoutId)
762
- if (syncState.auth.dataToSync || syncState.config.dataToSync) {
818
+ if (
819
+ syncState.auth.dataToSync ||
820
+ (syncState.config.dataToSync && ownRole.sync.config === 'allowed')
821
+ ) {
763
822
  timeoutId = setTimeout(onTimeout, timeoutMs)
764
823
  return
765
824
  }
766
825
  project.$sync[kSyncState].off('state', onSyncState)
767
- resolve(this.#waitForInitialSync(project, { timeoutMs }))
826
+ this.#waitForInitialSync(project, { timeoutMs }).then(resolve, reject)
768
827
  }
769
828
  const onTimeout = () => {
770
829
  project.$sync[kSyncState].off('state', onSyncState)
@@ -953,10 +1012,6 @@ export class MapeoManager extends TypedEmitter {
953
1012
  * @param {string} projectPublicId
954
1013
  */
955
1014
  async leaveProject(projectPublicId) {
956
- const project = await this.getProject(projectPublicId)
957
-
958
- await project[kProjectLeave]()
959
-
960
1015
  const row = this.#db
961
1016
  .select({
962
1017
  keysCipher: projectKeysTable.keysCipher,
@@ -990,6 +1045,7 @@ export class MapeoManager extends TypedEmitter {
990
1045
  ...projectKeys,
991
1046
  encryptionKeys: updatedEncryptionKeys,
992
1047
  },
1048
+ hasLeftProject: true,
993
1049
  })
994
1050
 
995
1051
  this.#db
@@ -997,6 +1053,10 @@ export class MapeoManager extends TypedEmitter {
997
1053
  .where(eq(projectSettingsTable.docId, projectId))
998
1054
  .run()
999
1055
 
1056
+ const project = await this.getProject(projectPublicId)
1057
+
1058
+ await project[kProjectLeave]()
1059
+
1000
1060
  this.#activeProjects.delete(projectPublicId)
1001
1061
  }
1002
1062
 
@@ -1041,13 +1101,3 @@ function validateProjectKeys(projectKeys) {
1041
1101
  function hasSavedDeviceInfo(partialDeviceInfo) {
1042
1102
  return Boolean(partialDeviceInfo.name)
1043
1103
  }
1044
-
1045
- /**
1046
- * @param {string|undefined} date
1047
- * @returns {string|null}
1048
- */
1049
- function ignoreUnixDate(date) {
1050
- if (date === UNIX_EPOCH_DATE) return null
1051
- if (date === undefined) return null
1052
- return date
1053
- }