@comapeo/core 1.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 (186) hide show
  1. package/LICENSE.md +9 -0
  2. package/README.md +31 -0
  3. package/dist/blob-api.d.ts +92 -0
  4. package/dist/blob-api.d.ts.map +1 -0
  5. package/dist/blob-store/index.d.ts +163 -0
  6. package/dist/blob-store/index.d.ts.map +1 -0
  7. package/dist/blob-store/live-download.d.ts +107 -0
  8. package/dist/blob-store/live-download.d.ts.map +1 -0
  9. package/dist/config-import.d.ts +74 -0
  10. package/dist/config-import.d.ts.map +1 -0
  11. package/dist/constants.d.ts +14 -0
  12. package/dist/constants.d.ts.map +1 -0
  13. package/dist/core-manager/bitfield-rle.d.ts +25 -0
  14. package/dist/core-manager/bitfield-rle.d.ts.map +1 -0
  15. package/dist/core-manager/core-index.d.ts +56 -0
  16. package/dist/core-manager/core-index.d.ts.map +1 -0
  17. package/dist/core-manager/index.d.ts +125 -0
  18. package/dist/core-manager/index.d.ts.map +1 -0
  19. package/dist/core-manager/random-access-file-pool.d.ts +17 -0
  20. package/dist/core-manager/random-access-file-pool.d.ts.map +1 -0
  21. package/dist/core-manager/remote-bitfield.d.ts +146 -0
  22. package/dist/core-manager/remote-bitfield.d.ts.map +1 -0
  23. package/dist/core-ownership.d.ts +112 -0
  24. package/dist/core-ownership.d.ts.map +1 -0
  25. package/dist/datastore/index.d.ts +91 -0
  26. package/dist/datastore/index.d.ts.map +1 -0
  27. package/dist/datatype/index.d.ts +108 -0
  28. package/dist/discovery/local-discovery.d.ts +64 -0
  29. package/dist/discovery/local-discovery.d.ts.map +1 -0
  30. package/dist/errors.d.ts +4 -0
  31. package/dist/errors.d.ts.map +1 -0
  32. package/dist/fastify-controller.d.ts +27 -0
  33. package/dist/fastify-controller.d.ts.map +1 -0
  34. package/dist/fastify-plugins/blobs.d.ts +6 -0
  35. package/dist/fastify-plugins/blobs.d.ts.map +1 -0
  36. package/dist/fastify-plugins/constants.d.ts +3 -0
  37. package/dist/fastify-plugins/constants.d.ts.map +1 -0
  38. package/dist/fastify-plugins/icons.d.ts +6 -0
  39. package/dist/fastify-plugins/icons.d.ts.map +1 -0
  40. package/dist/fastify-plugins/maps/index.d.ts +11 -0
  41. package/dist/fastify-plugins/maps/index.d.ts.map +1 -0
  42. package/dist/fastify-plugins/maps/offline-fallback-map.d.ts +12 -0
  43. package/dist/fastify-plugins/maps/offline-fallback-map.d.ts.map +1 -0
  44. package/dist/fastify-plugins/maps/static-maps.d.ts +11 -0
  45. package/dist/fastify-plugins/maps/static-maps.d.ts.map +1 -0
  46. package/dist/fastify-plugins/utils.d.ts +23 -0
  47. package/dist/fastify-plugins/utils.d.ts.map +1 -0
  48. package/dist/generated/extensions.d.ts +44 -0
  49. package/dist/generated/extensions.d.ts.map +1 -0
  50. package/dist/generated/keys.d.ts +36 -0
  51. package/dist/generated/keys.d.ts.map +1 -0
  52. package/dist/generated/rpc.d.ts +87 -0
  53. package/dist/generated/rpc.d.ts.map +1 -0
  54. package/dist/icon-api.d.ts +109 -0
  55. package/dist/icon-api.d.ts.map +1 -0
  56. package/dist/index-writer/index.d.ts +51 -0
  57. package/dist/index-writer/index.d.ts.map +1 -0
  58. package/dist/index.d.ts +14 -0
  59. package/dist/index.d.ts.map +1 -0
  60. package/dist/invite-api.d.ts +70 -0
  61. package/dist/invite-api.d.ts.map +1 -0
  62. package/dist/lib/hashmap.d.ts +62 -0
  63. package/dist/lib/hashmap.d.ts.map +1 -0
  64. package/dist/lib/hypercore-helpers.d.ts +6 -0
  65. package/dist/lib/hypercore-helpers.d.ts.map +1 -0
  66. package/dist/lib/noise-secret-stream-helpers.d.ts +45 -0
  67. package/dist/lib/noise-secret-stream-helpers.d.ts.map +1 -0
  68. package/dist/lib/ponyfills.d.ts +10 -0
  69. package/dist/lib/ponyfills.d.ts.map +1 -0
  70. package/dist/lib/string.d.ts +2 -0
  71. package/dist/lib/string.d.ts.map +1 -0
  72. package/dist/lib/timing-safe-equal.d.ts +15 -0
  73. package/dist/lib/timing-safe-equal.d.ts.map +1 -0
  74. package/dist/local-peers.d.ts +151 -0
  75. package/dist/local-peers.d.ts.map +1 -0
  76. package/dist/logger.d.ts +32 -0
  77. package/dist/logger.d.ts.map +1 -0
  78. package/dist/mapeo-manager.d.ts +178 -0
  79. package/dist/mapeo-manager.d.ts.map +1 -0
  80. package/dist/mapeo-project.d.ts +3233 -0
  81. package/dist/mapeo-project.d.ts.map +1 -0
  82. package/dist/member-api.d.ts +114 -0
  83. package/dist/member-api.d.ts.map +1 -0
  84. package/dist/roles.d.ts +157 -0
  85. package/dist/roles.d.ts.map +1 -0
  86. package/dist/schema/client.d.ts +284 -0
  87. package/dist/schema/client.d.ts.map +1 -0
  88. package/dist/schema/project.d.ts +1812 -0
  89. package/dist/schema/project.d.ts.map +1 -0
  90. package/dist/schema/schema-to-drizzle.d.ts +20 -0
  91. package/dist/schema/schema-to-drizzle.d.ts.map +1 -0
  92. package/dist/schema/types.d.ts +98 -0
  93. package/dist/schema/types.d.ts.map +1 -0
  94. package/dist/schema/utils.d.ts +55 -0
  95. package/dist/schema/utils.d.ts.map +1 -0
  96. package/dist/sync/core-sync-state.d.ts +252 -0
  97. package/dist/sync/core-sync-state.d.ts.map +1 -0
  98. package/dist/sync/namespace-sync-state.d.ts +47 -0
  99. package/dist/sync/namespace-sync-state.d.ts.map +1 -0
  100. package/dist/sync/peer-sync-controller.d.ts +44 -0
  101. package/dist/sync/peer-sync-controller.d.ts.map +1 -0
  102. package/dist/sync/sync-api.d.ts +158 -0
  103. package/dist/sync/sync-api.d.ts.map +1 -0
  104. package/dist/sync/sync-state.d.ts +40 -0
  105. package/dist/sync/sync-state.d.ts.map +1 -0
  106. package/dist/translation-api.d.ts +288 -0
  107. package/dist/translation-api.d.ts.map +1 -0
  108. package/dist/types.d.ts +115 -0
  109. package/dist/types.d.ts.map +1 -0
  110. package/dist/utils.d.ts +115 -0
  111. package/dist/utils.d.ts.map +1 -0
  112. package/dist/utils_types.d.ts +14 -0
  113. package/drizzle/client/0000_bumpy_carnage.sql +33 -0
  114. package/drizzle/client/meta/0000_snapshot.json +199 -0
  115. package/drizzle/client/meta/_journal.json +13 -0
  116. package/drizzle/project/0000_spooky_lady_ursula.sql +192 -0
  117. package/drizzle/project/meta/0000_snapshot.json +1137 -0
  118. package/drizzle/project/meta/_journal.json +13 -0
  119. package/package.json +202 -0
  120. package/src/blob-api.js +139 -0
  121. package/src/blob-store/index.js +325 -0
  122. package/src/blob-store/live-download.js +373 -0
  123. package/src/config-import.js +604 -0
  124. package/src/constants.js +34 -0
  125. package/src/core-manager/bitfield-rle.js +235 -0
  126. package/src/core-manager/core-index.js +87 -0
  127. package/src/core-manager/index.js +504 -0
  128. package/src/core-manager/random-access-file-pool.js +30 -0
  129. package/src/core-manager/remote-bitfield.js +416 -0
  130. package/src/core-ownership.js +235 -0
  131. package/src/datastore/README.md +46 -0
  132. package/src/datastore/index.js +234 -0
  133. package/src/datatype/README.md +33 -0
  134. package/src/datatype/index.d.ts +108 -0
  135. package/src/datatype/index.js +358 -0
  136. package/src/discovery/local-discovery.js +303 -0
  137. package/src/errors.js +5 -0
  138. package/src/fastify-controller.js +84 -0
  139. package/src/fastify-plugins/blobs.js +139 -0
  140. package/src/fastify-plugins/constants.js +5 -0
  141. package/src/fastify-plugins/icons.js +158 -0
  142. package/src/fastify-plugins/maps/index.js +173 -0
  143. package/src/fastify-plugins/maps/offline-fallback-map.js +114 -0
  144. package/src/fastify-plugins/maps/static-maps.js +271 -0
  145. package/src/fastify-plugins/utils.js +52 -0
  146. package/src/generated/README.md +3 -0
  147. package/src/generated/extensions.d.ts +44 -0
  148. package/src/generated/extensions.js +196 -0
  149. package/src/generated/extensions.ts +237 -0
  150. package/src/generated/keys.d.ts +36 -0
  151. package/src/generated/keys.js +148 -0
  152. package/src/generated/keys.ts +185 -0
  153. package/src/generated/rpc.d.ts +87 -0
  154. package/src/generated/rpc.js +389 -0
  155. package/src/generated/rpc.ts +463 -0
  156. package/src/icon-api.js +282 -0
  157. package/src/index-writer/README.md +38 -0
  158. package/src/index-writer/index.js +124 -0
  159. package/src/index.js +16 -0
  160. package/src/invite-api.js +450 -0
  161. package/src/lib/hashmap.js +91 -0
  162. package/src/lib/hypercore-helpers.js +18 -0
  163. package/src/lib/noise-secret-stream-helpers.js +37 -0
  164. package/src/lib/ponyfills.js +25 -0
  165. package/src/lib/string.js +7 -0
  166. package/src/lib/timing-safe-equal.js +34 -0
  167. package/src/local-peers.js +737 -0
  168. package/src/logger.js +99 -0
  169. package/src/mapeo-manager.js +914 -0
  170. package/src/mapeo-project.js +980 -0
  171. package/src/member-api.js +319 -0
  172. package/src/roles.js +412 -0
  173. package/src/schema/client.js +55 -0
  174. package/src/schema/project.js +44 -0
  175. package/src/schema/schema-to-drizzle.js +118 -0
  176. package/src/schema/types.ts +153 -0
  177. package/src/schema/utils.js +51 -0
  178. package/src/sync/core-sync-state.js +440 -0
  179. package/src/sync/namespace-sync-state.js +193 -0
  180. package/src/sync/peer-sync-controller.js +332 -0
  181. package/src/sync/sync-api.js +588 -0
  182. package/src/sync/sync-state.js +63 -0
  183. package/src/translation-api.js +141 -0
  184. package/src/types.ts +149 -0
  185. package/src/utils.js +210 -0
  186. package/src/utils_types.d.ts +14 -0
