@comapeo/core 5.4.1 → 6.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 (100) hide show
  1. package/dist/blob-api.d.ts.map +1 -1
  2. package/dist/blob-store/downloader.d.ts.map +1 -1
  3. package/dist/blob-store/hyperdrive-index.d.ts.map +1 -1
  4. package/dist/blob-store/index.d.ts.map +1 -1
  5. package/dist/core-manager/bitfield-rle.d.ts.map +1 -1
  6. package/dist/core-manager/core-index.d.ts.map +1 -1
  7. package/dist/core-manager/index.d.ts +1 -2
  8. package/dist/core-manager/index.d.ts.map +1 -1
  9. package/dist/core-ownership.d.ts.map +1 -1
  10. package/dist/datastore/index.d.ts.map +1 -1
  11. package/dist/datatype/index.d.ts +7 -0
  12. package/dist/datatype/index.d.ts.map +1 -1
  13. package/dist/discovery/local-discovery.d.ts.map +1 -1
  14. package/dist/errors.d.ts +437 -35
  15. package/dist/errors.d.ts.map +1 -1
  16. package/dist/fastify-plugins/blobs.d.ts.map +1 -1
  17. package/dist/fastify-plugins/icons.d.ts.map +1 -1
  18. package/dist/fastify-plugins/maps.d.ts.map +1 -1
  19. package/dist/generated/extensions.d.ts +1 -1
  20. package/dist/generated/extensions.d.ts.map +1 -1
  21. package/dist/generated/rpc.d.ts +1 -0
  22. package/dist/generated/rpc.d.ts.map +1 -1
  23. package/dist/icon-api.d.ts +0 -1
  24. package/dist/icon-api.d.ts.map +1 -1
  25. package/dist/import-categories.d.ts.map +1 -1
  26. package/dist/index-writer/index.d.ts.map +1 -1
  27. package/dist/index.d.ts +1 -0
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/intl/parse-bcp-47.d.ts.map +1 -1
  30. package/dist/invite/invite-api.d.ts.map +1 -1
  31. package/dist/lib/drizzle-helpers.d.ts.map +1 -1
  32. package/dist/lib/hypercore-helpers.d.ts.map +1 -1
  33. package/dist/lib/key-by.d.ts.map +1 -1
  34. package/dist/local-peers.d.ts +0 -14
  35. package/dist/local-peers.d.ts.map +1 -1
  36. package/dist/logger.d.ts.map +1 -1
  37. package/dist/mapeo-manager.d.ts +2 -1
  38. package/dist/mapeo-manager.d.ts.map +1 -1
  39. package/dist/mapeo-project.d.ts +15 -8
  40. package/dist/mapeo-project.d.ts.map +1 -1
  41. package/dist/member-api.d.ts +42 -7
  42. package/dist/member-api.d.ts.map +1 -1
  43. package/dist/roles.d.ts.map +1 -1
  44. package/dist/schema/json-schema-to-drizzle.d.ts.map +1 -1
  45. package/dist/schema.d.ts +2 -0
  46. package/dist/schema.d.ts.map +1 -0
  47. package/dist/sync/core-sync-state.d.ts.map +1 -1
  48. package/dist/sync/peer-sync-controller.d.ts.map +1 -1
  49. package/dist/sync/sync-api.d.ts.map +1 -1
  50. package/dist/utils.d.ts +8 -10
  51. package/dist/utils.d.ts.map +1 -1
  52. package/package.json +18 -2
  53. package/src/blob-api.js +24 -4
  54. package/src/blob-store/downloader.js +7 -6
  55. package/src/blob-store/entries-stream.js +1 -1
  56. package/src/blob-store/hyperdrive-index.js +3 -5
  57. package/src/blob-store/index.js +15 -20
  58. package/src/core-manager/bitfield-rle.js +2 -1
  59. package/src/core-manager/core-index.js +2 -1
  60. package/src/core-manager/index.js +12 -13
  61. package/src/core-ownership.js +7 -3
  62. package/src/datastore/index.js +13 -9
  63. package/src/datatype/index.js +28 -5
  64. package/src/discovery/local-discovery.js +8 -7
  65. package/src/errors.js +530 -62
  66. package/src/fastify-controller.js +3 -3
  67. package/src/fastify-plugins/blobs.js +21 -14
  68. package/src/fastify-plugins/icons.js +18 -9
  69. package/src/fastify-plugins/maps.js +6 -5
  70. package/src/generated/extensions.d.ts +1 -1
  71. package/src/generated/extensions.js +5 -5
  72. package/src/generated/extensions.ts +6 -6
  73. package/src/generated/rpc.d.ts +1 -0
  74. package/src/generated/rpc.js +12 -1
  75. package/src/generated/rpc.ts +13 -0
  76. package/src/icon-api.js +15 -7
  77. package/src/import-categories.js +6 -7
  78. package/src/index-writer/index.js +3 -2
  79. package/src/index.js +1 -0
  80. package/src/intl/parse-bcp-47.js +2 -1
  81. package/src/invite/invite-api.js +26 -20
  82. package/src/lib/drizzle-helpers.js +54 -39
  83. package/src/lib/hypercore-helpers.js +4 -2
  84. package/src/lib/key-by.js +3 -1
  85. package/src/local-peers.js +39 -46
  86. package/src/logger.js +2 -1
  87. package/src/mapeo-manager.js +36 -23
  88. package/src/mapeo-project.js +96 -67
  89. package/src/member-api.js +177 -96
  90. package/src/roles.js +11 -10
  91. package/src/schema/json-schema-to-drizzle.js +13 -4
  92. package/src/schema.js +1 -0
  93. package/src/sync/core-sync-state.js +2 -1
  94. package/src/sync/peer-sync-controller.js +4 -3
  95. package/src/sync/sync-api.js +9 -9
  96. package/src/translation-api.js +2 -2
  97. package/src/utils.js +58 -43
  98. package/dist/lib/error.d.ts +0 -51
  99. package/dist/lib/error.d.ts.map +0 -1
  100. package/src/lib/error.js +0 -71
