@comapeo/core 3.2.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,6 +15,13 @@
15
15
  "when": 1729783892753,
16
16
  "tag": "0001_medical_wendell_rand",
17
17
  "breakpoints": true
18
+ },
19
+ {
20
+ "idx": 2,
21
+ "version": "5",
22
+ "when": 1749657174497,
23
+ "tag": "0002_cooing_princess_powerful",
24
+ "breakpoints": true
18
25
  }
19
26
  ]
20
27
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@comapeo/core",
3
- "version": "3.2.0",
3
+ "version": "4.1.0",
4
4
  "description": "Offline p2p mapping library",
5
5
  "main": "src/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -112,12 +112,13 @@
112
112
  "@comapeo/core2.0.1": "npm:@comapeo/core@2.0.1",
113
113
  "@comapeo/ipc": "^2.1.0",
114
114
  "@mapeo/default-config": "5.0.0",
115
- "@mapeo/mock-data": "^3.0.0",
115
+ "@mapeo/mock-data": "^5.0.0",
116
116
  "@sinonjs/fake-timers": "^10.0.2",
117
117
  "@types/b4a": "^1.6.0",
118
118
  "@types/bogon": "^1.0.2",
119
119
  "@types/compact-encoding": "^2.15.0",
120
120
  "@types/debug": "^4.1.8",
121
+ "@types/geojson": "^7946.0.16",
121
122
  "@types/json-schema": "^7.0.11",
122
123
  "@types/json-stable-stringify": "^1.0.36",
123
124
  "@types/nanobench": "^3.0.0",
@@ -136,6 +137,7 @@
136
137
  "drizzle-kit": "^0.20.14",
137
138
  "eslint": "^8.57.0",
138
139
  "execa": "^9.5.1",
140
+ "filter-obj": "^6.1.0",
139
141
  "husky": "^8.0.0",
140
142
  "iterpal": "^0.4.0",
141
143
  "lint-staged": "^14.0.1",
@@ -160,7 +162,7 @@
160
162
  },
