@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.
- package/LICENSE.md +9 -0
- package/README.md +31 -0
- package/dist/blob-api.d.ts +92 -0
- package/dist/blob-api.d.ts.map +1 -0
- package/dist/blob-store/index.d.ts +163 -0
- package/dist/blob-store/index.d.ts.map +1 -0
- package/dist/blob-store/live-download.d.ts +107 -0
- package/dist/blob-store/live-download.d.ts.map +1 -0
- package/dist/config-import.d.ts +74 -0
- package/dist/config-import.d.ts.map +1 -0
- package/dist/constants.d.ts +14 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/core-manager/bitfield-rle.d.ts +25 -0
- package/dist/core-manager/bitfield-rle.d.ts.map +1 -0
- package/dist/core-manager/core-index.d.ts +56 -0
- package/dist/core-manager/core-index.d.ts.map +1 -0
- package/dist/core-manager/index.d.ts +125 -0
- package/dist/core-manager/index.d.ts.map +1 -0
- package/dist/core-manager/random-access-file-pool.d.ts +17 -0
- package/dist/core-manager/random-access-file-pool.d.ts.map +1 -0
- package/dist/core-manager/remote-bitfield.d.ts +146 -0
- package/dist/core-manager/remote-bitfield.d.ts.map +1 -0
- package/dist/core-ownership.d.ts +112 -0
- package/dist/core-ownership.d.ts.map +1 -0
- package/dist/datastore/index.d.ts +91 -0
- package/dist/datastore/index.d.ts.map +1 -0
- package/dist/datatype/index.d.ts +108 -0
- package/dist/discovery/local-discovery.d.ts +64 -0
- package/dist/discovery/local-discovery.d.ts.map +1 -0
- package/dist/errors.d.ts +4 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/fastify-controller.d.ts +27 -0
- package/dist/fastify-controller.d.ts.map +1 -0
- package/dist/fastify-plugins/blobs.d.ts +6 -0
- package/dist/fastify-plugins/blobs.d.ts.map +1 -0
- package/dist/fastify-plugins/constants.d.ts +3 -0
- package/dist/fastify-plugins/constants.d.ts.map +1 -0
- package/dist/fastify-plugins/icons.d.ts +6 -0
- package/dist/fastify-plugins/icons.d.ts.map +1 -0
- package/dist/fastify-plugins/maps/index.d.ts +11 -0
- package/dist/fastify-plugins/maps/index.d.ts.map +1 -0
- package/dist/fastify-plugins/maps/offline-fallback-map.d.ts +12 -0
- package/dist/fastify-plugins/maps/offline-fallback-map.d.ts.map +1 -0
- package/dist/fastify-plugins/maps/static-maps.d.ts +11 -0
- package/dist/fastify-plugins/maps/static-maps.d.ts.map +1 -0
- package/dist/fastify-plugins/utils.d.ts +23 -0
- package/dist/fastify-plugins/utils.d.ts.map +1 -0
- package/dist/generated/extensions.d.ts +44 -0
- package/dist/generated/extensions.d.ts.map +1 -0
- package/dist/generated/keys.d.ts +36 -0
- package/dist/generated/keys.d.ts.map +1 -0
- package/dist/generated/rpc.d.ts +87 -0
- package/dist/generated/rpc.d.ts.map +1 -0
- package/dist/icon-api.d.ts +109 -0
- package/dist/icon-api.d.ts.map +1 -0
- package/dist/index-writer/index.d.ts +51 -0
- package/dist/index-writer/index.d.ts.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/invite-api.d.ts +70 -0
- package/dist/invite-api.d.ts.map +1 -0
- package/dist/lib/hashmap.d.ts +62 -0
- package/dist/lib/hashmap.d.ts.map +1 -0
- package/dist/lib/hypercore-helpers.d.ts +6 -0
- package/dist/lib/hypercore-helpers.d.ts.map +1 -0
- package/dist/lib/noise-secret-stream-helpers.d.ts +45 -0
- package/dist/lib/noise-secret-stream-helpers.d.ts.map +1 -0
- package/dist/lib/ponyfills.d.ts +10 -0
- package/dist/lib/ponyfills.d.ts.map +1 -0
- package/dist/lib/string.d.ts +2 -0
- package/dist/lib/string.d.ts.map +1 -0
- package/dist/lib/timing-safe-equal.d.ts +15 -0
- package/dist/lib/timing-safe-equal.d.ts.map +1 -0
- package/dist/local-peers.d.ts +151 -0
- package/dist/local-peers.d.ts.map +1 -0
- package/dist/logger.d.ts +32 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/mapeo-manager.d.ts +178 -0
- package/dist/mapeo-manager.d.ts.map +1 -0
- package/dist/mapeo-project.d.ts +3233 -0
- package/dist/mapeo-project.d.ts.map +1 -0
- package/dist/member-api.d.ts +114 -0
- package/dist/member-api.d.ts.map +1 -0
- package/dist/roles.d.ts +157 -0
- package/dist/roles.d.ts.map +1 -0
- package/dist/schema/client.d.ts +284 -0
- package/dist/schema/client.d.ts.map +1 -0
- package/dist/schema/project.d.ts +1812 -0
- package/dist/schema/project.d.ts.map +1 -0
- package/dist/schema/schema-to-drizzle.d.ts +20 -0
- package/dist/schema/schema-to-drizzle.d.ts.map +1 -0
- package/dist/schema/types.d.ts +98 -0
- package/dist/schema/types.d.ts.map +1 -0
- package/dist/schema/utils.d.ts +55 -0
- package/dist/schema/utils.d.ts.map +1 -0
- package/dist/sync/core-sync-state.d.ts +252 -0
- package/dist/sync/core-sync-state.d.ts.map +1 -0
- package/dist/sync/namespace-sync-state.d.ts +47 -0
- package/dist/sync/namespace-sync-state.d.ts.map +1 -0
- package/dist/sync/peer-sync-controller.d.ts +44 -0
- package/dist/sync/peer-sync-controller.d.ts.map +1 -0
- package/dist/sync/sync-api.d.ts +158 -0
- package/dist/sync/sync-api.d.ts.map +1 -0
- package/dist/sync/sync-state.d.ts +40 -0
- package/dist/sync/sync-state.d.ts.map +1 -0
- package/dist/translation-api.d.ts +288 -0
- package/dist/translation-api.d.ts.map +1 -0
- package/dist/types.d.ts +115 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/utils.d.ts +115 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils_types.d.ts +14 -0
- package/drizzle/client/0000_bumpy_carnage.sql +33 -0
- package/drizzle/client/meta/0000_snapshot.json +199 -0
- package/drizzle/client/meta/_journal.json +13 -0
- package/drizzle/project/0000_spooky_lady_ursula.sql +192 -0
- package/drizzle/project/meta/0000_snapshot.json +1137 -0
- package/drizzle/project/meta/_journal.json +13 -0
- package/package.json +202 -0
- package/src/blob-api.js +139 -0
- package/src/blob-store/index.js +325 -0
- package/src/blob-store/live-download.js +373 -0
- package/src/config-import.js +604 -0
- package/src/constants.js +34 -0
- package/src/core-manager/bitfield-rle.js +235 -0
- package/src/core-manager/core-index.js +87 -0
- package/src/core-manager/index.js +504 -0
- package/src/core-manager/random-access-file-pool.js +30 -0
- package/src/core-manager/remote-bitfield.js +416 -0
- package/src/core-ownership.js +235 -0
- package/src/datastore/README.md +46 -0
- package/src/datastore/index.js +234 -0
- package/src/datatype/README.md +33 -0
- package/src/datatype/index.d.ts +108 -0
- package/src/datatype/index.js +358 -0
- package/src/discovery/local-discovery.js +303 -0
- package/src/errors.js +5 -0
- package/src/fastify-controller.js +84 -0
- package/src/fastify-plugins/blobs.js +139 -0
- package/src/fastify-plugins/constants.js +5 -0
- package/src/fastify-plugins/icons.js +158 -0
- package/src/fastify-plugins/maps/index.js +173 -0
- package/src/fastify-plugins/maps/offline-fallback-map.js +114 -0
- package/src/fastify-plugins/maps/static-maps.js +271 -0
- package/src/fastify-plugins/utils.js +52 -0
- package/src/generated/README.md +3 -0
- package/src/generated/extensions.d.ts +44 -0
- package/src/generated/extensions.js +196 -0
- package/src/generated/extensions.ts +237 -0
- package/src/generated/keys.d.ts +36 -0
- package/src/generated/keys.js +148 -0
- package/src/generated/keys.ts +185 -0
- package/src/generated/rpc.d.ts +87 -0
- package/src/generated/rpc.js +389 -0
- package/src/generated/rpc.ts +463 -0
- package/src/icon-api.js +282 -0
- package/src/index-writer/README.md +38 -0
- package/src/index-writer/index.js +124 -0
- package/src/index.js +16 -0
- package/src/invite-api.js +450 -0
- package/src/lib/hashmap.js +91 -0
- package/src/lib/hypercore-helpers.js +18 -0
- package/src/lib/noise-secret-stream-helpers.js +37 -0
- package/src/lib/ponyfills.js +25 -0
- package/src/lib/string.js +7 -0
- package/src/lib/timing-safe-equal.js +34 -0
- package/src/local-peers.js +737 -0
- package/src/logger.js +99 -0
- package/src/mapeo-manager.js +914 -0
- package/src/mapeo-project.js +980 -0
- package/src/member-api.js +319 -0
- package/src/roles.js +412 -0
- package/src/schema/client.js +55 -0
- package/src/schema/project.js +44 -0
- package/src/schema/schema-to-drizzle.js +118 -0
- package/src/schema/types.ts +153 -0
- package/src/schema/utils.js +51 -0
- package/src/sync/core-sync-state.js +440 -0
- package/src/sync/namespace-sync-state.js +193 -0
- package/src/sync/peer-sync-controller.js +332 -0
- package/src/sync/sync-api.js +588 -0
- package/src/sync/sync-state.js +63 -0
- package/src/translation-api.js +141 -0
- package/src/types.ts +149 -0
- package/src/utils.js +210 -0
- package/src/utils_types.d.ts +14 -0
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
import { validate } from '@comapeo/schema'
|
|
2
|
+
import { getTableConfig } from 'drizzle-orm/sqlite-core'
|
|
3
|
+
import { eq, inArray, sql } from 'drizzle-orm'
|
|
4
|
+
import { randomBytes } from 'node:crypto'
|
|
5
|
+
import { noop, deNullify } from '../utils.js'
|
|
6
|
+
import { NotFoundError } from '../errors.js'
|
|
7
|
+
import { TypedEmitter } from 'tiny-typed-emitter'
|
|
8
|
+
import { parse as parseBCP47 } from 'bcp-47'
|
|
9
|
+
import { setProperty, getProperty } from 'dot-prop'
|
|
10
|
+
/** @import { MapeoDoc, MapeoValue } from '@comapeo/schema' */
|
|
11
|
+
/** @import { MapeoDocMap, MapeoValueMap } from '../types.js' */
|
|
12
|
+
/** @import { DataStore } from '../datastore/index.js' */
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @typedef {`${MapeoDoc['schemaName']}Table`} MapeoDocTableName
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* @template T
|
|
19
|
+
* @typedef {T[(keyof T) & MapeoDocTableName]} GetMapeoDocTables
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
22
|
+
* Union of Drizzle schema tables that correspond to MapeoDoc types (e.g. excluding backlink tables and other utility tables)
|
|
23
|
+
* @typedef {GetMapeoDocTables<typeof import('../schema/project.js')> | GetMapeoDocTables<typeof import('../schema/client.js')>} MapeoDocTables
|
|
24
|
+
*/
|
|
25
|
+
/**
|
|
26
|
+
* @typedef {{ [K in MapeoDocTables['_']['name']]: Extract<MapeoDocTables, { _: { name: K }}> }} MapeoDocTablesMap
|
|
27
|
+
*/
|
|
28
|
+
/**
|
|
29
|
+
* @template T
|
|
30
|
+
* @template {keyof any} K
|
|
31
|
+
* @typedef {T extends any ? Omit<T, K> : never} OmitUnion
|
|
32
|
+
*/
|
|
33
|
+
/**
|
|
34
|
+
* @template {MapeoDoc} TDoc
|
|
35
|
+
* @typedef {object} DataTypeEvents
|
|
36
|
+
* @property {(docs: TDoc[]) => void} updated-docs
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
function generateId() {
|
|
40
|
+
return randomBytes(32).toString('hex')
|
|
41
|
+
}
|
|
42
|
+
function generateDate() {
|
|
43
|
+
return new Date().toISOString()
|
|
44
|
+
}
|
|
45
|
+
export const kCreateWithDocId = Symbol('kCreateWithDocId')
|
|
46
|
+
export const kSelect = Symbol('select')
|
|
47
|
+
export const kTable = Symbol('table')
|
|
48
|
+
export const kDataStore = Symbol('dataStore')
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @template {DataStore} TDataStore
|
|
52
|
+
* @template {TDataStore['schemas'][number]} TSchemaName
|
|
53
|
+
* @template {MapeoDocTablesMap[TSchemaName]} TTable
|
|
54
|
+
* @template {Exclude<MapeoDocMap[TSchemaName], { schemaName: 'coreOwnership' }>} TDoc
|
|
55
|
+
* @template {Exclude<MapeoValueMap[TSchemaName], { schemaName: 'coreOwnership' }>} TValue
|
|
56
|
+
* @extends {TypedEmitter<DataTypeEvents<TDoc> & import('../types.js').DefaultEmitterEvents<DataTypeEvents<TDoc>>>}
|
|
57
|
+
*/
|
|
58
|
+
export class DataType extends TypedEmitter {
|
|
59
|
+
#dataStore
|
|
60
|
+
#table
|
|
61
|
+
#schemaName
|
|
62
|
+
#sql
|
|
63
|
+
#db
|
|
64
|
+
#getTranslations
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
*
|
|
68
|
+
* @param {object} opts
|
|
69
|
+
* @param {TTable} opts.table
|
|
70
|
+
* @param {TDataStore} opts.dataStore
|
|
71
|
+
* @param {import('drizzle-orm/better-sqlite3').BetterSQLite3Database} opts.db
|
|
72
|
+
* @param {import('../translation-api.js').default['get']} opts.getTranslations
|
|
73
|
+
*/
|
|
74
|
+
constructor({ dataStore, table, db, getTranslations }) {
|
|
75
|
+
super()
|
|
76
|
+
this.#dataStore = dataStore
|
|
77
|
+
this.#table = table
|
|
78
|
+
this.#schemaName = /** @type {TSchemaName} */ (getTableConfig(table).name)
|
|
79
|
+
this.#db = db
|
|
80
|
+
this.#getTranslations = getTranslations
|
|
81
|
+
this.#sql = {
|
|
82
|
+
getByDocId: db
|
|
83
|
+
.select()
|
|
84
|
+
.from(table)
|
|
85
|
+
.where(eq(table.docId, sql.placeholder('docId')))
|
|
86
|
+
.prepare(),
|
|
87
|
+
getMany: db
|
|
88
|
+
.select()
|
|
89
|
+
.from(table)
|
|
90
|
+
.where(eq(table.deleted, false))
|
|
91
|
+
.prepare(),
|
|
92
|
+
getManyWithDeleted: db.select().from(table).prepare(),
|
|
93
|
+
}
|
|
94
|
+
this.on('newListener', (eventName) => {
|
|
95
|
+
if (eventName !== 'updated-docs') return
|
|
96
|
+
if (this.listenerCount('updated-docs') > 1) return
|
|
97
|
+
if (this.#schemaName === 'projectSettings') return
|
|
98
|
+
// Avoid adding a listener to the dataStore unless we need to (e.g. this has a listener attached), for performance reasons.
|
|
99
|
+
this.#dataStore.on(this.#schemaName, this.#handleDataStoreUpdate)
|
|
100
|
+
})
|
|
101
|
+
this.on('removeListener', (eventName) => {
|
|
102
|
+
if (eventName !== 'updated-docs') return
|
|
103
|
+
if (this.listenerCount('updated-docs') > 0) return
|
|
104
|
+
if (this.#schemaName === 'projectSettings') return
|
|
105
|
+
this.#dataStore.off(this.#schemaName, this.#handleDataStoreUpdate)
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
get [kTable]() {
|
|
110
|
+
return this.#table
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
get schemaName() {
|
|
114
|
+
return this.#schemaName
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
get namespace() {
|
|
118
|
+
return this.#dataStore.namespace
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
get [kDataStore]() {
|
|
122
|
+
return this.#dataStore
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* @template {import('type-fest').Exact<TValue, T>} T
|
|
127
|
+
* @param {T} value
|
|
128
|
+
*/
|
|
129
|
+
async create(value) {
|
|
130
|
+
const docId = generateId()
|
|
131
|
+
// @ts-expect-error - can't figure this one out, types in index.d.ts override this
|
|
132
|
+
return this[kCreateWithDocId](docId, value, { checkExisting: false })
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* @param {string} docId
|
|
137
|
+
* @param {TValue | import('../types.js').CoreOwnershipWithSignaturesValue} value
|
|
138
|
+
* @param {{ checkExisting?: boolean }} [opts] - only used internally to skip the checkExisting check when creating a document with a random ID (collisions should be too small probability to be worth checking for)
|
|
139
|
+
*/
|
|
140
|
+
async [kCreateWithDocId](docId, value, { checkExisting = true } = {}) {
|
|
141
|
+
if (!validate(this.#schemaName, value)) {
|
|
142
|
+
// TODO: pass through errors from validate functions
|
|
143
|
+
throw new Error('Invalid value ' + value)
|
|
144
|
+
}
|
|
145
|
+
if (checkExisting) {
|
|
146
|
+
const existing = await this.getByDocId(docId).catch(noop)
|
|
147
|
+
if (existing) {
|
|
148
|
+
throw new Error('Doc with docId ' + docId + ' already exists')
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const nowDateString = generateDate()
|
|
152
|
+
|
|
153
|
+
/** @type {Parameters<typeof DataStore.prototype.write>[0]} */
|
|
154
|
+
const doc = {
|
|
155
|
+
...value,
|
|
156
|
+
docId,
|
|
157
|
+
createdAt: nowDateString,
|
|
158
|
+
updatedAt: nowDateString,
|
|
159
|
+
deleted: false,
|
|
160
|
+
links: [],
|
|
161
|
+
}
|
|
162
|
+
await this.#dataStore.write(doc)
|
|
163
|
+
return this.getByDocId(doc.docId)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* @param {string} docId
|
|
168
|
+
* @param {{ lang?: string }} [opts]
|
|
169
|
+
*/
|
|
170
|
+
async getByDocId(docId, { lang } = {}) {
|
|
171
|
+
await this.#dataStore.indexer.idle()
|
|
172
|
+
const result = /** @type {undefined | MapeoDoc} */ (
|
|
173
|
+
this.#sql.getByDocId.get({ docId })
|
|
174
|
+
)
|
|
175
|
+
if (!result) throw new NotFoundError()
|
|
176
|
+
return this.#translate(deNullify(result), { lang })
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* @param {string} versionId
|
|
181
|
+
* @param {{ lang?: string }} [opts]
|
|
182
|
+
*/
|
|
183
|
+
async getByVersionId(versionId, { lang } = {}) {
|
|
184
|
+
const result = await this.#dataStore.read(versionId)
|
|
185
|
+
return this.#translate(result, { lang })
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* @param {MapeoDoc} doc
|
|
190
|
+
* @param {{ lang?: string }} [opts]
|
|
191
|
+
*/
|
|
192
|
+
async #translate(doc, { lang } = {}) {
|
|
193
|
+
if (!lang) return doc
|
|
194
|
+
|
|
195
|
+
const { language, region } = parseBCP47(lang)
|
|
196
|
+
if (!language) return doc
|
|
197
|
+
const translatedDoc = JSON.parse(JSON.stringify(doc))
|
|
198
|
+
|
|
199
|
+
const value = {
|
|
200
|
+
languageCode: language,
|
|
201
|
+
docRef: {
|
|
202
|
+
docId: translatedDoc.docId,
|
|
203
|
+
versionId: translatedDoc.versionId,
|
|
204
|
+
},
|
|
205
|
+
docRefType: translatedDoc.schemaName,
|
|
206
|
+
regionCode: region !== null ? region : undefined,
|
|
207
|
+
}
|
|
208
|
+
let translations = await this.#getTranslations(value)
|
|
209
|
+
// if passing a region code returns no matches,
|
|
210
|
+
// fallback to matching only languageCode
|
|
211
|
+
if (translations.length === 0 && value.regionCode) {
|
|
212
|
+
value.regionCode = undefined
|
|
213
|
+
translations = await this.#getTranslations(value)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
for (const translation of translations) {
|
|
217
|
+
if (typeof getProperty(doc, translation.propertyRef) === 'string') {
|
|
218
|
+
setProperty(doc, translation.propertyRef, translation.message)
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return doc
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** @param {{ includeDeleted?: boolean, lang?: string }} [opts] */
|
|
225
|
+
async getMany({ includeDeleted = false, lang } = {}) {
|
|
226
|
+
await this.#dataStore.indexer.idle()
|
|
227
|
+
const rows = includeDeleted
|
|
228
|
+
? this.#sql.getManyWithDeleted.all()
|
|
229
|
+
: this.#sql.getMany.all()
|
|
230
|
+
return await Promise.all(
|
|
231
|
+
rows.map(
|
|
232
|
+
async (doc) =>
|
|
233
|
+
await this.#translate(deNullify(/** @type {MapeoDoc} */ (doc)), {
|
|
234
|
+
lang,
|
|
235
|
+
})
|
|
236
|
+
)
|
|
237
|
+
)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
*
|
|
242
|
+
* @template {import('type-fest').Exact<TValue, T>} T
|
|
243
|
+
* @param {string | string[]} versionId
|
|
244
|
+
* @param {T} value
|
|
245
|
+
*/
|
|
246
|
+
async update(versionId, value) {
|
|
247
|
+
await this.#dataStore.indexer.idle()
|
|
248
|
+
const links = Array.isArray(versionId) ? versionId : [versionId]
|
|
249
|
+
const { docId, createdAt, originalVersionId } = await this.#validateLinks(
|
|
250
|
+
links
|
|
251
|
+
)
|
|
252
|
+
/** @type {any} */
|
|
253
|
+
const doc = {
|
|
254
|
+
// @ts-expect-error Can't figure out why TypeScript doesn't think `value` is spreadable.
|
|
255
|
+
...value,
|
|
256
|
+
docId,
|
|
257
|
+
createdAt,
|
|
258
|
+
updatedAt: new Date().toISOString(),
|
|
259
|
+
originalVersionId,
|
|
260
|
+
links,
|
|
261
|
+
}
|
|
262
|
+
await this.#dataStore.write(doc)
|
|
263
|
+
return this.getByDocId(docId)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* @param {string} docId
|
|
268
|
+
*/
|
|
269
|
+
async delete(docId) {
|
|
270
|
+
await this.#dataStore.indexer.idle()
|
|
271
|
+
const existingDoc = await this.getByDocId(docId)
|
|
272
|
+
|
|
273
|
+
if ('deleted' in existingDoc && existingDoc.deleted) {
|
|
274
|
+
throw new Error('Doc already deleted')
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** @type {any} */
|
|
278
|
+
const doc = {
|
|
279
|
+
...existingDoc,
|
|
280
|
+
updatedAt: new Date().toISOString(),
|
|
281
|
+
// @ts-expect-error - TS just doesn't work in this class
|
|
282
|
+
links: [existingDoc.versionId, ...existingDoc.forks],
|
|
283
|
+
deleted: true,
|
|
284
|
+
}
|
|
285
|
+
await this.#dataStore.write(doc)
|
|
286
|
+
return this.getByDocId(docId)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async [kSelect]() {
|
|
290
|
+
await this.#dataStore.indexer.idle()
|
|
291
|
+
const result = this.#db.select().from(this.#table)
|
|
292
|
+
// [The result of `from()` is awaitable.][0] We don't want this because we
|
|
293
|
+
// want to be able to await the result of this method and then call
|
|
294
|
+
// `.where()` on the result.
|
|
295
|
+
//
|
|
296
|
+
// As a workaround, we remove promise methods from the result.
|
|
297
|
+
//
|
|
298
|
+
// [0]: https://github.com/drizzle-team/drizzle-orm/commit/c063144dc08726cc15323582fe377210329e579e
|
|
299
|
+
return removePromiseMethods(result)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Validate that existing docs with the given versionIds (links):
|
|
304
|
+
* - exist
|
|
305
|
+
* - have the same schemaName as this dataType
|
|
306
|
+
* - all share the same docId
|
|
307
|
+
* - all share the same originalVersionId
|
|
308
|
+
* Throws if any of these conditions fail, otherwise returns the validated
|
|
309
|
+
* docId and createAt datetime
|
|
310
|
+
* @param {string[]} links
|
|
311
|
+
* @returns {Promise<{ docId: MapeoDoc['docId'], createdAt: MapeoDoc['createdAt'], originalVersionId: MapeoDoc['originalVersionId'] }>}
|
|
312
|
+
*/
|
|
313
|
+
async #validateLinks(links) {
|
|
314
|
+
const prevDocs = await Promise.all(
|
|
315
|
+
links.map((versionId) => this.getByVersionId(versionId))
|
|
316
|
+
)
|
|
317
|
+
const { docId, createdAt, originalVersionId } = prevDocs[0]
|
|
318
|
+
const areLinksValid = prevDocs.every(
|
|
319
|
+
(doc) =>
|
|
320
|
+
doc.docId === docId &&
|
|
321
|
+
doc.schemaName === this.#schemaName &&
|
|
322
|
+
doc.originalVersionId === originalVersionId
|
|
323
|
+
)
|
|
324
|
+
if (!areLinksValid) {
|
|
325
|
+
throw new Error('Updated docs must have the same docId and schemaName')
|
|
326
|
+
}
|
|
327
|
+
return { docId, createdAt, originalVersionId }
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* @param {Set<string>} docIds
|
|
332
|
+
*/
|
|
333
|
+
#handleDataStoreUpdate = (docIds) => {
|
|
334
|
+
if (this.listenerCount('updated-docs') === 0) return
|
|
335
|
+
const updatedDocs = /** @type {TDoc[]} */ (
|
|
336
|
+
this.#db
|
|
337
|
+
.select()
|
|
338
|
+
.from(this.#table)
|
|
339
|
+
.where(inArray(this.#table.docId, [...docIds]))
|
|
340
|
+
.all()
|
|
341
|
+
.map((doc) => deNullify(doc))
|
|
342
|
+
)
|
|
343
|
+
this.emit('updated-docs', updatedDocs)
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* @template {object} T
|
|
349
|
+
* @param {T} value
|
|
350
|
+
* @returns {Omit<T, 'then' | 'catch' | 'finally'> & { then?: undefined, catch?: undefined, finally?: undefined }}
|
|
351
|
+
*/
|
|
352
|
+
function removePromiseMethods(value) {
|
|
353
|
+
return Object.create(value, {
|
|
354
|
+
then: { value: undefined },
|
|
355
|
+
catch: { value: undefined },
|
|
356
|
+
finally: { value: undefined },
|
|
357
|
+
})
|
|
358
|
+
}
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { TypedEmitter } from 'tiny-typed-emitter'
|
|
2
|
+
import net from 'node:net'
|
|
3
|
+
import { randomBytes } from 'node:crypto'
|
|
4
|
+
import NoiseSecretStream from '@hyperswarm/secret-stream'
|
|
5
|
+
import { once } from 'node:events'
|
|
6
|
+
import { noop } from '../utils.js'
|
|
7
|
+
import { isPrivate } from 'bogon'
|
|
8
|
+
import StartStopStateMachine from 'start-stop-state-machine'
|
|
9
|
+
import pTimeout from 'p-timeout'
|
|
10
|
+
import { keyToPublicId } from '@mapeo/crypto'
|
|
11
|
+
import { Logger } from '../logger.js'
|
|
12
|
+
/** @import { OpenedNoiseStream } from '../lib/noise-secret-stream-helpers.js' */
|
|
13
|
+
|
|
14
|
+
/** @typedef {{ publicKey: Buffer, secretKey: Buffer }} Keypair */
|
|
15
|
+
/** @typedef {OpenedNoiseStream<net.Socket>} OpenedNetNoiseStream */
|
|
16
|
+
|
|
17
|
+
const TCP_KEEP_ALIVE_OPTIONS = {
|
|
18
|
+
keepAlive: true,
|
|
19
|
+
keepAliveInitialDelay: 10_000,
|
|
20
|
+
}
|
|
21
|
+
export const ERR_DUPLICATE = 'Duplicate connection'
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @typedef {Object} DiscoveryEvents
|
|
25
|
+
* @property {(connection: OpenedNetNoiseStream) => void} connection
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @extends {TypedEmitter<DiscoveryEvents>}
|
|
30
|
+
*/
|
|
31
|
+
export class LocalDiscovery extends TypedEmitter {
|
|
32
|
+
#identityKeypair
|
|
33
|
+
#name = randomBytes(8).toString('hex')
|
|
34
|
+
#server
|
|
35
|
+
/** @type {Map<string, OpenedNetNoiseStream>} */
|
|
36
|
+
#noiseConnections = new Map()
|
|
37
|
+
#sm
|
|
38
|
+
#log
|
|
39
|
+
/** @type {(e: Error) => void} */
|
|
40
|
+
#handleSocketError
|
|
41
|
+
#l
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param {Object} opts
|
|
45
|
+
* @param {Keypair} opts.identityKeypair
|
|
46
|
+
* @param {Logger} [opts.logger]
|
|
47
|
+
*/
|
|
48
|
+
constructor({ identityKeypair, logger }) {
|
|
49
|
+
super()
|
|
50
|
+
this.#l = Logger.create('LocalDiscovery', logger)
|
|
51
|
+
this.#log = this.#l.log.bind(this.#l)
|
|
52
|
+
this.#sm = new StartStopStateMachine({
|
|
53
|
+
start: this.#start.bind(this),
|
|
54
|
+
stop: this.#stop.bind(this),
|
|
55
|
+
})
|
|
56
|
+
this.#handleSocketError = (e) => {
|
|
57
|
+
this.#log('socket error', e.message)
|
|
58
|
+
}
|
|
59
|
+
this.#identityKeypair = identityKeypair
|
|
60
|
+
this.#server = net.createServer(
|
|
61
|
+
TCP_KEEP_ALIVE_OPTIONS,
|
|
62
|
+
this.#handleTcpConnection.bind(this, false)
|
|
63
|
+
)
|
|
64
|
+
this.#server.on('error', (e) => {
|
|
65
|
+
this.#log('Server error', e)
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** @returns {Promise<{ name: string, port: number }>} */
|
|
70
|
+
async start() {
|
|
71
|
+
await this.#sm.start()
|
|
72
|
+
return { name: this.#name, port: getAddress(this.#server).port }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** @returns {Promise<void>} */
|
|
76
|
+
async #start() {
|
|
77
|
+
// Let OS choose port, listen on ip4, all interfaces
|
|
78
|
+
this.#server.listen(0, '0.0.0.0')
|
|
79
|
+
await once(this.#server, 'listening')
|
|
80
|
+
const addr = getAddress(this.#server)
|
|
81
|
+
this.#log('server listening on port ' + addr.port)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* @param {object} peer
|
|
86
|
+
* @param {string} peer.address
|
|
87
|
+
* @param {number} peer.port
|
|
88
|
+
* @param {string} peer.name
|
|
89
|
+
* @returns {void}
|
|
90
|
+
*/
|
|
91
|
+
connectPeer({ address, port, name }) {
|
|
92
|
+
if (this.#name === name) return
|
|
93
|
+
this.#log('peer connected', name.slice(0, 7), address, port)
|
|
94
|
+
if (this.#noiseConnections.has(name)) {
|
|
95
|
+
this.#log(`Already connected to ${name.slice(0, 7)}`)
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
const socket = net.connect({
|
|
99
|
+
host: address,
|
|
100
|
+
port,
|
|
101
|
+
...TCP_KEEP_ALIVE_OPTIONS,
|
|
102
|
+
})
|
|
103
|
+
socket.on('error', this.#handleSocketError)
|
|
104
|
+
socket.once('connect', () => {
|
|
105
|
+
this.#handleTcpConnection(true, socket)
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* @param {boolean} isInitiator
|
|
111
|
+
* @param {net.Socket} socket
|
|
112
|
+
* @returns {void}
|
|
113
|
+
*/
|
|
114
|
+
#handleTcpConnection(isInitiator, socket) {
|
|
115
|
+
socket.off('error', this.#handleSocketError)
|
|
116
|
+
socket.on('error', onSocketError)
|
|
117
|
+
|
|
118
|
+
/** @param {Error} e */
|
|
119
|
+
function onSocketError(e) {
|
|
120
|
+
if ('code' in e && e.code === 'EPIPE') {
|
|
121
|
+
socket.destroy()
|
|
122
|
+
if (secretStream) {
|
|
123
|
+
secretStream.destroy()
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const { remoteAddress } = socket
|
|
129
|
+
if (!remoteAddress || !isPrivate(remoteAddress)) {
|
|
130
|
+
socket.destroy(new Error('Invalid remoteAddress ' + remoteAddress))
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
this.#log(
|
|
134
|
+
`${isInitiator ? 'outgoing' : 'incoming'} tcp connection ${
|
|
135
|
+
isInitiator ? 'to' : 'from'
|
|
136
|
+
} ${remoteAddress}`
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
const secretStream = new NoiseSecretStream(isInitiator, socket, {
|
|
140
|
+
keyPair: this.#identityKeypair,
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
secretStream.on('error', this.#handleSocketError)
|
|
144
|
+
|
|
145
|
+
secretStream.on('connect', () => {
|
|
146
|
+
// Further errors will be handled in #handleNoiseStreamConnection()
|
|
147
|
+
socket.off('error', onSocketError)
|
|
148
|
+
secretStream.off('error', this.#handleSocketError)
|
|
149
|
+
this.#handleNoiseStreamConnection(
|
|
150
|
+
// We know the NoiseStream is open at this point, so we can coerce the type
|
|
151
|
+
/** @type {OpenedNetNoiseStream} */
|
|
152
|
+
(secretStream)
|
|
153
|
+
)
|
|
154
|
+
})
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* @param {OpenedNetNoiseStream} existing
|
|
159
|
+
* @param {OpenedNetNoiseStream} keeping
|
|
160
|
+
* @returns {void}
|
|
161
|
+
*/
|
|
162
|
+
#handleConnectionSwap(existing, keeping) {
|
|
163
|
+
let closed = false
|
|
164
|
+
|
|
165
|
+
existing.on('close', () => {
|
|
166
|
+
// The connection we are keeping could have closed before we get here
|
|
167
|
+
if (closed) return
|
|
168
|
+
|
|
169
|
+
keeping.removeListener('error', noop)
|
|
170
|
+
keeping.removeListener('close', onclose)
|
|
171
|
+
|
|
172
|
+
this.#handleNoiseStreamConnection(keeping)
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
keeping.on('error', noop)
|
|
176
|
+
keeping.on('close', onclose)
|
|
177
|
+
|
|
178
|
+
function onclose() {
|
|
179
|
+
closed = true
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* @param {OpenedNetNoiseStream} conn
|
|
185
|
+
* @returns {void}
|
|
186
|
+
*/
|
|
187
|
+
#handleNoiseStreamConnection(conn) {
|
|
188
|
+
const { remotePublicKey, isInitiator } = conn
|
|
189
|
+
if (!remotePublicKey) {
|
|
190
|
+
// Shouldn't get here
|
|
191
|
+
this.#log('Error: incoming connection with no publicKey')
|
|
192
|
+
conn.destroy()
|
|
193
|
+
return
|
|
194
|
+
}
|
|
195
|
+
const remoteId = keyToPublicId(remotePublicKey)
|
|
196
|
+
|
|
197
|
+
this.#log(
|
|
198
|
+
`${isInitiator ? 'outgoing' : 'incoming'} secret stream connection ${
|
|
199
|
+
isInitiator ? 'to' : 'from'
|
|
200
|
+
} %h`,
|
|
201
|
+
remotePublicKey
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
const existing = this.#noiseConnections.get(remoteId)
|
|
205
|
+
|
|
206
|
+
if (existing) {
|
|
207
|
+
const keyCompare = Buffer.compare(
|
|
208
|
+
this.#identityKeypair.publicKey,
|
|
209
|
+
remotePublicKey
|
|
210
|
+
)
|
|
211
|
+
const keepExisting =
|
|
212
|
+
// These first two checks check if a peer tried to connect twice. In
|
|
213
|
+
// this case we keep the existing connection.
|
|
214
|
+
(isInitiator && existing.isInitiator) ||
|
|
215
|
+
(!isInitiator && !existing.isInitiator) ||
|
|
216
|
+
// If each peer tried to connect to the other at the same time, then we
|
|
217
|
+
// tie-break based on public key comparison (the initiator need to check
|
|
218
|
+
// the opposite of the non-initiator, because the keys are the other way
|
|
219
|
+
// around for them)
|
|
220
|
+
(isInitiator ? keyCompare > 0 : keyCompare <= 0)
|
|
221
|
+
if (keepExisting) {
|
|
222
|
+
this.#log(`keeping existing, destroying new`)
|
|
223
|
+
conn.on('error', noop)
|
|
224
|
+
conn.destroy(new Error(ERR_DUPLICATE))
|
|
225
|
+
return
|
|
226
|
+
} else {
|
|
227
|
+
this.#log(`destroying existing, keeping new`)
|
|
228
|
+
existing.on('error', noop)
|
|
229
|
+
existing.destroy(new Error(ERR_DUPLICATE))
|
|
230
|
+
this.#handleConnectionSwap(existing, conn)
|
|
231
|
+
return
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Bail if the server has already stopped by this point. This should be
|
|
236
|
+
// rare but can happen during connection swaps if the new connection is
|
|
237
|
+
// "promoted" after the server's doors have already been closed.
|
|
238
|
+
if (!this.#server.listening) {
|
|
239
|
+
this.#log('server stopped, destroying connection %h', remotePublicKey)
|
|
240
|
+
conn.destroy()
|
|
241
|
+
return
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
this.#noiseConnections.set(remoteId, conn)
|
|
245
|
+
|
|
246
|
+
conn.on('close', () => {
|
|
247
|
+
this.#log('closed connection with %h', remotePublicKey)
|
|
248
|
+
this.#noiseConnections.delete(remoteId)
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
// No 'error' listeners attached to `conn` at this point, it's up to the
|
|
252
|
+
// consumer to attach an 'error' listener to avoid uncaught errors.
|
|
253
|
+
this.emit('connection', conn)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Close all servers and stop multicast advertising and browsing. Will wait
|
|
258
|
+
* for open sockets to close unless opts.force=true in which case open sockets
|
|
259
|
+
* are force-closed after opts.timeout milliseconds
|
|
260
|
+
*
|
|
261
|
+
* @param {object} [opts]
|
|
262
|
+
* @param {boolean} [opts.force=false] Force-close open sockets after timeout milliseconds
|
|
263
|
+
* @param {number} [opts.timeout=0] Optional timeout when calling stop() with force=true
|
|
264
|
+
* @returns {Promise<void>}
|
|
265
|
+
*/
|
|
266
|
+
async stop(opts) {
|
|
267
|
+
return this.#sm.stop(opts)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* @type {LocalDiscovery['stop']}
|
|
272
|
+
*/
|
|
273
|
+
async #stop({ force = false, timeout = 0 } = {}) {
|
|
274
|
+
this.#log('stopping')
|
|
275
|
+
const { port } = getAddress(this.#server)
|
|
276
|
+
this.#server.close()
|
|
277
|
+
const closePromise = once(this.#server, 'close')
|
|
278
|
+
await pTimeout(closePromise, {
|
|
279
|
+
milliseconds: force ? (timeout === 0 ? 1 : timeout) : Infinity,
|
|
280
|
+
fallback: () => {
|
|
281
|
+
for (const socket of this.#noiseConnections.values()) {
|
|
282
|
+
socket.destroy()
|
|
283
|
+
}
|
|
284
|
+
return pTimeout(closePromise, { milliseconds: 500 })
|
|
285
|
+
},
|
|
286
|
+
})
|
|
287
|
+
this.#log(`stopped for ${port}`)
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Get the address of a server, will throw if the server is not yet listening or
|
|
293
|
+
* if it is listening on a socket
|
|
294
|
+
* @param {import('node:net').Server} server
|
|
295
|
+
* @returns {import('node:net').AddressInfo}
|
|
296
|
+
*/
|
|
297
|
+
function getAddress(server) {
|
|
298
|
+
const addr = server.address()
|
|
299
|
+
if (addr === null || typeof addr === 'string') {
|
|
300
|
+
throw new Error('Server is not listening on a port')
|
|
301
|
+
}
|
|
302
|
+
return addr
|
|
303
|
+
}
|