package/package.json CHANGED
@@ -1,9 +1,23 @@
1
1
  {
2
2
  "name": "@comapeo/core",
3
- "version": "5.4.1",
3
+ "version": "6.0.0",
4
4
  "description": "Offline p2p mapping library",
5
5
  "main": "src/index.js",
6
6
  "types": "dist/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "import": "./src/index.js"
11
+ },
12
+ "./errors.js": {
13
+ "types": "./dist/errors.d.ts",
14
+ "import": "./src/errors.js"
15
+ },
16
+ "./schema.js": {
17
+ "types": "./dist/schema.d.ts",
18
+ "import": "./src/schema.js"
19
+ }
20
+ },
7
21
  "type": "module",
8
22
  "scripts": {
9
23
  "lint": "eslint --cache .",
@@ -13,6 +27,7 @@
13
27
  "test:unit": "node --test",
14
28
  "test:e2e": "node --test test-e2e/*.js test-e2e/**/*.js",
15
29
  "test:types": "tsc -p test-types/tsconfig.json",
30
+ "build": "npm-run-all build:types",
16
31
  "build:types": "tsc -p tsconfig.npm.json && cpy 'src/**/*.d.ts' dist",
17
32
  "bench": "nanobench benchmarks/*.js",
18
33
  "type": "tsc",
@@ -21,7 +36,7 @@
21
36
  "protobuf": "node ./scripts/build-messages.js",
22
37
  "db:generate:project": "drizzle-kit generate --dialect sqlite --schema src/schema/project.js --out drizzle/project",
23
38
  "db:generate:client": "drizzle-kit generate --dialect sqlite --schema src/schema/client.js --out drizzle/client",
24
- "prepack": "npm run build:types",
39
+ "prepack": "npm run build",
25
40
  "prepare": "husky install"
26
41
  },
27
42
  "files": [
@@ -182,6 +197,7 @@
182
197
  "comapeocat": "^1.0.0",
183
198
  "compact-encoding": "^2.12.0",
184
199
  "corestore": "6.8.4",
200
+ "custom-error-creator": "^1.1.1",
185
201
  "debug": "^4.3.4",
186
202
  "dot-prop": "^9.0.0",
187
203
  "dot-prop-extra": "^10.2.0",
package/src/blob-api.js CHANGED
@@ -2,6 +2,7 @@ import fs from 'node:fs'
2
2
  // @ts-expect-error - pipelinePromise missing from streamx types
3
3
  import { Transform, pipelinePromise as pipeline } from 'streamx'
4
4
  import { createHash, randomBytes } from 'node:crypto'
5
+ import { BlobReadError, UnsupportedMimeTypeError } from './errors.js'
5
6
  /** @import { BlobId, BlobType } from './types.js' */
6
7
 
7
8
  /**
@@ -57,8 +58,13 @@ export class BlobApi {
57
58
  { type, variant: 'original', name },
58
59
  { metadata }
59
60
  )
61
+
60
62
  const writePromises = [
61
- pipeline(fs.createReadStream(original), hashTransform(hash), ws),
63
+ pipeline(fs.createReadStream(original), hashTransform(hash), ws).catch(
64
+ (/** @type {Error} */ e) => {
65
+ throw new BlobReadError(original, { cause: e })
66
+ }
67
+ ),
62
68
  ]
