Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: rebuild SQLite when project migrations occur #949

Merged
merged 6 commits into from
Oct 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@
"magic-bytes.js": "^1.10.0",
"map-obj": "^5.0.2",
"mime": "^4.0.3",
"multi-core-indexer": "^1.0.0-alpha.10",
"multi-core-indexer": "^1.0.0",
"p-defer": "^4.0.0",
"p-event": "^6.0.1",
"p-timeout": "^6.1.2",
Expand Down
3 changes: 3 additions & 0 deletions src/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,6 @@ export const NAMESPACE_SCHEMAS = /** @type {const} */ ({
})

export const SUPPORTED_CONFIG_VERSION = 1

// WARNING: This value is persisted. Be careful when changing it.
export const DRIZZLE_MIGRATIONS_TABLE = '__drizzle_migrations'
1 change: 1 addition & 0 deletions src/datastore/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const datastore = new DataStore({
// Process entries here using an indexer...
},
namespace: 'data',
reindex: false,
})

/** @type {MapeoDoc} */
Expand Down
4 changes: 3 additions & 1 deletion src/datastore/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,9 @@ export class DataStore extends TypedEmitter {
* @param {TNamespace} opts.namespace
* @param {(entries: MultiCoreIndexer.Entry<'binary'>[]) => Promise<import('../index-writer/index.js').IndexedDocIds>} opts.batch
* @param {MultiCoreIndexer.StorageParam} opts.storage
* @param {boolean} opts.reindex
*/
constructor({ coreManager, namespace, batch, storage }) {
constructor({ coreManager, namespace, batch, storage, reindex }) {
super()
this.#coreManager = coreManager
this.#namespace = namespace
Expand All @@ -66,6 +67,7 @@ export class DataStore extends TypedEmitter {
this.#coreIndexer = new MultiCoreIndexer(cores, {
storage,
batch: (entries) => this.#handleEntries(entries),
reindex,
})
coreManager.on('add-core', (coreRecord) => {
if (coreRecord.namespace !== namespace) return
Expand Down
79 changes: 79 additions & 0 deletions src/lib/drizzle-helpers.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { sql } from 'drizzle-orm'
import { assert } from '../utils.js'
import { migrate as drizzleMigrate } from 'drizzle-orm/better-sqlite3/migrator'
import { DRIZZLE_MIGRATIONS_TABLE } from '../constants.js'
/** @import { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3' */

/**
* @param {unknown} queryResult
* @returns {number}
*/
const getNumberResult = (queryResult) => {
assert(
queryResult &&
typeof queryResult === 'object' &&
'result' in queryResult &&
typeof queryResult.result === 'number',
'expected query to return proper result'
)
return queryResult.result
}

/**
* Get the number of rows in a table using `SELECT COUNT(*)`.
* Returns 0 if the table doesn't exist.
*
* @param {BetterSQLite3Database} db
* @param {string} tableName
* @returns {number}
*/
const safeCountTableRows = (db, tableName) =>
db.transaction((tx) => {
const existsQuery = sql`
SELECT EXISTS (
SELECT 1
FROM sqlite_schema
WHERE type IS 'table'
AND name IS ${tableName}
) AS result
`
const existsResult = tx.get(existsQuery)
const exists = getNumberResult(existsResult)
if (!exists) return 0

const countQuery = sql`
SELECT COUNT(*) AS result
FROM ${sql.identifier(tableName)}
`
const countResult = tx.get(countQuery)
return getNumberResult(countResult)
})

/**
* @internal
* @typedef {'initialized database' | 'migrated' | 'no migration'} MigrationResult
*/

/**
* Wrapper around Drizzle's migration function. Returns what happened during
* migration; did a migration occur?
*
* @param {BetterSQLite3Database} db
* @param {object} options
* @param {string} options.migrationsFolder
* @returns {MigrationResult}
*/
export const migrate = (db, { migrationsFolder }) => {
const migrationsBefore = safeCountTableRows(db, DRIZZLE_MIGRATIONS_TABLE)
drizzleMigrate(db, {
migrationsFolder,
migrationsTable: DRIZZLE_MIGRATIONS_TABLE,
})
const migrationsAfter = safeCountTableRows(db, DRIZZLE_MIGRATIONS_TABLE)

if (migrationsAfter === migrationsBefore) return 'no migration'

if (migrationsBefore === 0) return 'initialized database'

return 'migrated'
}
61 changes: 44 additions & 17 deletions src/mapeo-project.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import path from 'path'
import Database from 'better-sqlite3'
import { decodeBlockPrefix, decode, parseVersionId } from '@comapeo/schema'
import { drizzle } from 'drizzle-orm/better-sqlite3'
import { migrate } from 'drizzle-orm/better-sqlite3/migrator'
import { discoveryKey } from 'hypercore-crypto'
import { TypedEmitter } from 'tiny-typed-emitter'

Expand Down Expand Up @@ -39,11 +38,13 @@ import {
} from './roles.js'
import {
assert,
ExhaustivenessError,
getDeviceId,
projectKeyToId,
projectKeyToPublicId,
valueOf,
} from './utils.js'
import { migrate } from './lib/drizzle-helpers.js'
import { omit } from './lib/omit.js'
import { MemberApi } from './member-api.js'
import { SyncApi, kHandleDiscoveryKey } from './sync/sync-api.js'
Expand Down Expand Up @@ -139,11 +140,45 @@ export class MapeoProject extends TypedEmitter {
this.#isArchiveDevice = isArchiveDevice

///////// 1. Setup database

this.#sqlite = new Database(dbPath)
const db = drizzle(this.#sqlite)
migrate(db, { migrationsFolder: projectMigrationsFolder })
const migrationResult = migrate(db, {
migrationsFolder: projectMigrationsFolder,
})
let reindex
switch (migrationResult) {
case 'initialized database':
case 'no migration':
reindex = false
break
case 'migrated':
reindex = true
break
default:
throw new ExhaustivenessError(migrationResult)
}

const indexedTables = [
observationTable,
trackTable,
presetTable,
fieldTable,
coreOwnershipTable,
roleTable,
deviceInfoTable,
iconTable,
translationTable,
remoteDetectionAlertTable,
]

///////// 2. Wipe data if we need to re-index

if (reindex) {
for (const table of indexedTables) db.delete(table).run()
}

///////// 2. Setup random-access-storage functions
///////// 3. Setup random-access-storage functions

/** @type {ConstructorParameters<typeof CoreManager>[0]['storage']} */
const coreManagerStorage = (name) =>
Expand All @@ -153,7 +188,7 @@ export class MapeoProject extends TypedEmitter {
const indexerStorage = (name) =>
coreStorage(path.join(INDEXER_STORAGE_FOLDER_NAME, name))

///////// 3. Create instances
///////// 4. Create instances

this.#coreManager = new CoreManager({
projectSecretKey,
Expand All @@ -166,18 +201,7 @@ export class MapeoProject extends TypedEmitter {
})

this.#indexWriter = new IndexWriter({
tables: [
observationTable,
trackTable,
presetTable,
fieldTable,
coreOwnershipTable,
roleTable,
deviceInfoTable,
iconTable,
translationTable,
remoteDetectionAlertTable,
],
tables: indexedTables,
sqlite: this.#sqlite,
getWinner,
mapDoc: (doc, version) => {
Expand All @@ -199,6 +223,7 @@ export class MapeoProject extends TypedEmitter {
namespace: 'auth',
batch: (entries) => this.#indexWriter.batch(entries),
storage: indexerStorage,
reindex,
}),
config: new DataStore({
coreManager: this.#coreManager,
Expand All @@ -209,12 +234,14 @@ export class MapeoProject extends TypedEmitter {
sharedIndexWriter,
}),
storage: indexerStorage,
reindex,
}),
data: new DataStore({
coreManager: this.#coreManager,
namespace: 'data',
batch: (entries) => this.#indexWriter.batch(entries),
storage: indexerStorage,
reindex,
}),
}

Expand Down Expand Up @@ -363,7 +390,7 @@ export class MapeoProject extends TypedEmitter {
dataType: this.#dataTypes.translation,
})

///////// 4. Replicate local peers automatically
///////// 5. Replicate local peers automatically

// Replicate already connected local peers
for (const peer of localPeers.peers) {
Expand Down
101 changes: 97 additions & 4 deletions test-e2e/migration.js
Original file line number Diff line number Diff line change
@@ -1,18 +1,111 @@
import test from 'node:test'
import { KeyManager } from '@mapeo/crypto'
import RAM from 'random-access-memory'
import { MapeoManager } from '../src/mapeo-manager.js'
import Fastify from 'fastify'
import assert from 'node:assert/strict'
import fsPromises from 'node:fs/promises'
import test from 'node:test'
import RAM from 'random-access-memory'
import { temporaryDirectory } from 'tempy'
import { createOldManagerOnVersion2_0_1 } from './utils.js'
import { MapeoManager } from '../src/mapeo-manager.js'
import {
connectPeers,
createManager,
createOldManagerOnVersion2_0_1,
invite,
} from './utils.js'

const projectMigrationsFolder = new URL('../drizzle/project', import.meta.url)
.pathname
const clientMigrationsFolder = new URL('../drizzle/client', import.meta.url)
.pathname

test('migrations pick up values that were not previously understood', async (t) => {
// Create Manager 1, which has new data.

const manager1 = createManager('a', t)
await manager1.setDeviceInfo({
name: 'a',
deviceType: 'selfHostedServer',
// Old versions shouldn't be able to recognize this.
selfHostedServerDetails: { baseUrl: 'https://comapeo-test.example/' },
})

const projectId = await manager1.createProject({ name: 'test project' })
const manager1Project = await manager1.getProject(projectId)

{
const manager1Members = await manager1Project.$member.getMany()
assert(
manager1Members.some(
(member) =>
member.selfHostedServerDetails?.baseUrl ===
'https://comapeo-test.example/'
),
'test setup: new manager has new data'
)
}

// Create Manager 2, which is not yet up to date.

const manager2DbFolder = temporaryDirectory()
const manager2CoreStorage = temporaryDirectory()
t.after(() => fsPromises.rm(manager2DbFolder, { recursive: true }))
t.after(() => fsPromises.rm(manager2CoreStorage, { recursive: true }))

const manager2BeforeMigration = await createOldManagerOnVersion2_0_1('b', {
dbFolder: manager2DbFolder,
coreStorage: manager2CoreStorage,
})
await manager2BeforeMigration.setDeviceInfo({
name: 'b',
deviceType: 'mobile',
})

// Connect them and ensure that Manager 2 doesn't yet know about the new data.

const disconnect = connectPeers([manager1, manager2BeforeMigration])

await invite({
projectId,
invitor: manager1,
invitees: [manager2BeforeMigration],
})

{
const manager2Project = await manager2BeforeMigration.getProject(projectId)
await manager2Project.$sync.waitForSync('initial')
const manager2Members = await manager2Project.$member.getMany()
assert(
!manager2Members.some((member) => 'selfHostedServerDetails' in member),
"test setup: old manager doesn't understand new data (yet)"
)

await manager2Project.close()
}

await disconnect()

// Migrate Manager 2 and see that it now knows about the data.

const manager2AfterMigration = createManager('b', t, {
dbFolder: manager2DbFolder,
coreStorage: manager2CoreStorage,
})

{
const manager2Project = await manager2AfterMigration.getProject(projectId)
const manager2Members = await manager2Project.$member.getMany()
const serverMember = manager2Members.find(
(member) => member.deviceType === 'selfHostedServer'
)
assert(serverMember, 'we still have the server member')
assert.equal(
serverMember.selfHostedServerDetails?.baseUrl,
'https://comapeo-test.example/',
'migrated manager has new data'
)
}
})

test('migration of localDeviceInfo table', async (t) => {
const dbFolder = temporaryDirectory()
const rootKey = KeyManager.generateRootKey()
Expand Down
Loading
Loading