@@ -0,0 +1,980 @@
1
+ import path from 'path'
2
+ import Database from 'better-sqlite3'
3
+ import { decodeBlockPrefix, decode, parseVersionId } from '@comapeo/schema'
4
+ import { drizzle } from 'drizzle-orm/better-sqlite3'
5
+ import { migrate } from 'drizzle-orm/better-sqlite3/migrator'
6
+ import { discoveryKey } from 'hypercore-crypto'
7
+ import { TypedEmitter } from 'tiny-typed-emitter'
8
+
9
+ import { NAMESPACES, NAMESPACE_SCHEMAS } from './constants.js'
10
+ import { CoreManager } from './core-manager/index.js'
11
+ import { DataStore } from './datastore/index.js'
12
+ import { DataType, kCreateWithDocId } from './datatype/index.js'
13
+ import { BlobStore } from './blob-store/index.js'
14
+ import { BlobApi } from './blob-api.js'
15
+ import { IndexWriter } from './index-writer/index.js'
16
+ import { projectSettingsTable } from './schema/client.js'
17
+ import {
18
+ coreOwnershipTable,
19
+ deviceInfoTable,
20
+ fieldTable,
21
+ observationTable,
22
+ trackTable,
23
+ presetTable,
24
+ roleTable,
25
+ iconTable,
26
+ translationTable,
27
+ } from './schema/project.js'
28
+ import {
29
+ CoreOwnership,
30
+ getWinner,
31
+ mapAndValidateCoreOwnership,
32
+ } from './core-ownership.js'
33
+ import {
34
+ BLOCKED_ROLE_ID,
35
+ COORDINATOR_ROLE_ID,
36
+ Roles,
37
+ LEFT_ROLE_ID,
38
+ } from './roles.js'
39
+ import {
40
+ assert,
41
+ getDeviceId,
42
+ projectKeyToId,
43
+ projectKeyToPublicId,
44
+ valueOf,
45
+ } from './utils.js'
46
+ import { MemberApi } from './member-api.js'
47
+ import { SyncApi, kHandleDiscoveryKey } from './sync/sync-api.js'
48
+ import { Logger } from './logger.js'
49
+ import { IconApi } from './icon-api.js'
50
+ import { readConfig } from './config-import.js'
51
+ import TranslationApi from './translation-api.js'
52
+ /** @import { ProjectSettingsValue } from '@comapeo/schema' */
53
+ /** @import { CoreStorage, KeyPair, Namespace } from './types.js' */
54
+
55
+ /** @typedef {Omit<ProjectSettingsValue, 'schemaName'>} EditableProjectSettings */
56
+ /** @typedef {ProjectSettingsValue['configMetadata']} ConfigMetadata */
57
+
58
+ const CORESTORE_STORAGE_FOLDER_NAME = 'corestore'
59
+ const INDEXER_STORAGE_FOLDER_NAME = 'indexer'
60
+ export const kCoreManager = Symbol('coreManager')
61
+ export const kCoreOwnership = Symbol('coreOwnership')
62
+ export const kSetOwnDeviceInfo = Symbol('kSetOwnDeviceInfo')
63
+ export const kBlobStore = Symbol('blobStore')
64
+ export const kProjectReplicate = Symbol('replicate project')
65
+ export const kDataTypes = Symbol('dataTypes')
66
+ export const kProjectLeave = Symbol('leave project')
67
+ export const kClearDataIfLeft = Symbol('clear data if left project')
68
+
69
+ const EMPTY_PROJECT_SETTINGS = Object.freeze({})
70
+
71
+ /**
72
+ * @extends {TypedEmitter<{ close: () => void }>}
73
+ */
74
+ export class MapeoProject extends TypedEmitter {
75
+ #projectId
76
+ #deviceId
77
+ #coreManager
78
+ #indexWriter
79
+ #dataStores
80
+ #dataTypes
81
+ #blobStore
82
+ #coreOwnership
83
+ #roles
84
+ #sqlite
85
+ #memberApi
86
+ #iconApi
87
+ #syncApi
88
+ /** @type {TranslationApi} */
89
+ #translationApi
90
+ #l
91
+ /** @type {Boolean} this avoids loading multiple configs in parallel */
92
+ #loadingConfig
93
+
94
+ static EMPTY_PROJECT_SETTINGS = EMPTY_PROJECT_SETTINGS
95
+
96
+ /**
97
+ * @param {Object} opts
98
+ * @param {string} opts.dbPath Path to store project sqlite db. Use `:memory:` for memory storage
99
+ * @param {string} opts.projectMigrationsFolder path for drizzle migration folder for project
100
+ * @param {import('@mapeo/crypto').KeyManager} opts.keyManager mapeo/crypto KeyManager instance
101
+ * @param {Buffer} opts.projectKey 32-byte public key of the project creator core
102
+ * @param {Buffer} [opts.projectSecretKey] 32-byte secret key of the project creator core
103
+ * @param {import('./generated/keys.js').EncryptionKeys} opts.encryptionKeys Encryption keys for each namespace
104
+ * @param {import('drizzle-orm/better-sqlite3').BetterSQLite3Database} opts.sharedDb
105
+ * @param {IndexWriter} opts.sharedIndexWriter
106
+ * @param {CoreStorage} opts.coreStorage Folder to store all hypercore data
107
+ * @param {(mediaType: 'blobs' | 'icons') => Promise<string>} opts.getMediaBaseUrl
108
+ * @param {import('./local-peers.js').LocalPeers} opts.localPeers
109
+ * @param {Logger} [opts.logger]
110
+ *
111
+ */
112
+ constructor({
113
+ dbPath,
114
+ projectMigrationsFolder,
115
+ sharedDb,
116
+ sharedIndexWriter,
117
+ coreStorage,
118
+ keyManager,
119
+ projectKey,
120
+ projectSecretKey,
121
+ encryptionKeys,
122
+ getMediaBaseUrl,
123
+ localPeers,
124
+ logger,
125
+ }) {
126
+ super()
127
+
128
+ this.#l = Logger.create('project', logger)
129
+ this.#deviceId = getDeviceId(keyManager)
130
+ this.#projectId = projectKeyToId(projectKey)
131
+ this.#loadingConfig = false
132
+
133
+ ///////// 1. Setup database
134
+ this.#sqlite = new Database(dbPath)
135
+ const db = drizzle(this.#sqlite)
136
+ migrate(db, { migrationsFolder: projectMigrationsFolder })
137
+
138
+ ///////// 2. Setup random-access-storage functions
139
+
140
+ /** @type {ConstructorParameters<typeof CoreManager>[0]['storage']} */
141
+ const coreManagerStorage = (name) =>
142
+ coreStorage(path.join(CORESTORE_STORAGE_FOLDER_NAME, name))
143
+
144
+ /** @type {ConstructorParameters<typeof DataStore>[0]['storage']} */
145
+ const indexerStorage = (name) =>
146
+ coreStorage(path.join(INDEXER_STORAGE_FOLDER_NAME, name))
147
+
148
+ ///////// 3. Create instances
149
+
150
+ this.#coreManager = new CoreManager({
151
+ projectSecretKey,
152
+ encryptionKeys,
153
+ projectKey,
154
+ keyManager,
155
+ storage: coreManagerStorage,
156
+ db,
157
+ logger: this.#l,
158
+ })
159
+
160
+ this.#indexWriter = new IndexWriter({
161
+ tables: [
162
+ observationTable,
163
+ trackTable,
164
+ presetTable,
165
+ fieldTable,
166
+ coreOwnershipTable,
167
+ roleTable,
168
+ deviceInfoTable,
169
+ iconTable,
170
+ translationTable,
171
+ ],
172
+ sqlite: this.#sqlite,
173
+ getWinner,
174
+ mapDoc: (doc, version) => {
175
+ switch (doc.schemaName) {
176
+ case 'coreOwnership':
177
+ return mapAndValidateCoreOwnership(doc, version)
178
+ case 'deviceInfo':
179
+ return mapAndValidateDeviceInfo(doc, version)
180
+ default:
181
+ return doc
182
+ }
183
+ },
184
+ logger: this.#l,
185
+ })
186
+
187
+ this.#dataStores = {
188
+ auth: new DataStore({
189
+ coreManager: this.#coreManager,
190
+ namespace: 'auth',
191
+ batch: (entries) => this.#indexWriter.batch(entries),
192
+ storage: indexerStorage,
193
+ }),
194
+ config: new DataStore({
195
+ coreManager: this.#coreManager,
196
+ namespace: 'config',
197
+ batch: (entries) =>
198
+ this.#handleConfigEntries(entries, {
199
+ projectIndexWriter: this.#indexWriter,
200
+ sharedIndexWriter,
201
+ }),
202
+ storage: indexerStorage,
203
+ }),
204
+ data: new DataStore({
205
+ coreManager: this.#coreManager,
206
+ namespace: 'data',
207
+ batch: (entries) => this.#indexWriter.batch(entries),
208
+ storage: indexerStorage,
209
+ }),
210
+ }
211
+
212
+ /** @type {typeof TranslationApi.prototype.get} */
213
+ const getTranslations = (...args) => this.$translation.get(...args)
214
+ this.#dataTypes = {
215
+ observation: new DataType({
216
+ dataStore: this.#dataStores.data,
217
+ table: observationTable,
218
+ db,
219
+ getTranslations,
220
+ }),
221
+ track: new DataType({
222
+ dataStore: this.#dataStores.data,
223
+ table: trackTable,
224
+ db,
225
+ getTranslations,
226
+ }),
227
+ preset: new DataType({
228
+ dataStore: this.#dataStores.config,
229
+ table: presetTable,
230
+ db,
231
+ getTranslations,
232
+ }),
233
+ field: new DataType({
234
+ dataStore: this.#dataStores.config,
235
+ table: fieldTable,
236
+ db,
237
+ getTranslations,
238
+ }),
239
+ projectSettings: new DataType({
240
+ dataStore: this.#dataStores.config,
241
+ table: projectSettingsTable,
242
+ db: sharedDb,
243
+ getTranslations,
244
+ }),
245
+ coreOwnership: new DataType({
246
+ dataStore: this.#dataStores.auth,
247
+ table: coreOwnershipTable,
248
+ db,
249
+ getTranslations,
250
+ }),
251
+ role: new DataType({
252
+ dataStore: this.#dataStores.auth,
253
+ table: roleTable,
254
+ db,
255
+ getTranslations,
256
+ }),
257
+ deviceInfo: new DataType({
258
+ dataStore: this.#dataStores.config,
259
+ table: deviceInfoTable,
260
+ db,
261
+ getTranslations,
262
+ }),
263
+ icon: new DataType({
264
+ dataStore: this.#dataStores.config,
265
+ table: iconTable,
266
+ db,
267
+ getTranslations,
268
+ }),
269
+ translation: new DataType({
270
+ dataStore: this.#dataStores.config,
271
+ table: translationTable,
272
+ db,
273
+ getTranslations: () => {
274
+ throw new Error('Cannot get translation for translations')
275
+ },
276
+ }),
277
+ }
278
+ const identityKeypair = keyManager.getIdentityKeypair()
279
+ const coreKeypairs = getCoreKeypairs({
280
+ projectKey,
281
+ projectSecretKey,
282
+ keyManager,
283
+ })
284
+ this.#coreOwnership = new CoreOwnership({
285
+ dataType: this.#dataTypes.coreOwnership,
286
+ coreKeypairs,
287
+ identityKeypair,
288
+ })
289
+ this.#roles = new Roles({
290
+ dataType: this.#dataTypes.role,
291
+ coreOwnership: this.#coreOwnership,
292
+ coreManager: this.#coreManager,
293
+ projectKey: projectKey,
294
+ deviceKey: keyManager.getIdentityKeypair().publicKey,
295
+ })
296
+
297
+ this.#memberApi = new MemberApi({
298
+ deviceId: this.#deviceId,
299
+ roles: this.#roles,
300
+ coreOwnership: this.#coreOwnership,
301
+ encryptionKeys,
302
+ projectKey,
303
+ rpc: localPeers,
304
+ dataTypes: {
305
+ deviceInfo: this.#dataTypes.deviceInfo,
306
+ project: this.#dataTypes.projectSettings,
307
+ },
308
+ })
309
+
310
+ const projectPublicId = projectKeyToPublicId(projectKey)
311
+
312
+ this.#blobStore = new BlobStore({
313
+ coreManager: this.#coreManager,
314
+ })
315
+
316
+ this.$blobs = new BlobApi({
317
+ blobStore: this.#blobStore,
318
+ getMediaBaseUrl: async () => {
319
+ let base = await getMediaBaseUrl('blobs')
320
+ if (!base.endsWith('/')) {
321
+ base += '/'
322
+ }
323
+ return base + projectPublicId
324
+ },
325
+ })
326
+
327
+ this.#iconApi = new IconApi({
328
+ iconDataStore: this.#dataStores.config,
329
+ iconDataType: this.#dataTypes.icon,
330
+ getMediaBaseUrl: async () => {
331
+ let base = await getMediaBaseUrl('icons')
332
+ if (!base.endsWith('/')) {
333
+ base += '/'
334
+ }
335
+ return base + projectPublicId
336
+ },
337
+ })
338
+
339
+ this.#syncApi = new SyncApi({
340
+ coreManager: this.#coreManager,
341
+ coreOwnership: this.#coreOwnership,
342
+ roles: this.#roles,
343
+ logger: this.#l,
344
+ })
345
+
346
+ this.#translationApi = new TranslationApi({
347
+ dataType: this.#dataTypes.translation,
348
+ table: translationTable,
349
+ })
350
+
351
+ ///////// 4. Replicate local peers automatically
352
+
353
+ // Replicate already connected local peers
354
+ for (const peer of localPeers.peers) {
355
+ if (peer.status !== 'connected') continue
356
+ this.#coreManager.creatorCore.replicate(peer.protomux)
357
+ }
358
+
359
+ /**
360
+ * @type {import('./local-peers.js').LocalPeersEvents['peer-add']}
361
+ */
362
+ const onPeerAdd = (peer) => {
363
+ this.#coreManager.creatorCore.replicate(peer.protomux)
364
+ }
365
+
366
+ /**
367
+ * @type {import('./local-peers.js').LocalPeersEvents['discovery-key']}
368
+ */
369
+ const onDiscoverykey = (discoveryKey, stream) => {
370
+ this.#syncApi[kHandleDiscoveryKey](discoveryKey, stream)
371
+ }
372
+
373
+ // When a new peer is found, try to replicate (if it is not a member of the
374
+ // project it will fail the role check and be ignored)
375
+ localPeers.on('peer-add', onPeerAdd)
376
+
377
+ // This happens whenever a peer replicates a core to the stream. SyncApi
378
+ // handles replicating this core if we also have it, or requesting the key
379
+ // for the core.
380
+ localPeers.on('discovery-key', onDiscoverykey)
381
+
382
+ this.once('close', () => {
383
+ localPeers.off('peer-add', onPeerAdd)
384
+ localPeers.off('discovery-key', onDiscoverykey)
385
+ })
386
+
387
+ this.#l.log('Created project instance %h', projectKey)
388
+ }
389
+
390
+ /**
391
+ * CoreManager instance, used for tests
392
+ */
393
+ get [kCoreManager]() {
394
+ return this.#coreManager
395
+ }
396
+
397
+ /**
398
+ * CoreOwnership instance, used for tests
399
+ */
400
+ get [kCoreOwnership]() {
401
+ return this.#coreOwnership
402
+ }
403
+
404
+ /**
405
+ * DataTypes object mappings, used for tests
406
+ */
407
+ get [kDataTypes]() {
408
+ return this.#dataTypes
409
+ }
410
+
411
+ get [kBlobStore]() {
412
+ return this.#blobStore
413
+ }
414
+
415
+ get deviceId() {
416
+ return this.#deviceId
417
+ }
418
+
419
+ /**
420
+ * Resolves when hypercores have all loaded
421
+ *
422
+ * @returns {Promise<void>}
423
+ */
424
+ ready() {
425
+ return this.#coreManager.ready()
426
+ }
427
+
428
+ /**
429
+ */
430
+ async close() {
431
+ this.#l.log('closing project %h', this.#projectId)
432
+ const dataStorePromises = []
433
+ for (const dataStore of Object.values(this.#dataStores)) {
434
+ dataStorePromises.push(dataStore.close())
435
+ }
436
+ await Promise.all(dataStorePromises)
437
+ await this.#coreManager.close()
438
+
439
+ this.#sqlite.close()
440
+
441
+ this.emit('close')
442
+ }
443
+
444
+ /**
445
+ * @param {import('multi-core-indexer').Entry[]} entries
446
+ * @param {{projectIndexWriter: IndexWriter, sharedIndexWriter: IndexWriter}} indexWriters
447
+ */
448
+ async #handleConfigEntries(
449
+ entries,
450
+ { projectIndexWriter, sharedIndexWriter }
451
+ ) {
452
+ /** @type {import('multi-core-indexer').Entry[]} */
453
+ const projectSettingsEntries = []
454
+ /** @type {import('multi-core-indexer').Entry[]} */
455
+ const otherEntries = []
456
+
457
+ for (const entry of entries) {
458
+ try {
459
+ const { schemaName } = decodeBlockPrefix(entry.block)
460
+
461
+ if (schemaName === 'projectSettings') {
462
+ projectSettingsEntries.push(entry)
463
+ } else if (schemaName === 'translation') {
464
+ const doc = decode(entry.block, {
465
+ coreDiscoveryKey: entry.key,
466
+ index: entry.index,
467
+ })
468
+
469
+ assert(doc.schemaName === 'translation', 'expected a translation doc')
470
+ this.#translationApi.index(doc)
471
+ otherEntries.push(entry)
472
+ } else {
473
+ otherEntries.push(entry)
474
+ }
475
+ } catch {
476
+ // Ignore errors thrown by values that can't be decoded for now
477
+ }
478
+ }
479
+ // TODO: Note that docs indexed to the shared index writer (project
480
+ // settings) are not currently returned here, so it is not possible to
481
+ // subscribe to updates for projectSettings
482
+ const [indexed] = await Promise.all([
483
+ projectIndexWriter.batch(otherEntries),
484
+ sharedIndexWriter.batch(projectSettingsEntries),
485
+ ])
486
+ return indexed
487
+ }
488
+
489
+ get observation() {
490
+ return this.#dataTypes.observation
491
+ }
492
+ get track() {
493
+ return this.#dataTypes.track
494
+ }
495
+ get preset() {
496
+ return this.#dataTypes.preset
497
+ }
498
+ get field() {
499
+ return this.#dataTypes.field
500
+ }
501
+
502
+ get $member() {
503
+ return this.#memberApi
504
+ }
505
+
506
+ get $sync() {
507
+ return this.#syncApi
508
+ }
509
+
510
+ get $translation() {
511
+ return this.#translationApi
512
+ }
513
+
514
+ /**
515
+ * @param {Partial<EditableProjectSettings>} settings
516
+ * @returns {Promise<EditableProjectSettings>}
517
+ */
518
+ async $setProjectSettings(settings) {
519
+ const { projectSettings } = this.#dataTypes
520
+
521
+ // We only want to catch the error to the getByDocId call
522
+ // Using try/catch for this is a little verbose when dealing with TS types
523
+ const existing = await projectSettings
524
+ .getByDocId(this.#projectId)
525
+ .catch(() => {
526
+ // project does not exist so return null
527
+ return null
528
+ })
529
+
530
+ if (existing) {
531
+ return extractEditableProjectSettings(
532
+ await projectSettings.update([existing.versionId, ...existing.forks], {
533
+ ...valueOf(existing),
534
+ ...settings,
535
+ })
536
+ )
537
+ }
538
+
539
+ return extractEditableProjectSettings(
540
+ await projectSettings[kCreateWithDocId](this.#projectId, {
541
+ ...settings,
542
+ schemaName: 'projectSettings',
543
+ })
544
+ )
545
+ }
546
+
547
+ /**
548
+ * @returns {Promise<EditableProjectSettings>}
549
+ */
550
+ async $getProjectSettings() {
551
+ try {
552
+ return extractEditableProjectSettings(
553
+ await this.#dataTypes.projectSettings.getByDocId(this.#projectId)
554
+ )
555
+ } catch (e) {
556
+ this.#l.log('No project settings')
557
+ return /** @type {EditableProjectSettings} */ (EMPTY_PROJECT_SETTINGS)
558
+ }
559
+ }
560
+
561
+ async $getOwnRole() {
562
+ return this.#roles.getRole(this.#deviceId)
563
+ }
564
+
565
+ /**
566
+ * @param {string} originalVersionId The `originalVersionId` from a document.
567
+ * @returns {Promise<string>} The device ID for this creator.
568
+ * @throws When device ID cannot be found.
569
+ */
570
+ async $originalVersionIdToDeviceId(originalVersionId) {
571
+ const { coreDiscoveryKey } = parseVersionId(originalVersionId)
572
+ const coreId = this.#coreManager
573
+ .getCoreByDiscoveryKey(coreDiscoveryKey)
574
+ ?.key.toString('hex')
575
+ if (!coreId) throw new Error('NotFound')
576
+ return this.#coreOwnership.getOwner(coreId)
577
+ }
578
+
579
+ /**
580
+ * Replicate a project to a @hyperswarm/secret-stream. Invites will not
581
+ * function because the RPC channel is not connected for project replication,
582
+ * and only this project will replicate (to replicate multiple projects you
583
+ * need to replicate the manager instance via manager[kManagerReplicate])
584
+ *
585
+ * @param {Parameters<import('hypercore')['replicate']>[0]} stream A duplex stream, a @hyperswarm/secret-stream, or a Protomux instance
586
+ */
587
+ [kProjectReplicate](stream) {
588
+ // @ts-expect-error - hypercore types need updating
589
+ const replicationStream = this.#coreManager.creatorCore.replicate(stream, {
590
+ // @ts-ignore - hypercore types do not currently include this option
591
+ ondiscoverykey: async (discoveryKey) => {
592
+ const protomux =
593
+ /** @type {import('protomux')<import('@hyperswarm/secret-stream')>} */ (
594
+ replicationStream.noiseStream.userData
595
+ )
596
+ this.#syncApi[kHandleDiscoveryKey](discoveryKey, protomux)
597
+ },
598
+ })
599
+ return replicationStream
600
+ }
601
+
602
+ /**
603
+ * @param {Pick<import('@comapeo/schema').DeviceInfoValue, 'name' | 'deviceType'>} value
604
+ * @returns {Promise<import('@comapeo/schema').DeviceInfo>}
605
+ */
606
+ async [kSetOwnDeviceInfo](value) {
607
+ const { deviceInfo } = this.#dataTypes
608
+
609
+ const configCoreId = this.#coreManager
610
+ .getWriterCore('config')
611
+ .key.toString('hex')
612
+
613
+ const doc = {
614
+ name: value.name,
615
+ deviceType: value.deviceType,
616
+ schemaName: /** @type {const} */ ('deviceInfo'),
617
+ }
618
+
619
+ let existingDoc
620
+ try {
621
+ existingDoc = await deviceInfo.getByDocId(configCoreId)
622
+ } catch (err) {
623
+ return await deviceInfo[kCreateWithDocId](configCoreId, doc)
624
+ }
625
+
626
+ return deviceInfo.update(existingDoc.versionId, doc)
627
+ }
628
+
629
+ /**
630
+ * @returns {import('./icon-api.js').IconApi}
631
+ */
632
+ get $icons() {
633
+ return this.#iconApi
634
+ }
635
+
636
+ /**
637
+ * @returns {Promise<void>}
638
+ */
639
+ async #throwIfCannotLeaveProject() {
640
+ const roleDocs = await this.#dataTypes.role.getMany()
641
+
642
+ const ownRole = roleDocs.find(({ docId }) => this.#deviceId === docId)
643
+
644
+ if (ownRole?.roleId === BLOCKED_ROLE_ID) {
645
+ throw new Error('Cannot leave a project as a blocked device')
646
+ }
647
+
648
+ const allRoles = await this.#roles.getAll()
649
+
650
+ const isOnlyDevice = allRoles.size <= 1
651
+ if (isOnlyDevice) return
652
+
653
+ const projectCreatorDeviceId = await this.#coreOwnership.getOwner(
654
+ this.#projectId
655
+ )
656
+
657
+ for (const deviceId of allRoles.keys()) {
658
+ if (deviceId === this.#deviceId) continue
659
+ const isCreatorOrCoordinator =
660
+ deviceId === projectCreatorDeviceId ||
661
+ roleDocs.some(
662
+ (doc) => doc.docId === deviceId && doc.roleId === COORDINATOR_ROLE_ID
663
+ )
664
+ if (isCreatorOrCoordinator) return
665
+ }
666
+
667
+ throw new Error(
668
+ 'Cannot leave a project that does not have an external creator or another coordinator'
669
+ )
670
+ }
671
+
672
+ async [kProjectLeave]() {
673
+ await this.#throwIfCannotLeaveProject()
674
+
675
+ await this.#roles.assignRole(this.#deviceId, LEFT_ROLE_ID)
676
+
677
+ await this[kClearDataIfLeft]()
678
+ }
679
+
680
+ /**
681
+ * Clear data if we've left the project. No-op if you're still in the project.
682
+ * @returns {Promise<void>}
683
+ */
684
+ async [kClearDataIfLeft]() {
685
+ const role = await this.$getOwnRole()
686
+ if (role.roleId !== LEFT_ROLE_ID) {
687
+ return
688
+ }
689
+
690
+ const namespacesWithoutAuth =
691
+ /** @satisfies {Exclude<Namespace, 'auth'>[]} */ ([
692
+ 'config',
693
+ 'data',
694
+ 'blob',
695
+ 'blobIndex',
696
+ ])
697
+ const dataStoresToUnlink = Object.values(this.#dataStores).filter(
698
+ (dataStore) => dataStore.namespace !== 'auth'
699
+ )
700
+
701
+ await Promise.all(dataStoresToUnlink.map((ds) => ds.close()))
702
+
703
+ await Promise.all(
704
+ namespacesWithoutAuth.flatMap((namespace) => [
705
+ this.#coreManager.getWriterCore(namespace).core.close(),
706
+ this.#coreManager.deleteOthersData(namespace),
707
+ ])
708
+ )
709
+
710
+ await Promise.all(dataStoresToUnlink.map((ds) => ds.unlink()))
711
+
712
+ /** @type {Set<string>} */
713
+ const authSchemas = new Set(NAMESPACE_SCHEMAS.auth)
714
+ for (const schemaName of this.#indexWriter.schemas) {
715
+ const isAuthSchema = authSchemas.has(schemaName)
716
+ if (!isAuthSchema) this.#indexWriter.deleteSchema(schemaName)
717
+ }
718
+ }
719
+
720
+ /** @param {Object} opts
721
+ * @param {string} opts.configPath
722
+ * @returns {Promise<Error[]>}
723
+ */
724
+ async importConfig({ configPath }) {
725
+ assert(
726
+ !this.#loadingConfig,
727
+ 'Cannot run multiple config imports at the same time'
728
+ )
729
+ this.#loadingConfig = true
730
+
731
+ try {
732
+ // check for already present fields and presets and delete them if exist
733
+ const presetsToDelete = await grabDocsToDelete(this.preset)
734
+ const fieldsToDelete = await grabDocsToDelete(this.field)
735
+ // delete only translations that refer to deleted fields and presets
736
+ const translationsToDelete = await grabTranslationsToDelete({
737
+ logger: this.#l,
738
+ translation: this.$translation.dataType,
739
+ preset: this.preset,
740
+ field: this.field,
741
+ })
742
+
743
+ const config = await readConfig(configPath)
744
+ /** @type {Map<string, import('./icon-api.js').IconRef>} */
745
+ const iconNameToRef = new Map()
746
+ /** @type {Map<string, import('@comapeo/schema').PresetValue['fieldRefs'][1]>} */
747
+ const fieldNameToRef = new Map()
748
+ /** @type {Map<string,import('@comapeo/schema').TranslationValue['docRef']>} */
749
+ const presetNameToRef = new Map()
750
+
751
+ // Do this in serial not parallel to avoid memory issues (avoid keeping all icon buffers in memory)
752
+ for await (const icon of config.icons()) {
753
+ const { docId, versionId } = await this.#iconApi.create(icon)
754
+ iconNameToRef.set(icon.name, { docId, versionId })
755
+ }
756
+
757
+ // Ok to create fields and presets in parallel
758
+ const fieldPromises = []
759
+ for (const { name, value } of config.fields()) {
760
+ fieldPromises.push(
761
+ this.#dataTypes.field.create(value).then(({ docId, versionId }) => {
762
+ fieldNameToRef.set(name, { docId, versionId })
763
+ })
764
+ )
765
+ }
766
+ await Promise.all(fieldPromises)
767
+
768
+ const presetsWithRefs = []
769
+ for (const { fieldNames, iconName, value, name } of config.presets()) {
770
+ const fieldRefs = fieldNames.map((fieldName) => {
771
+ const fieldRef = fieldNameToRef.get(fieldName)
772
+ if (!fieldRef) {
773
+ throw new Error(
774
+ `field ${fieldName} not found (referenced by preset ${value.name})})`
775
+ )
776
+ }
777
+ return fieldRef
778
+ })
779
+
780
+ if (!iconName) {
781
+ throw new Error(`preset ${value.name} is missing an icon name`)
782
+ }
783
+ const iconRef = iconNameToRef.get(iconName)
784
+ if (!iconRef) {
785
+ throw new Error(
786
+ `icon ${iconName} not found (referenced by preset ${value.name})`
787
+ )
788
+ }
789
+
790
+ presetsWithRefs.push({
791
+ preset: {
792
+ ...value,
793
+ iconRef,
794
+ fieldRefs,
795
+ },
796
+ name,
797
+ })
798
+ }
799
+
800
+ const presetPromises = []
801
+ for (const { preset, name } of presetsWithRefs) {
802
+ presetPromises.push(
803
+ this.preset.create(preset).then(({ docId, versionId }) => {
804
+ presetNameToRef.set(name, { docId, versionId })
805
+ })
806
+ )
807
+ }
808
+
809
+ await Promise.all(presetPromises)
810
+
811
+ const translationPromises = []
812
+ for (const { name, value } of config.translations()) {
813
+ let docRef
814
+ if (value.docRefType === 'field') {
815
+ docRef = { ...fieldNameToRef.get(name) }
816
+ } else if (value.docRefType === 'preset') {
817
+ docRef = { ...presetNameToRef.get(name) }
818
+ } else {
819
+ throw new Error(`invalid docRefType ${value.docRefType}`)
820
+ }
821
+ if (docRef.docId && docRef.versionId) {
822
+ translationPromises.push(
823
+ this.$translation.put({
824
+ ...value,
825
+ docRef: {
826
+ docId: docRef.docId,
827
+ versionId: docRef.versionId,
828
+ },
829
+ })
830
+ )
831
+ } else {
832
+ throw new Error(
833
+ `docRef for ${value.docRefType} with name ${name} not found`
834
+ )
835
+ }
836
+ }
837
+ await Promise.all(translationPromises)
838
+
839
+ // close the zip handles after we know we won't be needing them anymore
840
+ await config.close()
841
+ const presetIds = [...presetNameToRef.values()].map((val) => val.docId)
842
+
843
+ await this.$setProjectSettings({
844
+ defaultPresets: {
845
+ point: presetIds,
846
+ line: [],
847
+ area: [],
848
+ vertex: [],
849
+ relation: [],
850
+ },
851
+ configMetadata: config.metadata,
852
+ })
853
+
854
+ const deletePresetsPromise = Promise.all(
855
+ presetsToDelete.map(async (docId) => {
856
+ const { deleted } = await this.preset.getByDocId(docId)
857
+ if (!deleted) await this.preset.delete(docId)
858
+ })
859
+ )
860
+ const deleteFieldsPromise = Promise.all(
861
+ fieldsToDelete.map(async (docId) => {
862
+ const { deleted } = await this.field.getByDocId(docId)
863
+ if (!deleted) await this.field.delete(docId)
864
+ })
865
+ )
866
+ const deleteTranslationsPromise = Promise.all(
867
+ [...translationsToDelete].map(async (docId) => {
868
+ const { deleted } = await this.$translation.dataType.getByDocId(docId)
869
+ if (!deleted) await this.$translation.dataType.delete(docId)
870
+ })
871
+ )
872
+ await Promise.all([
873
+ deletePresetsPromise,
874
+ deleteFieldsPromise,
875
+ deleteTranslationsPromise,
876
+ ])
877
+ this.#loadingConfig = false
878
+ return config.warnings
879
+ } catch (e) {
880
+ this.#l.log('error loading config', e)
881
+ this.#loadingConfig = false
882
+ return /** @type Error[] */ []
883
+ }
884
+ }
885
+ }
886
+
887
+ /**
888
+ * @param {import("@comapeo/schema").ProjectSettings & { forks: string[] }} projectDoc
889
+ * @returns {EditableProjectSettings}
890
+ */
891
+ function extractEditableProjectSettings(projectDoc) {
892
+ // eslint-disable-next-line no-unused-vars
893
+ const { schemaName, ...result } = valueOf(projectDoc)
894
+ return result
895
+ }
896
+
897
+ /**
898
+ @param {MapeoProject['field'] | MapeoProject['preset']} dataType
899
+ @returns {Promise<String[]>}
900
+ */
901
+ async function grabDocsToDelete(dataType) {
902
+ const toDelete = []
903
+ for (const { docId } of await dataType.getMany()) {
904
+ toDelete.push(docId)
905
+ }
906
+ return toDelete
907
+ }
908
+
909
+ /**
910
+ * @param {Object} opts
911
+ * @param {Logger} opts.logger
912
+ * @param {MapeoProject['$translation']['dataType']} opts.translation
913
+ * @param {MapeoProject['preset']} opts.preset
914
+ * @param {MapeoProject['field']} opts.field
915
+ * @returns {Promise<Set<String>>}
916
+ */
917
+ async function grabTranslationsToDelete(opts) {
918
+ /** @type {Set<String>} */
919
+ const toDelete = new Set()
920
+ const translations = await opts.translation.getMany()
921
+ await Promise.all(
922
+ translations.map(async ({ docRefType, docRef, docId }) => {
923
+ if (docRefType === 'field' || docRefType === 'preset') {
924
+ let doc
925
+ try {
926
+ doc = await opts[docRefType].getByVersionId(docRef.versionId)
927
+ } catch (e) {
928
+ opts.logger.log(`referred ${docRef.versionId} is not found`)
929
+ }
930
+ if (doc) {
931
+ toDelete.add(docId)
932
+ }
933
+ }
934
+ })
935
+ )
936
+ return toDelete
937
+ }
938
+
939
+ /**
940
+ * Return a map of namespace -> core keypair
941
+ *
942
+ * For the project owner the keypair for the 'auth' namespace is the projectKey
943
+ * and projectSecretKey. In all other cases keypairs are derived from the
944
+ * project key
945
+ *
946
+ * @param {object} opts
947
+ * @param {Buffer} opts.projectKey
948
+ * @param {Buffer} [opts.projectSecretKey]
949
+ * @param {import('@mapeo/crypto').KeyManager} opts.keyManager
950
+ * @returns {Record<Namespace, KeyPair>}
951
+ */
952
+ function getCoreKeypairs({ projectKey, projectSecretKey, keyManager }) {
953
+ const keypairs = /** @type {Record<Namespace, KeyPair>} */ ({})
954
+
955
+ for (const namespace of NAMESPACES) {
956
+ keypairs[namespace] =
957
+ namespace === 'auth' && projectSecretKey
958
+ ? { publicKey: projectKey, secretKey: projectSecretKey }
959
+ : keyManager.getHypercoreKeypair(namespace, projectKey)
960
+ }
961
+
962
+ return keypairs
963
+ }
964
+
965
+ /**
966
+ * Validate that a deviceInfo record is written by the device that is it about,
967
+ * e.g. version.coreKey should equal docId
968
+ *
969
+ * @param {import('@comapeo/schema').DeviceInfo} doc
970
+ * @param {import('@comapeo/schema').VersionIdObject} version
971
+ * @returns {import('@comapeo/schema').DeviceInfo}
972
+ */
973
+ function mapAndValidateDeviceInfo(doc, { coreDiscoveryKey }) {
974
+ if (!coreDiscoveryKey.equals(discoveryKey(Buffer.from(doc.docId, 'hex')))) {
975
+ throw new Error(
976
+ 'Invalid deviceInfo record, cannot write deviceInfo for another device'
977
+ )
978
+ }
979
+ return doc
980
+ }