@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
@@ -2,7 +2,7 @@ import path from 'path'
2
2
  import Database from 'better-sqlite3'
3
3
  import { decodeBlockPrefix, decode, parseVersionId } from '@comapeo/schema'
4
4
  import { drizzle } from 'drizzle-orm/better-sqlite3'
5
- import { sql, count } from 'drizzle-orm'
5
+ import { sql, count, eq } from 'drizzle-orm'
6
6
  import { discoveryKey } from 'hypercore-crypto'
7
7
  import { TypedEmitter } from 'tiny-typed-emitter'
8
8
  import ZipArchive from 'zip-stream-promise'
@@ -11,7 +11,7 @@ import mime from 'mime/lite'
11
11
  // @ts-expect-error
12
12
  import { Readable, pipelinePromise } from 'streamx'
13
13
 
14
- import { NAMESPACES, NAMESPACE_SCHEMAS, UNIX_EPOCH_DATE } from './constants.js'
14
+ import { NAMESPACES, NAMESPACE_SCHEMAS } from './constants.js'
15
15
  import { CoreManager } from './core-manager/index.js'
16
16
  import { DataStore } from './datastore/index.js'
17
17
  import { DataType, kCreateWithDocId } from './datatype/index.js'
@@ -61,11 +61,12 @@ import {
61
61
  } from './sync/sync-api.js'
62
62
  import { Logger } from './logger.js'
63
63
  import { IconApi } from './icon-api.js'
64
- import { readConfig } from './config-import.js'
64
+ import { importCategories } from './import-categories.js'
65
65
  import TranslationApi from './translation-api.js'
66
66
  import { NotFoundError, nullIfNotFound } from './errors.js'
67
67
  import { WebSocket } from 'ws'
68
68
  import { createWriteStream } from 'fs'
69
+ import ensureError from 'ensure-error'
69
70
  /** @import { ProjectSettingsValue, Observation, Track } from '@comapeo/schema' */
70
71
  /** @import { Attachment, CoreStorage, BlobFilter, BlobId, BlobStoreEntriesStream, KeyPair, Namespace, ReplicationStream, GenericBlobFilter, MapeoValueMap, MapeoDocMap } from './types.js' */
71
72
  /** @typedef {Omit<ProjectSettingsValue, 'schemaName'>} EditableProjectSettings */
@@ -97,12 +98,12 @@ export const kBlobStore = Symbol('blobStore')
97
98
  export const kProjectReplicate = Symbol('replicate project')
98
99
  export const kDataTypes = Symbol('dataTypes')
99
100
  export const kProjectLeave = Symbol('leave project')
100
- export const kClearDataIfLeft = Symbol('clear data if left project')
101
+ export const kClearData = Symbol('clear project data')
101
102
  export const kSetIsArchiveDevice = Symbol('set isArchiveDevice')
102
103
  export const kIsArchiveDevice = Symbol('isArchiveDevice (temp - test only)')
103
104
  export const kGeoJSONFileName = Symbol('geoJSONFileName')
104
105
 
105
- const EMPTY_PROJECT_SETTINGS = Object.freeze({})
106
+ const EMPTY_PROJECT_SETTINGS = Object.freeze({ sendStats: false })
106
107
 
107
108
  /** @type BlobId['variant'][]*/
108
109
  const VARIANT_EXPORT_ORDER = ['original', 'preview', 'thumbnail']