63
69
 
64
70
  if (preview) {
@@ -66,7 +72,14 @@ export class BlobApi {
66
72
  { type, variant: 'preview', name },
67
73
  { metadata }
68
74
  )
69
- writePromises.push(pipeline(fs.createReadStream(preview), ws))
75
+
76
+ writePromises.push(
77
+ pipeline(fs.createReadStream(preview), ws).catch(
78
+ (/** @type {Error} */ e) => {
79
+ throw new BlobReadError(preview, { cause: e })
80
+ }
81
+ )
82
+ )
70
83
  }
71
84
 
72
85
  if (thumbnail) {
@@ -74,7 +87,14 @@ export class BlobApi {
74
87
  { type, variant: 'thumbnail', name },
75
88
  { metadata }
76
89
  )
77
- writePromises.push(pipeline(fs.createReadStream(thumbnail), ws))
90
+
91
+ writePromises.push(
92
+ pipeline(fs.createReadStream(thumbnail), ws).catch(
93
+ (/** @type {Error} */ e) => {
94
+ throw new BlobReadError(thumbnail, { cause: e })
95
+ }
96
+ )
97
+ )
78
98
  }
79
99
 
80
100
  await Promise.all(writePromises)
@@ -109,5 +129,5 @@ function getType(mimeType) {
109
129
  if (mimeType.startsWith('video')) return 'video'
110
130
  if (mimeType.startsWith('audio')) return 'audio'
111
131
 
112
- throw new Error(`Unsupported mimeType: ${mimeType}`)
132
+ throw new UnsupportedMimeTypeError({ mimeType })
113
133
  }
@@ -1,6 +1,7 @@
1
1
  import { TypedEmitter } from 'tiny-typed-emitter'
2
2
  import { createEntriesStream } from './entries-stream.js'
3
3
  import { filePathMatchesFilter } from './utils.js'
4
+ import { DriveNotFoundError, UnexpectedEndOfStreamError } from '../errors.js'
4
5
 
5
6
  /** @import { BlobFilter } from '../types.js' */
6
7
  /** @import { THyperdriveIndex } from './hyperdrive-index.js' */
@@ -74,13 +75,13 @@ export class Downloader extends TypedEmitter {
74
75
  if (!this.#shouldDownloadFile(filePath)) continue
75
76
  const drive = this.#driveIndex.get(driveId)
76
77
  // ERROR HANDLING: this is unexpected and should not happen
77
- if (!drive) throw new Error('Drive not found: ' + driveId)
78
+ if (!drive) throw new DriveNotFoundError({ driveId })
78
79
  const blobs = await drive.getBlobs()
79
80
  this.#ac.signal.throwIfAborted()
80
81
  await this.#processEntry(blobs.core, blob)
81
82
  this.#ac.signal.throwIfAborted()
82
83
  }
83
- throw new Error('Entries stream ended unexpectedly')
84
+ throw new UnexpectedEndOfStreamError()
84
85
  }
85
86
 
86
87
  /**
@@ -112,11 +113,11 @@ export class Downloader extends TypedEmitter {
112
113
  this.#ac.abort()
113
114
  }
114
115
 
115
- /** @param {Error} error */
116
- #handleError = (error) => {
116
+ /** @param {Error} err */
117
+ #handleError = (err) => {
117
118
  if (this.#ac.signal.aborted) return
118
- this.emit('error', error)
119
- this.#ac.abort(error)
119
+ this.emit('error', err)
120
+ this.#ac.abort(err)
120
121
  }
121
122
 
122
123
  #handleAbort = () => {
@@ -101,6 +101,6 @@ class AddDriveIds extends Transform {
101
101
  }
