@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,364 @@
1
+ import PQueue from 'p-queue'
2
+ import { Reader } from 'comapeocat/reader.js'
3
+ import { typedEntries } from './utils.js'
4
+ import { parseBcp47 } from './intl/parse-bcp-47.js'
5
+ import ensureError from 'ensure-error'
6
+
7
+ // The main reason for the concurrency limit is to avoid run-away memory usage.
8
+ // The comapeocat Reader limits icon size to 2Mb (as-per the specification), and
9
+ // it's likely that as SVGs, most icons will be much smaller than that, but we
10
+ // don't want a file with 1,000 icons of 2Mb each to bring a device to its
11
+ // knees. The choice of 4 is somewhat arbitrary, but should keep memory usage
12
+ // reasonable even in the worst case (8Mb of icon data in memory at once), while
13
+ // still allowing some parallelism to speed up the import.
14
+ const ICON_QUEUE_CONCURRENCY = 4
15
+ // Because fields and presets are all stored in a single file in the categories
16
+ // archive, they all need to be loaded into memory at once anyway (each file,
17
+ // with all docs, is limited to 1Mb). The concurrency limit here is somewhat
18
+ // arbitrary also, but it's to avoid a performance bottleneck if a user tries to
19
+ // import a file with thousands of categories or fields.
20
+ const DOC_QUEUE_CONCURRENCY = 32
21
+
22
+ /** @import {MapeoProject} from './mapeo-project.js' */
23
+ /** @typedef {{ docId: string, versionId: string }} Ref */
24
+ /**
25
+ * @param {MapeoProject} project
26
+ * @param {object} options
27
+ * @param {string} options.filePath
28
+ * @param {import('./logger.js').Logger} options.logger
29
+ * @returns {Promise<void>}
30
+ */
31
+ export async function importCategories(project, { filePath, logger }) {
32
+ // Queue promises to create icons and docs, initially with concurrency of 4
33
+ // for icons, to minimize memory usage.
34
+ const queue = new PQueue({ concurrency: ICON_QUEUE_CONCURRENCY })
35
+
36
+ // TODO: We should use something like a hash to avoid deleting and
37
+ // re-importing presets and fields that have not changed, and try to identify
38
+ // modified presets and fields so we can update them rather than delete and
39
+ // re-create.
40
+ const presetsToDelete = await existingDocIds(project.preset)
41
+ const fieldsToDelete = await existingDocIds(project.field)
42
+ // No need to delete translations or icons, since these are always fetched by
43
+ // their references, so unreferenced icons and translations are just ignored
44
+ // (because our DB is immutable, deleting docs does not save space, it instead
45
+ // adds a new copy of the doc with `deleted: true` to the DB)
46
+
47
+ const reader = new Reader(filePath)
48
+ try {
49
+ // This validates that all icons and fields referenced by categories exist -
50
+ // it should throw an error here (before any docs are created) for any of the
51
+ // errors that we check for below.
52
+ await reader.validate()
53
+
54
+ /** @type {Map<string, Ref>} */
55
+ const iconNameToRef = new Map()
56
+ /** @type {Map<string, Ref>} */
57
+ const fieldNameToRef = new Map()
58
+ /** @type {Map<string, Ref>} */
59
+ const presetNameToRef = new Map()
60
+
61
+ const categories = await reader.categories()
62
+ // We only add icons referenced by categories (the archive could contain additional icons)
63
+ /** @type {Set<string>} */
64
+ const iconsToAdd = new Set()
65
+ // Only import icons that are referenced by categories, rather than all icons in the file
66
+ for (const category of categories.values()) {
67
+ if (!('icon' in category && category.icon)) continue
68
+ iconsToAdd.add(category.icon)
69
+ }
70
+ /** @type {Error[]} */
71
+ const errors = []
72
+ for (const iconName of iconsToAdd) {
73
+ queue
74
+ .add(async () => {
75
+ const iconXml = await reader.getIcon(iconName)
76
+ if (!iconXml) {
77
+ // This should never happen because of the validate() call above
78
+ throw new Error(`Icon ${iconName} not found in import file`)
79
+ }
80
+ /** @type {Parameters<typeof project.$icons.create>[0]} */
81
+ const icon = {
82
+ name: iconName,
83
+ variants: [
84
+ {
85
+ mimeType: 'image/svg+xml',
86
+ size: 'medium',
87
+ blob: Buffer.from(iconXml, 'utf-8'),
88
+ },
89
+ ],
90
+ }
91
+
92
+ await project.$icons.create(icon).then(({ docId, versionId }) => {
93
+ iconNameToRef.set(iconName, { docId, versionId })
94
+ })
95
+ })
96
+ .catch((e) => {
97
+ // The queue task added above could throw (it shouldn't!), and the
98
+ // promise for that task is returned by `queue.add()`. We don't await
99
+ // this (because we want to keep adding items to the queue), but we need
100
+ // to avoid an uncaught error, so we catch it here and store it for later.
101
+ errors.push(ensureError(e))
102
+ })
103
+ }
104
+
105
+ await queue.onIdle()
106
+ if (errors.length > 0) {
107
+ throw new AggregateError(
108
+ errors,
109
+ 'Errors occurred creating icons during import'
110
+ )
111
+ }
112
+ queue.concurrency = DOC_QUEUE_CONCURRENCY // increase concurrency for creating fields and presets
113
+
114
+ const fields = await reader.fields()
115
+ /** @type {Set<string>} */
116
+ const fieldsToAdd = new Set()
117
+ // Only import fields that are referenced by categories, rather than all fields in the file
118
+ for (const category of categories.values()) {
119
+ for (const fieldName of category.fields) {
120
+ fieldsToAdd.add(fieldName)
121
+ }
122
+ }
123
+ for (const fieldName of fieldsToAdd) {
124
+ const field = getOrThrow(fields, fieldName)
125
+ /** @type {import('@comapeo/schema').FieldValue} */
126
+ const fieldValue = { ...field, schemaName: 'field' }
127
+ queue
128
+ .add(() =>
129
+ project.field.create(fieldValue).then(({ docId, versionId }) => {
130
+ fieldNameToRef.set(fieldName, { docId, versionId })
131
+ })
132
+ )
133
+ .catch((e) => {
134
+ errors.push(ensureError(e))
135
+ })
136
+ }
137
+
138
+ // Must wait for all fields and icons to be created before creating presets,
139
+ // because we need the field and icon refs
140
+ await queue.onIdle()
141
+ if (errors.length > 0) {
142
+ throw new AggregateError(
143
+ errors,
144
+ 'Errors occurred creating fields during import'
145
+ )
146
+ }
147
+
148
+ for (const [categoryName, category] of categories) {
149
+ const {
150
+ fields: fieldNames,
151
+ icon: iconName,
152
+ appliesTo,
153
+ ...rest
154
+ } = category
155
+ /** @type {Ref[]} */
156
+ const fieldRefs = []
157
+ for (const fieldName of fieldNames) {
158
+ const fieldRef = getOrThrow(fieldNameToRef, fieldName)
159
+ fieldRefs.push(fieldRef)
160
+ }
161
+
162
+ /** @type {import('./icon-api.js').IconRef} */
163
+ let iconRef
164
+ if (iconName) {
165
+ iconRef = getOrThrow(iconNameToRef, iconName)
166
+ }
167
+
168
+ /** @type {import('@comapeo/schema').PresetValue} */
169
+ const presetValue = {
170
+ ...rest,
171
+ geometry: appliesToToGeometry(appliesTo),
172
+ fieldRefs,
173
+ iconRef,
174
+ schemaName: 'preset',
175
+ }
176
+
177
+ queue
178
+ .add(() =>
179
+ project.preset.create(presetValue).then(({ docId, versionId }) => {
180
+ presetNameToRef.set(categoryName, { docId, versionId })
181
+ })
182
+ )
183
+ .catch((e) => {
184
+ errors.push(ensureError(e))
185
+ })
186
+ }
187
+
188
+ const { buildDateValue, ...readerMetadata } = await reader.metadata()
189
+ const fileVersion = await reader.fileVersion()
190
+
191
+ for await (const {
192
+ lang,
193
+ translations: translationsByDocType,
194
+ } of reader.translations()) {
195
+ const { language: languageCode, region: regionCode } = parseBcp47(lang)
196
+ if (!languageCode) {
197
+ logger.log(`ignoring invalid language tag: ${lang}`)
198
+ continue
199
+ }
200
+ for (const [docType, translationsByDocId] of typedEntries(
201
+ translationsByDocType
202
+ )) {
203
+ if (!translationsByDocId) continue
204
+ for (const [docId, translations] of typedEntries(translationsByDocId)) {
205
+ /** @type {{ docId: string, versionId: string } | undefined} */
206
+ let docRef
207
+ if (docType === 'field') {
208
+ docRef = fieldNameToRef.get(docId)
209
+ } else if (docType === 'category') {
210
+ docRef = presetNameToRef.get(docId)
211
+ }
212
+ if (!docRef) {
213
+ // ignore translations that reference unknown docs or doc types
214
+ continue
215
+ }
216
+ for (const [propertyRef, message] of typedEntries(translations)) {
217
+ /** @type {Parameters<typeof project.$translation.put>[0]} */
218
+ const translationValue = {
219
+ schemaName: 'translation',
220
+ languageCode: languageCode,
221
+ regionCode: regionCode ?? undefined,
222
+ docRefType: docType === 'category' ? 'preset' : 'field',
223
+ docRef,
224
+ propertyRef,
225
+ message,
226
+ }
227
+ queue
228
+ .add(() => project.$translation.put(translationValue))
229
+ .catch((e) => {
230
+ errors.push(ensureError(e))
231
+ })
232
+ // Since translations are stored in separate files in the archive,
233
+ // and there could potentially be hundreds of them in the future,
234
+ // and the async iterator in this loop will load a new file for
235
+ // every iteration, we pause here when there are queued tasks (up to
236
+ // DOCS_QUEUE_CONCURRENCY tasks could be pending however) to avoid
237
+ // run-away memory usage.
238
+ await queue.onSizeLessThan(1)
239
+ }
240
+ }
241
+ }
242
+ }
243
+
244
+ // Need to wait for all presets to be created so that we can read the refs for the defaultPresets
245
+ await queue.onIdle()
246
+ if (errors.length > 0) {
247
+ throw new AggregateError(
248
+ errors,
249
+ 'Errors occurred creating presets or translations during import'
250
+ )
251
+ }
252
+ /** @type {import('@comapeo/schema').ProjectSettings['defaultPresets']} */
253
+ const defaultPresets = {
254
+ point: [],
255
+ line: [],
256
+ area: [],
257
+ vertex: [],
258
+ relation: [],
259
+ }
260
+ const categorySelection = await reader.categorySelection()
261
+ for (const categoryName of categorySelection.observation) {
262
+ const ref = getOrThrow(presetNameToRef, categoryName)
263
+ defaultPresets.point.push(ref.docId)
264
+ }
265
+ for (const categoryName of categorySelection.track) {
266
+ const ref = getOrThrow(presetNameToRef, categoryName)
267
+ defaultPresets.line.push(ref.docId)
268
+ }
269
+
270
+ await project.$setProjectSettings({
271
+ defaultPresets,
272
+ configMetadata: {
273
+ ...readerMetadata,
274
+ fileVersion,
275
+ importDate: new Date().toISOString(),
276
+ buildDate: new Date(buildDateValue).toISOString(),
277
+ },
278
+ })
279
+
280
+ for (const docId of presetsToDelete) {
281
+ queue
282
+ .add(() => project.preset.delete(docId))
283
+ .catch((e) => {
284
+ errors.push(ensureError(e))
285
+ })
286
+ }
287
+ for (const docId of fieldsToDelete) {
288
+ queue
289
+ .add(() => project.field.delete(docId))
290
+ .catch((e) => {
291
+ errors.push(ensureError(e))
292
+ })
293
+ }
294
+ await queue.onIdle()
295
+ if (errors.length > 0) {
296
+ throw new AggregateError(
297
+ errors,
298
+ 'Errors occurred deleting old presets or fields during import'
299
+ )
300
+ }
301
+ } finally {
302
+ // Don't throw errors from closing the reader, because if the import was
303
+ // successful we don't want to throw an error, and if there was an error
304
+ // with the import, the error thrown here would mask that error (Control
305
+ // flow statements (return, throw, break, continue) in the finally block
306
+ // will "mask" any completion value of the try block or catch block)
307
+ await reader.close().catch((e) => {
308
+ logger.log('error closing import file reader', e)
309
+ })
310
+ }
311
+ }
312
+
313
+ /**
314
+ * List the docIds of all existing (excluding deleted) docs in the given dataType
315
+ *
316
+ * @param {MapeoProject['field'] | MapeoProject['preset']} dataType
317
+ * @returns {Promise<Set<string>>}
318
+ */
319
+ async function existingDocIds(dataType) {
320
+ const toDelete = new Set()
321
+ for (const { docId } of await dataType.getMany()) {
322
+ toDelete.add(docId)
323
+ }
324
+ return toDelete
325
+ }
326
+
327
+ const APPLIES_TO_TO_GEOMETRY = /** @type {const} */ ({
328
+ observation: 'point',
329
+ track: 'line',
330
+ })
331
+
332
+ /**
333
+ * Map the new "appliesTo" field to the old "geometry" field
334
+ *
335
+ * @param {import('comapeocat/reader.js').CategoryOutput['appliesTo']} appliesTo)}
336
+ * @returns {import('@comapeo/schema').Preset['geometry']}
337
+ */
338
+ function appliesToToGeometry(appliesTo) {
339
+ return appliesTo.map((a) => APPLIES_TO_TO_GEOMETRY[a]).filter(Boolean)
340
+ }
341
+
342
+ // In the import function, the validation of the import file should ensure that
343
+ // all references exist, so map.get() should always succeed, however TS is
344
+ // unable to validate this statically. There should be no way that this will
345
+ // throw, but this helper will catch the case we haven't though of yet.
346
+ /**
347
+ * Get a value from a Map, or throw an error if the key is not present
348
+ *
349
+ * @template K, V
350
+ * @param {Map<K, V>} map
351
+ * @param {K} key
352
+ * @param {string | Error} [msgOrError]
353
+ * @returns {V}
354
+ * @throws {TypeError} if `map` is not a Map
355
+ * @throws {Error} if `key` is not in `map` (with `msgOrError` as message or the default message)
356
+ */
357
+ function getOrThrow(map, key, msgOrError) {
358
+ if (!(map instanceof Map)) throw new TypeError('map must be a Map')
359
+ if (!map.has(key)) {
360
+ if (msgOrError instanceof Error) throw msgOrError
361
+ throw new Error(msgOrError ?? `key ${key} not found in map`)
362
+ }
363
+ return /** @type {V} */ (map.get(key))
364
+ }
@@ -1,7 +1,7 @@
1
1
  import { decode } from '@comapeo/schema'
2
2
  import SqliteIndexer from '@mapeo/sqlite-indexer'
3
3
  import { getTableConfig } from 'drizzle-orm/sqlite-core'
4
- import { getBacklinkTableName } from '../schema/utils.js'
4
+ import { getBacklinkTableName } from '../schema/comapeo-to-drizzle.js'
5
5
  import { discoveryKey } from 'hypercore-crypto'
6
6
  import { Logger } from '../logger.js'
7
7
  /** @import { MapeoDoc, VersionIdObject } from '@comapeo/schema' */