@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.
- package/dist/blob-store/downloader.d.ts +5 -2
- package/dist/blob-store/downloader.d.ts.map +1 -1
- package/dist/constants.d.ts +0 -1
- package/dist/constants.d.ts.map +1 -1
- package/dist/datatype/index.d.ts +1 -1
- package/dist/datatype/index.d.ts.map +1 -1
- package/dist/discovery/local-discovery.d.ts.map +1 -1
- package/dist/import-categories.d.ts +19 -0
- package/dist/import-categories.d.ts.map +1 -0
- package/dist/intl/iso639.d.ts +4 -0
- package/dist/intl/iso639.d.ts.map +1 -0
- package/dist/intl/parse-bcp-47.d.ts +22 -0
- package/dist/intl/parse-bcp-47.d.ts.map +1 -0
- package/dist/invite/invite-api.d.ts.map +1 -1
- package/dist/lib/drizzle-helpers.d.ts +19 -1
- package/dist/lib/drizzle-helpers.d.ts.map +1 -1
- package/dist/mapeo-manager.d.ts +15 -9
- package/dist/mapeo-manager.d.ts.map +1 -1
- package/dist/mapeo-project.d.ts +4968 -3017
- package/dist/mapeo-project.d.ts.map +1 -1
- package/dist/schema/client.d.ts +246 -232
- package/dist/schema/client.d.ts.map +1 -1
- package/dist/schema/comapeo-to-drizzle.d.ts +65 -0
- package/dist/schema/comapeo-to-drizzle.d.ts.map +1 -0
- package/dist/schema/json-schema-to-drizzle.d.ts +18 -0
- package/dist/schema/json-schema-to-drizzle.d.ts.map +1 -0
- package/dist/schema/project.d.ts +2711 -1835
- package/dist/schema/project.d.ts.map +1 -1
- package/dist/schema/types.d.ts +73 -66
- package/dist/schema/types.d.ts.map +1 -1
- package/dist/translation-api.d.ts +111 -189
- package/dist/translation-api.d.ts.map +1 -1
- package/dist/utils.d.ts +10 -0
- package/dist/utils.d.ts.map +1 -1
- package/drizzle/client/0004_glorious_shape.sql +1 -0
- package/drizzle/client/meta/0000_snapshot.json +13 -9
- package/drizzle/client/meta/0001_snapshot.json +13 -9
- package/drizzle/client/meta/0002_snapshot.json +13 -9
- package/drizzle/client/meta/0003_snapshot.json +13 -9
- package/drizzle/client/meta/0004_snapshot.json +239 -0
- package/drizzle/client/meta/_journal.json +7 -0
- package/drizzle/project/meta/0000_snapshot.json +43 -24
- package/drizzle/project/meta/0001_snapshot.json +47 -26
- package/drizzle/project/meta/0002_snapshot.json +47 -26
- package/package.json +16 -8
- package/src/constants.js +0 -3
- package/src/datatype/index.js +8 -5
- package/src/discovery/local-discovery.js +3 -2
- package/src/import-categories.js +364 -0
- package/src/index-writer/index.js +1 -1
- package/src/intl/iso639.js +8118 -0
- package/src/intl/parse-bcp-47.js +91 -0
- package/src/invite/invite-api.js +2 -0
- package/src/lib/drizzle-helpers.js +70 -18
- package/src/mapeo-manager.js +138 -88
- package/src/mapeo-project.js +56 -218
- package/src/roles.js +1 -1
- package/src/schema/client.js +22 -28
- package/src/schema/comapeo-to-drizzle.js +57 -0
- package/src/schema/{schema-to-drizzle.js → json-schema-to-drizzle.js} +25 -25
- package/src/schema/project.js +24 -37
- package/src/schema/types.ts +138 -99
- package/src/translation-api.js +64 -12
- package/src/utils.js +13 -0
- package/dist/config-import.d.ts +0 -74
- package/dist/config-import.d.ts.map +0 -1
- package/dist/schema/schema-to-drizzle.d.ts +0 -20
- package/dist/schema/schema-to-drizzle.d.ts.map +0 -1
- package/dist/schema/utils.d.ts +0 -55
- package/dist/schema/utils.d.ts.map +0 -1
- package/src/config-import.js +0 -603
- 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
|
+
}
|
package/src/invite/invite-api.js
CHANGED
|
@@ -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
|
|
24
|
-
*
|
|
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
|
|
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 ${
|
|
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
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
*
|
|
59
|
-
*
|
|
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
|
|
67
|
-
const
|
|
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
|
-
|
|
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 (
|
|
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
|
+
}
|
package/src/mapeo-manager.js
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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, {
|
|
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
|
-
*
|
|
342
|
-
*
|
|
343
|
-
*
|
|
344
|
-
*
|
|
345
|
-
*
|
|
346
|
-
*
|
|
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
|
-
#
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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
|
-
|
|
407
|
+
...values,
|
|
371
408
|
})
|
|
372
409
|
.onConflictDoUpdate({
|
|
373
410
|
target: projectKeysTable.projectId,
|
|
374
|
-
set: {
|
|
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
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
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
|
|
571
|
-
(p) => p.
|
|
629
|
+
const projectSettings = allProjectSettingsResult.find(
|
|
630
|
+
(p) => p.docId === projectId
|
|
572
631
|
)
|
|
573
632
|
|
|
574
|
-
if (
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
deNullify({
|
|
633
|
+
if (hasLeftProject && includeLeft) {
|
|
634
|
+
result.push({
|
|
635
|
+
...projectInfo,
|
|
578
636
|
projectId: projectPublicId,
|
|
579
|
-
|
|
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: {
|
|
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 (
|
|
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
|
-
|
|
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
|
-
}
|