102
102
  callback(null, { ...entry, driveId: this.#driveId, blobCoreId })
103
103
  })
104
- .catch((reason) => callback(ensureError(reason)))
104
+ .catch((e) => callback(ensureError(e)))
105
105
  }
106
106
  }
@@ -1,8 +1,8 @@
1
1
  import b4a from 'b4a'
2
2
  import { discoveryKey } from 'hypercore-crypto'
3
3
  import Hyperdrive from 'hyperdrive'
4
- import util from 'node:util'
5
4
  import { TypedEmitter } from 'tiny-typed-emitter'
5
+ import { MissingWriterError, UnsupportedCorestoreOptsError } from '../errors.js'
6
6
 
7
7
  /** @typedef {HyperdriveIndexImpl} THyperdriveIndex */
8
8
 
@@ -33,7 +33,7 @@ export class HyperdriveIndexImpl extends TypedEmitter {
33
33
  }
34
34
  }
35
35
  if (!writer) {
36
- throw new Error('Could not find a writer for the blobIndex namespace')
36
+ throw new MissingWriterError({ namespace: 'blobIndex' })
37
37
  }
38
38
  this.#writer = writer
39
39
 
@@ -103,9 +103,7 @@ class PretendCorestore {
103
103
  } else if (opts.name.includes('blobs')) {
104
104
  return this.#coreManager.getWriterCore('blob').core
105
105
  } else {
106
- throw new Error(
107
- 'Unsupported corestore.get() with opts ' + util.inspect(opts)
108
- )
106
+ throw new UnsupportedCorestoreOptsError({ opts })
109
107
  }
110
108
  }
111
109
 
@@ -7,7 +7,13 @@ import { noop } from '../utils.js'
7
7
  import { TypedEmitter } from 'tiny-typed-emitter'
8
8
  import { HyperdriveIndexImpl as HyperdriveIndex } from './hyperdrive-index.js'
9
9
  import { Logger } from '../logger.js'
10
- import { getErrorCode, getErrorMessage } from '../lib/error.js'
10
+ import {
11
+ BlobNotFoundError,
12
+ getErrorCode,
13
+ BlobsNotFoundError,
14
+ DriveNotFoundError,
15
+ } from '../errors.js'
16
+ import ensureError from 'ensure-error'
11
17
 
12
18
  /** @import Hyperdrive from 'hyperdrive' */
13
19
  /** @import { JsonObject } from 'type-fest' */
@@ -52,13 +58,6 @@ const NON_ARCHIVE_DEVICE_DOWNLOAD_FILTER = {
52
58
  // thumbnails aren't supported yet.
53
59
  }
54
60
 
55
- class ErrNotFound extends Error {
56
- constructor(message = 'NotFound') {
57
- super(message)
58
- this.code = 'ENOENT'
59
- }
60
- }
61
-
62
61
  /** @extends {TypedEmitter<BlobStoreEvents>} */
63
62
  export class BlobStore extends TypedEmitter {
64
63
  #driveIndex
@@ -110,12 +109,12 @@ export class BlobStore extends TypedEmitter {
110
109
  blobCoreId,
111
110
  })
112
111
  }
113
- } catch (err) {
114
- if (getErrorCode(err) === 'ERR_STREAM_PREMATURE_CLOSE') return
112
+ } catch (e) {
113
+ if (getErrorCode(e) === 'ERR_STREAM_PREMATURE_CLOSE') return
115
114
  this.#l.log(
116
115
  'Error getting blob entries stream for peer %h: %s',
117
116
  peerId,
118
- getErrorMessage(err)
117
+ ensureError(e).message
119
118
  )
120
119
  }
121
120
  }
@@ -217,7 +216,7 @@ export class BlobStore extends TypedEmitter {
217
216
  */
218
217
  #getDrive(driveId) {
219
218
  const drive = this.#driveIndex.get(driveId)
220
- if (!drive) throw new Error('Drive not found ' + driveId.slice(0, 7))
219
+ if (!drive) throw new DriveNotFoundError({ driveId: driveId.slice(0, 7) })
221
220
  return drive
222
221
  }
223
222
 