@@ -130,9 +131,10 @@ export class MapeoProject extends TypedEmitter {
130
131
  #translationApi
131
132
  #l
132
133
  /** @type {Boolean} this avoids loading multiple configs in parallel */
133
- #loadingConfig
134
+ #importingCategories
134
135
 
135
136
  static EMPTY_PROJECT_SETTINGS = EMPTY_PROJECT_SETTINGS
137
+ #getFallbackProjectInfo
136
138
 
137
139
  /**
138
140
  * @param {Object} opts
@@ -149,6 +151,7 @@ export class MapeoProject extends TypedEmitter {
149
151
  * @param {(url: string) => WebSocket} [opts.makeWebsocket]
150
152
  * @param {import('./local-peers.js').LocalPeers} opts.localPeers
151
153
  * @param {boolean} opts.isArchiveDevice Whether this device is an archive device
154
+ * @param {() => import('./schema/client.js').ProjectInfo | undefined} opts.getFallbackProjectInfo
152
155
  * @param {Logger} [opts.logger]
153
156
  *
154
157
  */
@@ -167,13 +170,15 @@ export class MapeoProject extends TypedEmitter {
167
170
  localPeers,
168
171
  logger,
169
172
  isArchiveDevice,
173
+ getFallbackProjectInfo,
170
174
  }) {
171
175
  super()
172
176
 
173
177
  this.#l = Logger.create('project', logger)
174
178
  this.#deviceId = getDeviceId(keyManager)
175
179
  this.#projectKey = projectKey
176
- this.#loadingConfig = false
180
+ this.#importingCategories = false
181
+ this.#getFallbackProjectInfo = getFallbackProjectInfo
177
182
 
178
183
  const getReplicationStream = this[kProjectReplicate].bind(this, true)
179
184
 
@@ -216,6 +221,11 @@ export class MapeoProject extends TypedEmitter {
216
221
 
217
222
  if (reindex) {
218
223
  for (const table of indexedTables) db.delete(table).run()
224
+
225
+ sharedDb
226
+ .delete(projectSettingsTable)
227
+ .where(eq(projectSettingsTable.docId, this.#projectId))
228
+ .run()
219
229
  }
220
230
 
221
231
  ///////// 3. Setup random-access-storage functions
@@ -679,7 +689,12 @@ export class MapeoProject extends TypedEmitter {
679
689
  await this.#dataTypes.projectSettings.getByDocId(this.#projectId)
680
690
  )
681
691
  } catch (e) {
682
- return /** @type {EditableProjectSettings} */ (EMPTY_PROJECT_SETTINGS)
692
+ // if (e instanceof Error && e.name !== 'NotFoundError') throw e
693
+ // If the project has not completed an initial sync, project settings will
694
+ // not be available, so use fallback project info which is set from the
695
+ // invite that was used to join the project.
696
+ const fallbackInfo = this.#getFallbackProjectInfo()
697
+ return fallbackInfo || EMPTY_PROJECT_SETTINGS
683
698
  }
684
699
  }
685
700
 
@@ -688,11 +703,9 @@ export class MapeoProject extends TypedEmitter {
688
703
  */
689
704
  async $hasSyncedProjectSettings() {
690
705
  try {
691
- const settings = await this.#dataTypes.projectSettings.getByDocId(
692
- this.#projectId
693
- )
694
-
695
- return settings.createdAt !== UNIX_EPOCH_DATE
706
+ // Should error if we haven't synced before
707
+ await this.#dataTypes.projectSettings.getByDocId(this.#projectId)
708
+ return true
696
709
  } catch (e) {
697
710
  return false
698
711
  }
@@ -1302,19 +1315,14 @@ export class MapeoProject extends TypedEmitter {
1302
1315
 
1303
1316
  await this.#roles.assignRole(this.#deviceId, LEFT_ROLE_ID)
1304
1317
 
1305
- await this[kClearDataIfLeft]()
1318
+ await this[kClearData]()
1306
1319
  }
1307
1320
 
1308
1321
  /**
1309
- * Clear data if we've left the project. No-op if you're still in the project.
1322
+ * Clear synced data, but keep auth data and own data
1310
1323
  * @returns {Promise<void>}
1311
1324
  */
1312
- async [kClearDataIfLeft]() {
1313
- const role = await this.$getOwnRole()
1314
- if (role.roleId !== LEFT_ROLE_ID) {
1315
- return
1316
- }
1317
-
1325
+ async [kClearData]() {
1318
1326
  const namespacesWithoutAuth =
1319
1327
  /** @satisfies {Exclude<Namespace, 'auth'>[]} */ ([
1320
1328
  'config',
@@ -1345,172 +1353,44 @@ export class MapeoProject extends TypedEmitter {
1345
1353
  }
1346
1354
  }
1347
1355
 
1348
- /** @param {Object} opts
1349
- * @param {string} opts.configPath
1350
- * @returns {Promise<Error[]>}
1356
+ /**
1357
+ * @deprecated
1358
+ * @param {object} opts
1359
+ * @param {string} opts.configPath
1360
+ * @returns {Promise<Error[]>}
1351
1361
  */
1352
1362
  async importConfig({ configPath }) {
1363
+ try {
1364
+ await this.$importCategories({ filePath: configPath })
1365
+ return []
1366
+ } catch (e) {
1367
+ return [ensureError(e)]
1368
+ }
1369
+ }
1370
+
1371
+ /**
1372
+ * @param {object} opts
1373
+ * @param {string} opts.filePath
1374
+ * @returns {Promise<void>}
1375
+ */
1376
+ async $importCategories({ filePath }) {
1353
1377
  assert(
1354
- !this.#loadingConfig,
1355
- 'Cannot run multiple config imports at the same time'
1378
+ !this.#importingCategories,
1379
+ 'Cannot run multiple category imports at the same time'
1356
1380
  )
1357
- this.#loadingConfig = true
1381
+ this.#importingCategories = true
1358
1382
 
1359
1383
  try {
1360
- // check for already present fields and presets and delete them if exist
1361
- const presetsToDelete = await grabDocsToDelete(this.preset)
1362
- const fieldsToDelete = await grabDocsToDelete(this.field)
1363
- // delete only translations that refer to deleted fields and presets
1364
- const translationsToDelete = await grabTranslationsToDelete({
1365
- logger: this.#l,
1366
- translation: this.$translation.dataType,
1367
- preset: this.preset,
1368
- field: this.field,
1369
- })
1370
-
1371
- const config = await readConfig(configPath)
1372
- /** @type {Map<string, import('./icon-api.js').IconRef>} */
1373
- const iconNameToRef = new Map()
1374
- /** @type {Map<string, import('@comapeo/schema').PresetValue['fieldRefs'][1]>} */
1375
- const fieldNameToRef = new Map()
1376
- /** @type {Map<string,import('@comapeo/schema').TranslationValue['docRef']>} */
1377
- const presetNameToRef = new Map()
1378
-
1379
- // Do this in serial not parallel to avoid memory issues (avoid keeping all icon buffers in memory)
1380
- for await (const icon of config.icons()) {
1381
- const { docId, versionId } = await this.#iconApi.create(icon)
1382
- iconNameToRef.set(icon.name, { docId, versionId })
1383
- }
1384
-
1385
- // Ok to create fields and presets in parallel
1386
- const fieldPromises = []
1387
- for (const { name, value } of config.fields()) {
1388
- fieldPromises.push(
1389
- this.#dataTypes.field.create(value).then(({ docId, versionId }) => {
1390
- fieldNameToRef.set(name, { docId, versionId })
1391
- })
1392
- )
1393
- }
1394
- await Promise.all(fieldPromises)
1395
-
1396
- const presetsWithRefs = []
1397
- for (const { fieldNames, iconName, value, name } of config.presets()) {
1398
- const fieldRefs = fieldNames.map((fieldName) => {
1399
- const fieldRef = fieldNameToRef.get(fieldName)
1400
- if (!fieldRef) {
1401
- throw new NotFoundError(
1402
- `field ${fieldName} not found (referenced by preset ${value.name})})`
1403
- )
1404
- }
1405
- return fieldRef
1406
- })
1407
-
1408
- if (!iconName) {
1409
- throw new Error(`preset ${value.name} is missing an icon name`)
1410
- }
1411
- const iconRef = iconNameToRef.get(iconName)
1412
- if (!iconRef) {
1413
- throw new NotFoundError(
1414
- `icon ${iconName} not found (referenced by preset ${value.name})`
1415
- )
1416
- }
1417
-
1418
- presetsWithRefs.push({
1419
- preset: {
1420
- ...value,
1421
- iconRef,
1422
- fieldRefs,
1423
- },
1424
- name,
1425
- })
1426
- }
1427
-
1428
- const presetPromises = []
1429
- for (const { preset, name } of presetsWithRefs) {
1430
- presetPromises.push(
1431
- this.preset.create(preset).then(({ docId, versionId }) => {
1432
- presetNameToRef.set(name, { docId, versionId })
1433
- })
1434
- )
1435
- }
1436
-
1437
- await Promise.all(presetPromises)
1438
-
1439
- const translationPromises = []
1440
- for (const { name, value } of config.translations()) {
1441
- let docRef
1442
- if (value.docRefType === 'field') {
1443
- docRef = { ...fieldNameToRef.get(name) }
1444
- } else if (value.docRefType === 'preset') {
1445
- docRef = { ...presetNameToRef.get(name) }
1446
- } else {
1447
- throw new Error(`invalid docRefType ${value.docRefType}`)
1448
- }
1449
- if (docRef.docId && docRef.versionId) {
1450
- translationPromises.push(
1451
- this.$translation.put({
1452
- ...value,
1453
- docRef: {
1454
- docId: docRef.docId,
1455
- versionId: docRef.versionId,
1456
- },
1457
- })
1458
- )
1459
- } else {
1460
- throw new NotFoundError(
1461
- `docRef for ${value.docRefType} with name ${name} not found`
1462
- )
1463
- }
1464
- }
1465
- await Promise.all(translationPromises)
1466
-
1467
- // close the zip handles after we know we won't be needing them anymore
1468
- await config.close()
1469
- const presetIds = [...presetNameToRef.values()].map((val) => val.docId)
1470
-
1471
- await this.$setProjectSettings({
1472
- defaultPresets: {
1473
- point: presetIds,
1474
- line: [],
1475
- area: [],
1476
- vertex: [],
1477
- relation: [],
1478
- },
1479
- configMetadata: config.metadata,
1480
- })
1481
-
1482
- const deletePresetsPromise = Promise.all(
1483
- presetsToDelete.map(async (docId) => {
1484
- const { deleted } = await this.preset.getByDocId(docId)
1485
- if (!deleted) await this.preset.delete(docId)
1486
- })
1487
- )
1488
- const deleteFieldsPromise = Promise.all(
1489
- fieldsToDelete.map(async (docId) => {
1490
- const { deleted } = await this.field.getByDocId(docId)
1491
- if (!deleted) await this.field.delete(docId)
1492
- })
1493
- )
1494
- const deleteTranslationsPromise = Promise.all(
1495
- [...translationsToDelete].map(async (docId) => {
1496
- const { deleted } = await this.$translation.dataType.getByDocId(docId)
1497
- if (!deleted) await this.$translation.dataType.delete(docId)
1498
- })
1499
- )
1500
- await Promise.all([
1501
- deletePresetsPromise,
1502
- deleteFieldsPromise,
1503
- deleteTranslationsPromise,
1504
- ])
1505
- this.#loadingConfig = false
1506
- return config.warnings
1384
+ await importCategories(this, { filePath, logger: this.#l })
1507
1385
  } catch (e) {
1508
1386
  this.#l.log('error loading config', e)
1509
- this.#loadingConfig = false
1510
- return /** @type Error[] */ []
1387
+ throw e
1388
+ } finally {
1389
+ this.#importingCategories = false
1511
1390
  }
1512
1391
  }
1513
1392
  }
1393
+
1514
1394
  /**
1515
1395
  * @param {import("@comapeo/schema").ProjectSettings & { forks: string[] }} projectDoc
1516
1396
  * @returns {EditableProjectSettings}
@@ -1519,48 +1399,6 @@ function extractEditableProjectSettings(projectDoc) {
1519
1399
  return omit(valueOf(projectDoc), ['schemaName'])
1520
1400
  }
1521
1401
 
1522
- /**
1523
- @param {MapeoProject['field'] | MapeoProject['preset']} dataType
1524
- @returns {Promise<String[]>}
1525
- */
1526
- async function grabDocsToDelete(dataType) {
1527
- const toDelete = []
1528
- for (const { docId } of await dataType.getMany()) {
1529
- toDelete.push(docId)
1530
- }
1531
- return toDelete
1532
- }
1533
-
1534
- /**
1535
- * @param {Object} opts
1536
- * @param {Logger} opts.logger
1537
- * @param {MapeoProject['$translation']['dataType']} opts.translation
1538
- * @param {MapeoProject['preset']} opts.preset
1539
- * @param {MapeoProject['field']} opts.field
1540
- * @returns {Promise<Set<String>>}
1541
- */
1542
- async function grabTranslationsToDelete(opts) {
1543
- /** @type {Set<String>} */
1544
- const toDelete = new Set()
1545
- const translations = await opts.translation.getMany()
1546
- await Promise.all(
1547
- translations.map(async ({ docRefType, docRef, docId }) => {
1548
- if (docRefType === 'field' || docRefType === 'preset') {
1549
- let doc
1550
- try {
1551
- doc = await opts[docRefType].getByVersionId(docRef.versionId)
1552
- } catch (e) {
1553
- opts.logger.log(`referred ${docRef.versionId} is not found`)
1554
- }
1555
- if (doc) {
1556
- toDelete.add(docId)
1557
- }
1558
- }
1559
- })
1560
- )
1561
- return toDelete
1562
- }
1563
-
1564
1402
  /**
1565
1403
  * Return a map of namespace -> core keypair
1566
1404
  *
package/src/roles.js CHANGED
@@ -148,7 +148,7 @@ export const NO_ROLE = {
148
148
  roleAssignment: [],
149
149
  sync: {
150
150
  auth: 'allowed',
151
- config: 'allowed',
151
+ config: 'blocked',
152
152
  data: 'blocked',
153
153
  blobIndex: 'blocked',
154
154
  blob: 'blocked',
@@ -3,55 +3,49 @@
3
3
  // device
4
4
  import { blob, sqliteTable, text, int } from 'drizzle-orm/sqlite-core'
5
5
  import { dereferencedDocSchemas as schemas } from '@comapeo/schema'
6
- import { jsonSchemaToDrizzleColumns as toColumns } from './schema-to-drizzle.js'
7
- import { backlinkTable, customJson } from './utils.js'
6
+ import {
7
+ comapeoSchemaToDrizzleTable as toDrizzle,
8
+ backlinkTable,
9
+ } from './comapeo-to-drizzle.js'
8
10
 
9
11
  /**
10
12
  * @import { ProjectSettings } from '@comapeo/schema'
13
+ * @import { $Type } from 'drizzle-orm'
14
+ * @import { SQLiteTextJsonBuilder } from 'drizzle-orm/sqlite-core'
11
15
  *
12
16
  * @internal
13
- * @typedef {Pick<ProjectSettings, 'name' | 'projectColor' | 'projectDescription'>} ProjectInfo
17
+ * @typedef {Pick<ProjectSettings, 'name' | 'projectColor' | 'projectDescription' | 'sendStats'>} ProjectInfo
14
18
  */
15
19
 
16
- const projectInfoColumn =
17
- /** @type {ReturnType<typeof import('drizzle-orm/sqlite-core').customType<{data: ProjectInfo}>>} */ (
18
- customJson
19
- )
20
-
21
20
  /** @type {ProjectInfo} */
22
- const PROJECT_INFO_DEFAULT_VALUE = {}
21
+ const PROJECT_INFO_DEFAULT_VALUE = { sendStats: false }
23
22
 
24
- export const projectSettingsTable = sqliteTable(
25
- 'projectSettings',
26
- toColumns(schemas.projectSettings)
27
- )
28
- export const projectBacklinkTable = backlinkTable(projectSettingsTable)
23
+ export const projectSettingsTable = toDrizzle(schemas.projectSettings)
24
+ export const projectBacklinkTable = backlinkTable('projectSettings')
29
25
  export const projectKeysTable = sqliteTable('projectKeys', {
30
26
  projectId: text('projectId').notNull().primaryKey(),
31
27
  projectPublicId: text('projectPublicId').notNull(),
32
- projectInviteId: blob('projectInviteId').notNull(),
28
+ projectInviteId: blob('projectInviteId', { mode: 'buffer' }).notNull(),
33
29
  keysCipher: blob('keysCipher', { mode: 'buffer' }).notNull(),
34
- projectInfo: projectInfoColumn('projectInfo')
35
- .default(
36
- // TODO: There's a bug in Drizzle where the default value does not get transformed by the custom type
37
- // @ts-expect-error
38
- JSON.stringify(PROJECT_INFO_DEFAULT_VALUE)
39
- )
40
- .notNull(),
30
+ projectInfo:
31
+ /** @type {$Type<SQLiteTextJsonBuilder, ProjectInfo>} */
32
+ (text('projectInfo', { mode: 'json' }))
33
+ .default(PROJECT_INFO_DEFAULT_VALUE)
34
+ .notNull(),
35
+ hasLeftProject: int('hasLeftProject', { mode: 'boolean' })
36
+ .notNull()
37
+ .default(false),
41
38
  })
42
39
 
43
40
  /**
44
41
  * @typedef {Omit<import('@comapeo/schema').DeviceInfoValue, 'schemaName'>} DeviceInfoParam
45
42
  */
46
43
 
47
- const deviceInfoColumn =
48
- /** @type {ReturnType<typeof import('drizzle-orm/sqlite-core').customType<{data: DeviceInfoParam }>>} */ (
49
- customJson
50
- )
51
-
52
44
  // This table only ever has one row in it.
53
45
  export const deviceSettingsTable = sqliteTable('deviceSettings', {
54
46
  deviceId: text('deviceId').notNull().unique(),
55
- deviceInfo: deviceInfoColumn('deviceInfo'),
47
+ deviceInfo:
48
+ /** @type {$Type<SQLiteTextJsonBuilder, DeviceInfoParam>} */
49
+ (text('deviceInfo', { mode: 'json' })),
56
50
  isArchiveDevice: int('isArchiveDevice', { mode: 'boolean' }),
57
51
  })
@@ -0,0 +1,57 @@
1
+ import { text, sqliteTable } from 'drizzle-orm/sqlite-core'
2
+ import { jsonSchemaToDrizzleSqliteTable } from './json-schema-to-drizzle.js'
3
+
4
+ export const BACKLINK_TABLE_POSTFIX = '_backlink'
5
+
6
+ /**
7
+ * @import { JsonSchemaToDrizzleSqliteTable } from './types.js'
8
+ * @import { SQLiteTextJsonBuilder } from 'drizzle-orm/sqlite-core'
9
+ * @import { $Type } from 'drizzle-orm'
10
+ * @import { MapeoDocMap } from '../types.js'
11
+ */
12
+
13
+ /**
14
+ * @typedef {typeof import('@comapeo/schema').dereferencedDocSchemas} ComapeoSchemaMap
15
+ * @typedef {{ forks: $Type<SQLiteTextJsonBuilder, string[]> }} AdditionalColumns
16
+ */
17
+
18
+ /**
19
+ * @template {ComapeoSchemaMap[keyof ComapeoSchemaMap]} TSchema
20
+ * @param {TSchema} schema
21
+ * @returns {JsonSchemaToDrizzleSqliteTable<
22
+ * MapeoDocMap[TSchema['properties']['schemaName']['const']],
23
+ * TSchema,
24
+ * TSchema['properties']['schemaName']['const'],
25
+ * AdditionalColumns,
26
+ * 'docId'
27
+ * >}
28
+ */
29
+ export function comapeoSchemaToDrizzleTable(schema) {
30
+ return jsonSchemaToDrizzleSqliteTable(
31
+ schema.properties.schemaName.const,
32
+ schema,
33
+ {
34
+ additionalColumns: { forks: text('forks', { mode: 'json' }).notNull() },
35
+ primaryKey: 'docId',
36
+ }
37
+ )
38
+ }
39
+
40
+ /**
41
+ * Table for storing backlinks, used for indexing. There needs to be one for
42
+ * each indexed document type, with a specific name `<datatype>_backlink`
43
+ *
44
+ * @param {import('@comapeo/schema').MapeoDoc['schemaName']} schemaName
45
+ */
46
+ export function backlinkTable(schemaName) {
47
+ return sqliteTable(getBacklinkTableName(schemaName), {
48
+ versionId: text('versionId').notNull().primaryKey(),
49
+ })
50
+ }
51
+
52
+ /**
53
+ * @param {string} tableName
54
+ */
55
+ export function getBacklinkTableName(tableName) {
56
+ return tableName + BACKLINK_TABLE_POSTFIX
57
+ }
@@ -1,24 +1,24 @@
1
- import { text, integer, real } from 'drizzle-orm/sqlite-core'
1
+ import { text, integer, real, sqliteTable } from 'drizzle-orm/sqlite-core'
2
2
  import { ExhaustivenessError } from '../utils.js'
3
- import { customJson } from './utils.js'
4
- /** @import { MapeoDoc } from '@comapeo/schema' */
5
- /** @import { MapeoDocMap } from '../types.js' */
6
3
 
7
4
  /**
8
- Convert a JSONSchema definition to a Drizzle Columns Map (the parameter for
9
- `sqliteTable()`).
10
-
11
- **NOTE**: The return of this function is _not_ type-checked (it is coerced with
12
- `as`, because it's not possible to type-check what this function is doing), but
13
- the return type _should_ be correct when using this function.
14
- @template {import('./types.js').JSONSchema7WithProps} TSchema
15
- NB: The inline typescript checker often marks this next line as an error, but this seems to be a bug with JSDoc parsing - running `tsc` does not show this as an error.
16
- @template {import('type-fest').Get<TSchema, 'properties.schemaName.const'>} TSchemaName
17
- @template {TSchemaName extends MapeoDoc['schemaName'] ? MapeoDocMap[TSchemaName] : any} TObjectType
18
- @param {TSchema} schema
19
- @returns {import('./types.js').SchemaToDrizzleColumns<TSchema, TObjectType>}
5
+ * @template {{ [ K in keyof TSchema['properties'] ]?: any }} TObjectType
6
+ * @template {import('./types.js').JSONSchema7Object} TSchema
7
+ * @template {string} TTableName
8
+ * @template {Record<string, import('drizzle-orm').ColumnBuilderBase>} TColumnsMap
9
+ * @template {keyof TSchema['properties']} TPrimaryKey
10
+ * @param {TTableName} tableName
11
+ * @param {TSchema} schema
12
+ * @param {object} [opts]
13
+ * @param {TColumnsMap} [opts.additionalColumns]
14
+ * @param {TPrimaryKey} [opts.primaryKey] - Column name to use as primary key, if not specified in schema
15
+ * @returns {import('./types.js').JsonSchemaToDrizzleSqliteTable<TObjectType, TSchema, TTableName, TColumnsMap, TPrimaryKey>}
20
16
  */
21
- export function jsonSchemaToDrizzleColumns(schema) {
17
+ export function jsonSchemaToDrizzleSqliteTable(
18
+ tableName,
19
+ schema,
20
+ { additionalColumns, primaryKey } = {}
21
+ ) {
22
22
  if (schema.type !== 'object' || !schema.properties) {
23
23
  throw new Error('Cannot process JSONSchema as SQL table')
24
24
  }
@@ -46,14 +46,11 @@ export function jsonSchemaToDrizzleColumns(schema) {
46
46
  ? /** @type {[typeof value.const]} */ ([value.const])
47
47
  : undefined
48
48
  columns[key] = text(key, { enum: enumValue })
49
- if (key === 'docId') {
50
- columns[key] = columns[key].primaryKey()
51
- }
52
49
  break
53
50
  }
54
51
  case 'array':
55
52
  case 'object':
56
- columns[key] = customJson(key)
53
+ columns[key] = text(key, { mode: 'json' })
57
54
  break
58
55
  case 'null':
59
56
  // Skip handling this right now
@@ -69,10 +66,13 @@ export function jsonSchemaToDrizzleColumns(schema) {
69
66
  columns[key] = columns[key].default(defaultValue)
70
67
  }
71
68
  }
69
+ if (key === primaryKey) {
70
+ columns[key] = columns[key].primaryKey()
71
+ }
72
72
  }
73
- // Not yet in @comapeo/schema
74
- columns.forks = customJson('forks').notNull()
75
- return /** @type {any} */ (columns)
73
+ return /** @type {any} */ (
74
+ sqliteTable(tableName, { ...columns, ...additionalColumns })
75
+ )
76
76
  }
77
77
 
78
78
  /**
@@ -85,7 +85,7 @@ function getDefault(value) {
85
85
  }
86
86
 
87
87
  /**
88
- * @param {import('./types.js').JSONSchema7WithProps} schema
88
+ * @param {import('./types.js').JSONSchema7Object} schema
89
89
  * @param {string} key
90
90
  * @returns {boolean}
91
91
  */