161
163
  "dependencies": {
162
164
  "@comapeo/fallback-smp": "^1.0.0",
163
- "@comapeo/schema": "1.6.0",
165
+ "@comapeo/schema": "2.0.0",
164
166
  "@digidem/types": "^2.3.0",
165
167
  "@fastify/error": "^3.4.1",
166
168
  "@fastify/type-provider-typebox": "^4.1.0",
@@ -189,7 +191,7 @@
189
191
  "json-stable-stringify": "^1.1.1",
190
192
  "magic-bytes.js": "^1.10.0",
191
193
  "map-obj": "^5.0.2",
192
- "mime": "^4.0.3",
194
+ "mime": "^4.0.7",
193
195
  "multi-core-indexer": "^1.0.0",
194
196
  "p-defer": "^4.0.0",
195
197
  "p-event": "^6.0.1",
package/src/blob-api.js CHANGED
@@ -4,35 +4,9 @@ import { Transform, pipelinePromise as pipeline } from 'streamx'
4
4
  import { createHash, randomBytes } from 'node:crypto'
5
5
  /** @import { BlobId, BlobType } from './types.js' */
6
6
 
7
- /**
8
- * Location coordinate data. Based on [Expo's `LocationObjectCoords`][0].
9
- * [0]: https://docs.expo.dev/versions/latest/sdk/location/#locationobjectcoords
10
- *
11
- * @typedef {object} LocationObjectCoords
12
- * @prop {number | null} accuracy
13
- * @prop {number | null} altitude
14
- * @prop {number | null} altitudeAccuracy
15
- * @prop {number | null} heading
16
- * @prop {number} latitude
17
- * @prop {number} longitude
18
- * @prop {number | null} speed
19
- */
20
-
21
- /**
22
- * Location metadata for a blob. Based on [Expo's `LocationObject`][0].
23
- * [0]: https://docs.expo.dev/versions/latest/sdk/location/#locationobject
24
- *
25
- * @typedef {object} LocationObject
26
- * @prop {LocationObjectCoords} coords
27
- * @prop {boolean} [mocked]
28
- * @prop {number} timestamp
29
- */
30
-
31
7
  /**
32
8
  * @typedef {object} Metadata
33
9
  * @prop {string} mimeType
34
- * @prop {number} timestamp
35
- * @prop {LocationObject} [location]
36
10
  */
37
11
 
38
12
  export class BlobApi {
package/src/constants.js CHANGED
@@ -35,3 +35,6 @@ export const SUPPORTED_CONFIG_VERSION = 1
35
35
 
36
36
  // WARNING: This value is persisted. Be careful when changing it.
37
37
  export const DRIZZLE_MIGRATIONS_TABLE = '__drizzle_migrations'
38
+
39
+ // Oldest possible time, ensure it gets overwritten with any updates
40
+ export const UNIX_EPOCH_DATE = new Date(0).toISOString()
@@ -38,6 +38,7 @@ import {
38
38
  projectKeyToProjectInviteId,
39
39
  projectKeyToPublicId,
40
40
  } from './utils.js'
41
+ import { UNIX_EPOCH_DATE } from './constants.js'
41
42
  import { openedNoiseSecretStream } from './lib/noise-secret-stream-helpers.js'
42
43
  import { omit } from './lib/omit.js'
43
44
  import { RandomAccessFilePool } from './core-manager/random-access-file-pool.js'
@@ -563,11 +564,13 @@ export class MapeoManager extends TypedEmitter {
563
564
  (p) => p.projectId === projectId
564
565
  )
565
566
 
567
+ if (!existingProject) continue
568
+
566
569
  result.push(
567
570
  deNullify({
568
571
  projectId: projectPublicId,
569
- createdAt: existingProject?.createdAt,
570
- updatedAt: existingProject?.updatedAt,
572
+ createdAt: ignoreUnixDate(existingProject?.createdAt),
573
+ updatedAt: ignoreUnixDate(existingProject?.updatedAt),
571
574
  name: existingProject?.name || projectInfo.name,
572
575
  projectColor:
573
576
  existingProject?.projectColor || projectInfo.projectColor,
@@ -658,6 +661,23 @@ export class MapeoManager extends TypedEmitter {
658
661
  )
659
662
  }
660
663
 
664
+ /** @type {import('drizzle-orm').InferInsertModel<typeof projectSettingsTable>} */
665
+ const settingsDoc = {
666
+ schemaName: 'projectSettings',
667
+ docId: projectId,
668
+ versionId: 'unknown',
669
+ originalVersionId: 'unknown',
670
+ createdAt: UNIX_EPOCH_DATE,
671
+ updatedAt: UNIX_EPOCH_DATE,
672
+ deleted: false,
673
+ links: [],
674
+ forks: [],
675
+ name: projectName,
676
+ projectDescription,
677
+ }
678
+
679
+ await this.#db.insert(projectSettingsTable).values([settingsDoc])
680
+
661
681
  // 5. Wait for initial project sync
662
682
  if (waitForSync) {
663
683
  await this.#waitForInitialSync(project)
@@ -692,17 +712,15 @@ export class MapeoManager extends TypedEmitter {
692
712
  * @returns {Promise<boolean>}
693
713
  */
694
714
  async #waitForInitialSync(project, { timeoutMs = 5000 } = {}) {
695
- const [ownRole, projectSettings] = await Promise.all([
715
+ const [ownRole, isProjectSettingsSynced] = await Promise.all([
696
716
  project.$getOwnRole(),
697
- project.$getProjectSettings(),
717
+ project.$hasSyncedProjectSettings(),
698
718
  ])
699
719
  const {
700
720
  auth: { localState: authState },
701
721
  config: { localState: configState },
702
722
  } = project.$sync[kSyncState].getState()
703
723
  const isRoleSynced = ownRole !== Roles.NO_ROLE
704
- const isProjectSettingsSynced =
705
- projectSettings !== MapeoProject.EMPTY_PROJECT_SETTINGS
706
724
  // Assumes every project that someone is invited to has at least one record
707
725
  // in the auth store - the row record for the invited device
708
726
  const isAuthSynced = authState.want === 0 && authState.have > 0
@@ -721,7 +739,6 @@ export class MapeoManager extends TypedEmitter {
721
739
  this.#l.log(
722
740
  'Pending initial sync: role %s, projectSettings %o, auth %o, config %o',
723
741
  isRoleSynced,
724
- isProjectSettingsSynced,
725
742
  isAuthSynced,
726
743
  isConfigSynced
727
744
  )
@@ -1012,3 +1029,13 @@ function validateProjectKeys(projectKeys) {
1012
1029
  function hasSavedDeviceInfo(partialDeviceInfo) {
1013
1030
  return Boolean(partialDeviceInfo.name)
1014
1031
  }
1032
+
1033
+ /**
1034
+ * @param {string|undefined} date
1035
+ * @returns {string|null}
1036
+ */
1037
+ function ignoreUnixDate(date) {
1038
+ if (date === UNIX_EPOCH_DATE) return null
1039
+ if (date === undefined) return null
1040
+ return date
1041
+ }
@@ -6,10 +6,11 @@ import { discoveryKey } from 'hypercore-crypto'
6
6
  import { TypedEmitter } from 'tiny-typed-emitter'
7
7
  import ZipArchive from 'zip-stream-promise'
8
8
  import * as b4a from 'b4a'
9
+ import mime from 'mime/lite'
9
10
  // @ts-expect-error
10
11
  import { Readable, pipelinePromise } from 'streamx'
11
12
 
12
- import { NAMESPACES, NAMESPACE_SCHEMAS } from './constants.js'
13
+ import { NAMESPACES, NAMESPACE_SCHEMAS, UNIX_EPOCH_DATE } from './constants.js'
13
14
  import { CoreManager } from './core-manager/index.js'
14
15
  import { DataStore } from './datastore/index.js'
15
16
  import { DataType, kCreateWithDocId } from './datatype/index.js'
@@ -69,6 +70,10 @@ import { createWriteStream } from 'fs'
69
70
  /** @typedef {Omit<ProjectSettingsValue, 'schemaName'>} EditableProjectSettings */
70
71
  /** @typedef {ProjectSettingsValue['configMetadata']} ConfigMetadata */
71
72
  /** @typedef {Map<string,Attachment>} SeenAttachments*/
73
+ /** @typedef {object} BlobRef
74
+ * @prop {string|undefined} mimeType
75
+ * @prop {BlobId} blobId
76
+ */
72
77
 
73
78
  const CORESTORE_STORAGE_FOLDER_NAME = 'corestore'
74
79
  const INDEXER_STORAGE_FOLDER_NAME = 'indexer'
@@ -83,8 +88,6 @@ export const kClearDataIfLeft = Symbol('clear data if left project')
83
88
  export const kSetIsArchiveDevice = Symbol('set isArchiveDevice')
84
89
  export const kIsArchiveDevice = Symbol('isArchiveDevice (temp - test only)')
85
90
  export const kGeoJSONFileName = Symbol('geoJSONFileName')
86
- export const kExportGeoJSONStream = Symbol('exportGeoJSONStream')
87
- export const kExportZipStream = Symbol('exportZipStream')
88
91
 
89
92
  const EMPTY_PROJECT_SETTINGS = Object.freeze({})
90
93
 
@@ -658,6 +661,21 @@ export class MapeoProject extends TypedEmitter {
658
661
  }
659
662
  }
660
663
 
664
+ /**
665
+ * @returns {Promise<boolean>}
666
+ */
667
+ async $hasSyncedProjectSettings() {
668
+ try {
669
+ const settings = await this.#dataTypes.projectSettings.getByDocId(
670
+ this.#projectId
671
+ )
672
+
673
+ return settings.createdAt !== UNIX_EPOCH_DATE
674
+ } catch (e) {
675
+ return false
676
+ }
677
+ }
678
+
661
679
  /**
662
680
  * @returns {Promise<undefined | string>}
663
681
  */
@@ -786,40 +804,43 @@ export class MapeoProject extends TypedEmitter {
786
804
  }
787
805
  }
788
806
 
789
- let latitude = lat
790
- let longitude = lon
791
- let altitude = null
792
- const position = observation?.metadata?.position?.coords
793
- if (position) {
794
- latitude = position.latitude
795
- longitude = position.longitude
796
- if (position.altitude !== undefined) {
797
- altitude = position.altitude
807
+ const metadataCoords = observation.metadata?.position?.coords
808
+ const altitude = metadataCoords?.altitude
809
+
810
+ /** @type {[number, number] | [number, number, number] | null} */
811
+ let coordinates = null
812
+
813
+ // Prioritize using the observation's `lat` and `lon` fields
814
+ if (typeof lat === 'number' && typeof lon === 'number') {
815
+ coordinates =
816
+ typeof altitude === 'number' ? [lon, lat, altitude] : [lon, lat]
817
+ } else {
818
+ // Fall back to using the observation metadata's position if possible
819
+ if (
820
+ typeof metadataCoords?.latitude === 'number' &&
821
+ typeof metadataCoords?.longitude === 'number'
822
+ ) {
823
+ coordinates =
824
+ typeof altitude === 'number'
825
+ ? [metadataCoords.longitude, metadataCoords.latitude, altitude]
826
+ : [metadataCoords.longitude, metadataCoords.latitude]
798
827
  }
799
828
  }
800
829
 
801
- const coordinates = [longitude, latitude]
802
- if (typeof altitude === 'number') {
803
- coordinates.push(altitude)
830
+ /** @type {import('geojson').Feature<import('geojson').Point | null>} */
831
+ const feature = {
832
+ type: 'Feature',
833
+ properties: observation,
834
+ geometry: coordinates
835
+ ? {
836
+ type: 'Point',
837
+ coordinates,
838
+ }
839
+ : null,
804
840
  }
805
- const hasLatLon =
806
- typeof longitude === 'number' && typeof latitude === 'number'
807
- const geometry = hasLatLon
808
- ? {
809
- type: 'Point',
810
- coordinates,
811
- }
812
- : null
813
841
  const comma = first ? '' : ','
814
842
  first = false
815
- yield b4a.from(
816
- `${comma}\n ` +
817
- JSON.stringify({
818
- type: 'Feature',
819
- properties: observation,
820
- geometry,
821
- })
822
- )
843
+ yield b4a.from(`${comma}\n ` + JSON.stringify(feature))
823
844
  }
824
845
  }
825
846
 
@@ -941,7 +962,7 @@ export class MapeoProject extends TypedEmitter {
941
962
  * @param {string} [options.lang]
942
963
  * @returns {Readable<Buffer | Uint8Array>}
943
964
  */
944
- [kExportGeoJSONStream]({
965
+ #exportGeoJSONStream({
945
966
  observations = true,
946
967
  tracks = true,
947
968
  lang,
@@ -1027,7 +1048,7 @@ export class MapeoProject extends TypedEmitter {
1027
1048
  ) {
1028
1049
  const fileName = await this[kGeoJSONFileName](observations, tracks)
1029
1050
  const filePath = path.join(exportFolder, fileName)
1030
- const source = this[kExportGeoJSONStream]({ observations, tracks, lang })
1051
+ const source = this.#exportGeoJSONStream({ observations, tracks, lang })
1031
1052
  const sink = createWriteStream(filePath)
1032
1053
  await pipelinePromise(source, sink)
1033
1054
 
@@ -1036,15 +1057,37 @@ export class MapeoProject extends TypedEmitter {
1036
1057
 
1037
1058
  /**
1038
1059
  * @param {Attachment} attachment
1039
- * @returns {Promise<null | BlobId>}
1060
+ * @returns {Promise<null | BlobRef>}
1040
1061
  */
1041
- async #tryGetBlobId(attachment) {
1062
+ async #tryGetAttachmentBlob(attachment) {
1042
1063
  // Audio must not have variants
1043
1064
  for (const variant of VARIANT_EXPORT_ORDER) {
1044
- const blobId = buildBlobId(attachment, variant)
1045
- const entry = await this.#blobStore.entry(blobId)
1046
- if (!entry) continue
1047
- return blobId
1065
+ try {
1066
+ const blobId = buildBlobId(attachment, variant)
1067
+ const entry = await this.#blobStore.entry(blobId)
1068
+ if (!entry) continue
1069
+ const metadata = entry.value.metadata
1070
+ if (!metadata || typeof metadata !== 'object') continue
1071
+ let mimeType = undefined
1072
+ if ('mimeType' in metadata) {
1073
+ if (typeof metadata.mimeType === 'string') {
1074
+ mimeType = metadata.mimeType
1075
+ } else {
1076
+ this.#l.log('Invalid type for mimeType in blob', blobId, entry)
1077
+ continue
1078
+ }
1079
+ }
1080
+ return { blobId, mimeType }
1081
+ } catch (e) {
1082
+ if (!(e instanceof Error)) throw e
1083
+ this.#l.log(
1084
+ 'Error loading blob id for attachment',
1085
+ attachment,
1086
+ variant,
1087
+ e.message
1088
+ )
1089
+ continue
1090
+ }
1048
1091
  }
1049
1092
 
1050
1093
  return null
@@ -1066,7 +1109,7 @@ export class MapeoProject extends TypedEmitter {
1066
1109
  // GeoJSON
1067
1110
  const geoJSONFileName = await this[kGeoJSONFileName](observations, tracks)
1068
1111
  const seenAttachments = new Map()
1069
- const geoJSONStream = this[kExportGeoJSONStream]({
1112
+ const geoJSONStream = this.#exportGeoJSONStream({
1070
1113
  observations,
1071
1114
  tracks,
1072
1115
  lang,
@@ -1079,16 +1122,31 @@ export class MapeoProject extends TypedEmitter {
1079
1122
  const missingAttachments = []
1080
1123
  // Attachments
1081
1124
  if (attachments) {
1082
- const mediaFolder = this.#exportPrefix('Media') + '/'
1125
+ const mediaFolder = (await this.#exportPrefix('Media')) + '/'
1083
1126
  for (const attachment of seenAttachments.values()) {
1084
- const blobId = await this.#tryGetBlobId(attachment)
1085
- if (blobId === null) {
1127
+ const ref = await this.#tryGetAttachmentBlob(attachment)
1128
+ if (ref === null) {
1086
1129
  missingAttachments.push(attachment)
1087
1130
  continue
1088
1131
  }
1089
1132
 
1133
+ const { blobId, mimeType } = ref
1134
+ let extensionString = ''
1135
+ if (mimeType) {
1136
+ const extension = mime.getExtension(mimeType)
1137
+ if (extension) {
1138
+ extensionString = '.' + extension
1139
+ } else {
1140
+ this.#l.log('Got unknown mime type in attachment blob', attachment)
1141
+ }
1142
+ }
1143
+
1090
1144
  const stream = this.#blobStore.createReadStream(blobId)
1091
- const name = mediaFolder + blobId.variant + '/' + attachment.name
1145
+ const name = path.posix.join(
1146
+ mediaFolder,
1147
+ blobId.variant,
1148
+ `${attachment.name}${extensionString}`
1149
+ )
1092
1150
 
1093
1151
  // @ts-expect-error
1094
1152
  await archive.entry(stream, { name })
@@ -1118,7 +1176,7 @@ export class MapeoProject extends TypedEmitter {
1118
1176
  * @param {string} [options.lang]
1119
1177
  * @returns {Readable<Buffer | Uint8Array>}
1120
1178
  */
1121
- [kExportZipStream]({
1179
+ #exportZipStream({
1122
1180
  observations = true,
1123
1181
  tracks = true,
1124
1182
  attachments = true,
@@ -1153,7 +1211,7 @@ export class MapeoProject extends TypedEmitter {
1153
1211
  ) {
1154
1212
  const fileName = await this.#zipFileName(observations, tracks)
1155
1213
  const filePath = path.join(exportFolder, fileName)
1156
- const source = this[kExportZipStream]({
1214
+ const source = this.#exportZipStream({
1157
1215
  observations,
1158
1216
  tracks,
1159
1217
  attachments,
package/src/member-api.js CHANGED
@@ -472,6 +472,7 @@ export class MemberApi extends TypedEmitter {
472
472
  )
473
473
  }
474
474
 
475
+ const onClosePromise = pEvent(websocket, 'close')
475
476
  const onErrorPromise = pEvent(websocket, 'error')
476
477
 
477
478
  const replicationStream = this.#getReplicationStream()
@@ -494,7 +495,6 @@ export class MemberApi extends TypedEmitter {
494
495
  websocket.close()
495
496
  throw errorEvent.error
496
497
  } else {
497
- const onClosePromise = pEvent(websocket, 'close')
498
498
  onErrorPromise.cancel()
499
499
  websocket.close()
500
500
  await onClosePromise
package/src/types.ts CHANGED
@@ -162,7 +162,7 @@ export type BlobStoreEntriesStream = Readable & {
162
162
  >
163
163
  }
164
164
 
165
- export type Attachment = Observation['attachments'][0]
165
+ export type Attachment = Observation['attachments'][number]
166
166
 
167
167
  export type StringToTaggedUnion<T extends string> = {
168
168
  [K in T]: {