@@ -232,7 +231,7 @@ export class BlobStore extends TypedEmitter {
232
231
  const drive = this.#getDrive(driveId)
233
232
  const path = makePath({ type, variant, name })
234
233
  const blob = await drive.get(path, { wait, timeout })
235
- if (!blob) throw new ErrNotFound()
234
+ if (!blob) throw new BlobNotFoundError()
236
235
  return blob
237
236
  }
238
237
 
@@ -285,9 +284,7 @@ export class BlobStore extends TypedEmitter {
285
284
  const blobs = await drive.getBlobs()
286
285
 
287
286
  if (!blobs) {
288
- throw new Error(
289
- 'Hyperblobs instance not found for drive ' + driveId.slice(0, 7)
290
- )
287
+ throw new BlobsNotFoundError({ driveId: driveId.slice(0, 7) })
291
288
  }
292
289
 
293
290
  return blobs.createReadStream(entry.value.blob, options)
@@ -305,9 +302,7 @@ export class BlobStore extends TypedEmitter {
305
302
  const blobs = await drive.getBlobs()
306
303
 
307
304
  if (!blobs) {
308
- throw new Error(
309
- 'Hyperblobs instance not found for drive ' + driveId.slice(0, 7)
310
- )
305
+ throw new BlobsNotFoundError({ driveId: driveId.slice(0, 7) })
311
306
  }
312
307
 
313
308
  return blobs.get(entry.value.blob, { wait: false, start: 0, length })
@@ -380,7 +375,7 @@ export class BlobStore extends TypedEmitter {
380
375
  options = { follow: false, wait: false }
381
376
  ) {
382
377
  const drive = this.#driveIndex.get(driveId)
383
- if (!drive) throw new Error('Drive not found ' + driveId.slice(0, 7))
378
+ if (!drive) throw new DriveNotFoundError({ driveId: driveId.slice(0, 7) })
384
379
  const path = makePath({ type, variant, name })
385
380
  const entry = await drive.entry(path, options)
386
381
  return entry
@@ -3,6 +3,7 @@
3
3
  // Modified to encode and decode Uint32Arrays
4
4
 
5
5
  import varint from 'varint'
6
+ import { InvalidBitfieldError } from '../errors.js'
6
7
 
7
8
  const isLittleEndian =
8
9
  new Uint8Array(new Uint16Array([0xff]).buffer)[0] === 0xff
@@ -123,7 +124,7 @@ export function decodingLength(buffer, offset) {
123
124
  if (!repeat) offset += slice
124
125
  }
125
126
 
126
- if (offset > buffer.length) throw new Error('Invalid RLE bitfield')
127
+ if (offset > buffer.length) throw new InvalidBitfieldError()
127
128
 
128
129
  if (len & (n - 1)) return len + (n - (len & (n - 1)))
129
130
 
@@ -1,4 +1,5 @@
1
1
  import crypto from 'hypercore-crypto'
2
+ import { MissingWriterError } from '../errors.js'
2
3
  /** @import { Namespace } from '../types.js' */
3
4
  /** @import { CoreRecord } from './index.js' */
4
5
 
@@ -59,7 +60,7 @@ export class CoreIndex {
59
60
  const writerRecord = this.#writersByNamespace.get(namespace)
60
61
  // Shouldn't happen, since we add all the writers in the contructor
61
62
  if (!writerRecord) {
62
- throw new Error(`Writer for namespace '${namespace}' is not defined`)
63
+ throw new MissingWriterError({ namespace })
63
64
  }
64
65
  return writerRecord
65
66
  }
@@ -1,7 +1,6 @@
1
1
  import { TypedEmitter } from 'tiny-typed-emitter'
2
2
  import Corestore from 'corestore'
3
3
  import { debounce } from 'throttle-debounce'
4
- import assert from 'node:assert/strict'
5
4
  import { sql, eq } from 'drizzle-orm'
6
5
 
7
6
  import {
@@ -17,7 +16,11 @@ import { coresTable } from '../schema/project.js'
17
16
  import * as rle from './bitfield-rle.js'
18
17
  import { CoreIndex } from './core-index.js'
19
18
  import mapObject from 'map-obj'
20
- import { PeerNotFoundError } from '../errors.js'
19
+ import {
20
+ InvalidProjectKeyError,
21
+ InvalidProjectSecretKeyError,
22
+ PeerNotFoundError,
23
+ } from '../errors.js'
21
24
 
22
25
  /** @import Hypercore from 'hypercore' */
23
26
  /** @import { BlobFilter, GenericBlobFilter, HypercorePeer, Namespace } from '../types.js' */
@@ -83,14 +86,10 @@ export class CoreManager extends TypedEmitter {
83
86
  logger,
84
87
  }) {
85
88
  super()
86
- assert(
87
- projectKey.length === 32,
88
- 'project owner core public key must be 32-byte buffer'
89
- )
90
- assert(
91
- !projectSecretKey || projectSecretKey.length === 64,
92
- 'project owner core secret key must be 64-byte buffer'
93
- )
89
+ if (projectKey.length !== 32) throw new InvalidProjectKeyError()
90
+ if (projectSecretKey && projectSecretKey.length !== 64) {
91
+ throw new InvalidProjectSecretKeyError()
92
+ }
94
93
  // Each peer will attach a listener, so max listeners is max attached peers
95
94
  this.setMaxListeners(0)
96
95
  this.#l = Logger.create('coreManager', logger)
@@ -492,11 +491,11 @@ export class CoreManager extends TypedEmitter {
492
491
  /**
493
492
  * Send a map share to a peer
494
493
  * @param {MapShareExtension} mapShare
495
- * @param {HypercorePeer['remotePublicKey']} peerId
496
494
  */
497
- async sendMapShare(mapShare, peerId) {
495
+ async sendMapShare(mapShare) {
496
+ const { receiverDeviceKey } = mapShare
498
497
  for (const peer of this.creatorCore.peers) {
499
- if (peer.remotePublicKey.equals(peerId)) {
498
+ if (peer.remotePublicKey.equals(receiverDeviceKey)) {
500
499
  this.#mapShareExtension.send(mapShare, peer)
501
500
  return
502
501
  }
@@ -16,7 +16,7 @@ import pDefer from 'p-defer'
16
16
  import { NAMESPACES } from './constants.js'
17
17
  import { TypedEmitter } from 'tiny-typed-emitter'
18
18
  import { omit } from './lib/omit.js'
19
- import { NotFoundError } from './errors.js'
19
+ import { InvalidCoreOwnershipError, NotFoundError } from './errors.js'
20
20
  /**
21
21
  * @import {
22
22
  * CoreOwnershipWithSignatures,
@@ -161,10 +161,14 @@ export function mapAndValidateCoreOwnership(doc, { coreDiscoveryKey }) {
161
161
  if (
162
162
  !coreDiscoveryKey.equals(discoveryKey(Buffer.from(doc.authCoreId, 'hex')))
163
163
  ) {
164
- throw new Error('Invalid coreOwnership record: mismatched authCoreId')
164
+ throw new InvalidCoreOwnershipError(
165
+ 'Invalid coreOwnership record: mismatched authCoreId'
166
+ )
165
167
  }
166
168
  if (!verifyCoreOwnership(doc)) {
167
- throw new Error('Invalid coreOwnership record: signatures are invalid')
169
+ throw new InvalidCoreOwnershipError(
170
+ 'Invalid coreOwnership record: signatures are invalid'
171
+ )
168
172
  }
169
173
  const docWithoutSignatures = omit(doc, [
170
174
  'identitySignature',
@@ -5,7 +5,12 @@ import pDefer from 'p-defer'
5
5
  import { discoveryKey } from 'hypercore-crypto'
6
6
  import { NAMESPACE_SCHEMAS } from '../constants.js'
7
7
  import { createMap } from '../utils.js'
8
- import { NotFoundError } from '../errors.js'
8
+ import {
9
+ InvalidDocSchemaError,
10
+ InvalidVersionIdError,
11
+ NotFoundError,
12
+ WriterCoreNotReadyError,
13
+ } from '../errors.js'
9
14
  /** @import { MapeoDoc } from '@comapeo/schema' */
10
15
 
11
16
  /**
@@ -139,11 +144,10 @@ export class DataStore extends TypedEmitter {
139
144
  async write(doc) {
140
145
  // @ts-ignore
141
146
  if (!NAMESPACE_SCHEMAS[this.#namespace].includes(doc.schemaName)) {
142
- throw new Error(
143
- `Schema '${doc.schemaName}' is not allowed in namespace '${
144
- this.#namespace
145
- }'`
146
- )
147
+ throw new InvalidDocSchemaError({
148
+ schemaName: doc.schemaName,
149
+ namespace: this.#namespace,
150
+ })
147
151
  }
148
152
  const block = encode(doc)
149
153
  // The indexer batch can sometimes complete before the append below
@@ -159,7 +163,7 @@ export class DataStore extends TypedEmitter {
159
163
  const index = length - 1
160
164
  const coreDiscoveryKey = this.#writerCore.discoveryKey
161
165
  if (!coreDiscoveryKey) {
162
- throw new Error('Writer core is not ready')
166
+ throw new WriterCoreNotReadyError()
163
167
  }
164
168
  const versionId = getVersionId({ coreDiscoveryKey, index })
165
169
  /** @type {import('p-defer').DeferredPromise<void>} */
@@ -181,7 +185,7 @@ export class DataStore extends TypedEmitter {
181
185
  async read(versionId) {
182
186
  const { coreDiscoveryKey, index } = parseVersionId(versionId)
183
187
  const coreRecord = this.#coreManager.getCoreByDiscoveryKey(coreDiscoveryKey)
184
- if (!coreRecord) throw new Error('Invalid versionId')
188
+ if (!coreRecord) throw new InvalidVersionIdError()
185
189
  const block = await coreRecord.core.get(index, { wait: false })
186
190
  if (!block) throw new NotFoundError('Not Found')
187
191
  return decode(block, { coreDiscoveryKey, index })
@@ -193,7 +197,7 @@ export class DataStore extends TypedEmitter {
193
197
  const index = length - 1
194
198
  const coreDiscoveryKey = this.#writerCore.discoveryKey
195
199
  if (!coreDiscoveryKey) {
196
- throw new Error('Writer core is not ready')
200
+ throw new WriterCoreNotReadyError()
197
201
  }
198
202
  const versionId = getVersionId({ coreDiscoveryKey, index })
199
203
  return versionId
@@ -3,10 +3,18 @@ import { getTableConfig } from 'drizzle-orm/sqlite-core'
3
3
  import { eq, inArray, sql } from 'drizzle-orm'
4
4
  import { randomBytes } from 'node:crypto'
5
5
  import { noop, mutatingDeNullify } from '../utils.js'
6
- import { NotFoundError } from '../errors.js'
6
+ import {
7
+ DocAlreadyDeletedError,
8
+ DocAlreadyExistsError,
9
+ InvalidDocError,
10
+ InvalidDocFormatError,
11
+ NotFoundError,
12
+ nullIfNotFound,
13
+ } from '../errors.js'
7
14
  import { TypedEmitter } from 'tiny-typed-emitter'
8
15
  import { setProperty, getProperty } from 'dot-prop-extra'
9
16
  import { parseBcp47 } from '../intl/parse-bcp-47.js'
17
+
10
18
  /** @import { MapeoDoc, MapeoValue } from '@comapeo/schema' */
11
19
  /** @import { RunResult } from 'better-sqlite3' */
12
20
  /** @import { SQLiteSelectBase } from 'drizzle-orm/sqlite-core' */
@@ -78,6 +86,7 @@ function generateDate() {
78
86
  return new Date().toISOString()
79
87
  }
80
88
  export const kCreateWithDocId = Symbol('kCreateWithDocId')
89
+ export const kCreateOrUpdateWithDocId = Symbol('kCreateWithDocId')
81
90
  export const kSelect = Symbol('select')
82
91
  export const kTable = Symbol('table')
83
92
  export const kDataStore = Symbol('dataStore')
@@ -181,6 +190,20 @@ export class DataType extends TypedEmitter {
181
190
  return this[kCreateWithDocId](docId, value, { checkExisting: false })
182
191
  }
183
192
 
193
+ /**
194
+ * @param {string} docId
195
+ * @param {ExcludeSchema<TValue, 'coreOwnership'>} value
196
+ * @returns {Promise<TDoc & DerivedDocFields>}
197
+ */
198
+ async [kCreateOrUpdateWithDocId](docId, value) {
199
+ const existing = await this.getByDocId(docId).catch(nullIfNotFound)
200
+ if (existing) {
201
+ return this.update(existing.versionId, value)
202
+ } else {
203
+ return this[kCreateWithDocId](docId, value, { checkExisting: false })
204
+ }
205
+ }
206
+
184
207
  /**
185
208
  * @param {string} docId
186
209
  * @param {ExcludeSchema<TValue, 'coreOwnership'> | CoreOwnershipWithSignaturesValue} value
@@ -190,12 +213,12 @@ export class DataType extends TypedEmitter {
190
213
  async [kCreateWithDocId](docId, value, { checkExisting = true } = {}) {
191
214
  if (!validate(this.#schemaName, value)) {
192
215
  // TODO: pass through errors from validate functions
193
- throw new Error('Invalid value ' + value)
216
+ throw new InvalidDocFormatError({ value })
194
217
  }
195
218
  if (checkExisting) {
196
219
  const existing = await this.getByDocId(docId).catch(noop)
197
220
  if (existing) {
198
- throw new Error('Doc with docId ' + docId + ' already exists')
221
+ throw new DocAlreadyExistsError({ docId })
199
222
  }
200
223
  }
201
224
  const nowDateString = generateDate()
@@ -376,7 +399,7 @@ export class DataType extends TypedEmitter {
376
399
  const existingDoc = await this.getByDocId(docId)
377
400
 
378
401
  if ('deleted' in existingDoc && existingDoc.deleted) {
379
- throw new Error('Doc already deleted')
402
+ throw new DocAlreadyDeletedError()
380
403
  }
381
404
 
382
405
  /** @type {any} */
@@ -423,7 +446,7 @@ export class DataType extends TypedEmitter {
423
446
  (doc) => doc.docId === docId && doc.schemaName === this.#schemaName
424
447
  )
425
448
  if (!areLinksValid) {
426
- throw new Error('Updated docs must have the same docId and schemaName')
449
+ throw new InvalidDocError()
427
450
  }
428
451
  return { docId, createdAt, originalVersionId }
429
452
  }
@@ -3,13 +3,14 @@ import net from 'node:net'
3
3
  import { randomBytes } from 'node:crypto'
4
4
  import NoiseSecretStream from '@hyperswarm/secret-stream'
5
5
  import { once } from 'node:events'
6
- import { noop } from '../utils.js'
6
+ import { noop, timeoutPromise } from '../utils.js'
7
7
  import { isPrivate } from 'bogon'
8
8
  import StartStopStateMachine from 'start-stop-state-machine'
9
- import pTimeout from 'p-timeout'
10
9
  import { keyToPublicId } from '@mapeo/crypto'
11
10
  import { Logger } from '../logger.js'
12
- import { getErrorCode } from '../lib/error.js'
11
+ import { ensureKnownError, getErrorCode } from '../errors.js'
12
+ import { ServerNotListeningError } from '../errors.js'
13
+
13
14
  /** @import { OpenedNoiseStream } from '../lib/noise-secret-stream-helpers.js' */
14
15
 
15
16
  /** @typedef {{ publicKey: Buffer, secretKey: Buffer }} Keypair */
@@ -88,7 +89,7 @@ export class LocalDiscovery extends TypedEmitter {
88
89
  this.#server.listen(this.#port, '0.0.0.0')
89
90
  await onListening
90
91
  } catch (e) {
91
- if (this.#port === 0) throw e
92
+ if (this.#port === 0) throw ensureKnownError(e)
92
93
  // Account for errors from re-binding the port failing
93
94
  this.#port = 0
94
95
  return this.#start()
@@ -297,7 +298,7 @@ export class LocalDiscovery extends TypedEmitter {
297
298
  for (const socket of this.#noiseConnections.values()) {
298
299
  socket.destroy()
299
300
  }
300
- return pTimeout(closePromise, { milliseconds: 500 })
301
+ return timeoutPromise(closePromise, { milliseconds: 500 })
301
302
  }
302
303
 
303
304
  if (!force) {
@@ -306,7 +307,7 @@ export class LocalDiscovery extends TypedEmitter {
306
307
  // If timeout is 0, we force-close immediately
307
308
  await forceClose()
308
309
  } else {
309
- await pTimeout(closePromise, {
310
+ await timeoutPromise(closePromise, {
310
311
  milliseconds: timeout,
311
312
  fallback: forceClose,
312
313
  })
@@ -324,7 +325,7 @@ export class LocalDiscovery extends TypedEmitter {
324
325
  function getAddress(server) {
325
326
  const addr = server.address()
326
327
  if (addr === null || typeof addr === 'string') {
327
- throw new Error('Server is not listening on a port')
328
+ throw new ServerNotListeningError()
328
329
  }
329
330
  return addr
330
331
  }