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

implement addProject method for MapeoManager class #215

Merged
merged 22 commits into from
Aug 29, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
bad938d
initial implementation
achou11 Aug 23, 2023
a7962db
update e2e test (currently broken)
achou11 Aug 23, 2023
d795d40
update implementation based on suggestions
achou11 Aug 24, 2023
6a301d3
set initial project settings and fix test
achou11 Aug 24, 2023
ad7ccd9
add todo comment about closing project instance
achou11 Aug 24, 2023
d66b99d
small cleanup
achou11 Aug 24, 2023
ee9f065
add test for attempting to add existing project
achou11 Aug 24, 2023
aa42617
add projectInfo column to projectKeys table
achou11 Aug 24, 2023
2175b97
update listProjects and addProject to use updated projectInfo column
achou11 Aug 24, 2023
7421bfa
update tests
achou11 Aug 24, 2023
6acbbcb
update addProject to use projectKeys table as source of truth
achou11 Aug 24, 2023
93fef59
update implementation to use appropriate source of truth tables
achou11 Aug 24, 2023
bfe7c0f
fix projectInfo column default value
achou11 Aug 29, 2023
011ef5a
remove addedAt project info stuff
achou11 Aug 29, 2023
9740df6
update getProject to allow returning just-added project
achou11 Aug 29, 2023
52557a4
update listProjects to return just-added projects
achou11 Aug 29, 2023
464f3cf
Revert "fix projectInfo column default value"
achou11 Aug 29, 2023
4d619ca
add comment about missing step
achou11 Aug 29, 2023
00082dc
update comment
achou11 Aug 29, 2023
3942e9f
add name assertions in multiple projects test
achou11 Aug 29, 2023
3fd174d
update test to not assume ordering
achou11 Aug 29, 2023
f83d914
update projectInfo column type
achou11 Aug 29, 2023
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
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ CREATE TABLE `project_backlink` (
--> statement-breakpoint
CREATE TABLE `projectKeys` (
`projectId` text PRIMARY KEY NOT NULL,
`keysCipher` blob NOT NULL
`keysCipher` blob NOT NULL,
`projectInfo` text DEFAULT '{}' NOT NULL
);
--> statement-breakpoint
CREATE TABLE `project` (
Expand Down
10 changes: 9 additions & 1 deletion drizzle/client/meta/0000_snapshot.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"version": "5",
"dialect": "sqlite",
"id": "adc33ec5-2e25-4d2b-91dc-f05686cb135a",
"id": "a4afa0b6-5fd1-4c8d-bc09-7e3f3fc1928f",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"project_backlink": {
Expand Down Expand Up @@ -36,6 +36,14 @@
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"projectInfo": {
"name": "projectInfo",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'{}'"
}
},
"indexes": {},
Expand Down
4 changes: 2 additions & 2 deletions drizzle/client/meta/_journal.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
{
"idx": 0,
"version": "5",
"when": 1692809392300,
"tag": "0000_absent_russian",
"when": 1693337118674,
"tag": "0000_steady_jackpot",
"breakpoints": true
}
]
Expand Down
140 changes: 120 additions & 20 deletions src/mapeo-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,37 @@ export class MapeoManager {
this.#activeProjects = new Map()
}

/**
* @param {Buffer} keysCipher
* @param {string} projectId
* @returns {ProjectKeys}
*/
#decodeProjectKeysCipher(keysCipher, projectId) {
return ProjectKeys.decode(
this.#keyManager.decryptLocalMessage(keysCipher, projectId)
)
}

/**
* @param {Object} opts
* @param {string} opts.projectId
* @param {ProjectKeys} opts.projectKeys
* @param {import('./generated/rpc.js').Invite_ProjectInfo} [opts.projectInfo]
*/
#saveToProjectKeysTable({ projectId, projectKeys, projectInfo }) {
this.#db
.insert(projectKeysTable)
.values({
projectId,
keysCipher: this.#keyManager.encryptLocalMessage(
Buffer.from(ProjectKeys.encode(projectKeys).finish().buffer),
projectId
),
projectInfo,
})
.run()
}

/**
* Create a new project.
* @param {import('type-fest').Simplify<Partial<Pick<ProjectValue, 'name'>>>} [settings]
Expand All @@ -66,7 +97,7 @@ export class MapeoManager {
data: randomBytes(32),
}

// 3. Save keys to client db in projectKeys table
// 3. Save keys to client db projectKeys table
/** @type {ProjectKeys} */
const keys = {
projectKey: projectKeypair.publicKey,
Expand All @@ -77,16 +108,7 @@ export class MapeoManager {
// TODO: Update to use @mapeo/crypto when ready (https://github.com/digidem/mapeo-core-next/issues/171)
const projectId = projectKeypair.publicKey.toString('hex')

this.#db
.insert(projectKeysTable)
.values({
projectId,
keysCipher: this.#keyManager.encryptLocalMessage(
Buffer.from(ProjectKeys.encode(keys).finish().buffer),
projectId
),
})
.run()
this.#saveToProjectKeysTable({ projectId, projectKeys: keys })

// 4. Create MapeoProject instance
const project = new MapeoProject({
Expand Down Expand Up @@ -115,24 +137,27 @@ export class MapeoManager {
* @returns {Promise<MapeoProject>}
*/
async getProject(projectId) {
const existing = this.#activeProjects.get(projectId)
// 1. Check for existing active project
const activeProject = this.#activeProjects.get(projectId)

if (existing) return existing
if (activeProject) return activeProject

const result = this.#db
// 2. Create project instance
const projectKeysTableResult = this.#db
.select({
keysCipher: projectKeysTable.keysCipher,
})
.from(projectKeysTable)
.where(eq(projectKeysTable.projectId, projectId))
.get()

if (!result) {
if (!projectKeysTableResult) {
throw new Error(`NotFound: project ID ${projectId} not found`)
}

const projectKeys = ProjectKeys.decode(
this.#keyManager.decryptLocalMessage(result.keysCipher, projectId)
const projectKeys = this.#decodeProjectKeysCipher(
projectKeysTableResult.keysCipher,
projectId
)

const project = new MapeoProject({
Expand All @@ -143,14 +168,28 @@ export class MapeoManager {
sharedIndexWriter: this.#projectSettingsIndexWriter,
})

// 3. Keep track of project instance as we know it's a properly existing project
this.#activeProjects.set(projectId, project)

return project
}

/**
* @returns {Promise<Array<Pick<ProjectValue, 'name'> & { projectId: string, createdAt: string, updatedAt: string }>>}
* @returns {Promise<Array<Pick<ProjectValue, 'name'> & { projectId: string, createdAt?: string, updatedAt?: string }>>}
*/
async listProjects() {
return this.#db
// We use the project keys table as the source of truth for projects that exist
// because we will always update this table when doing a create or add
// whereas the project table will only have projects that have been created, or added + synced
const allProjectKeysResult = this.#db
.select({
projectId: projectKeysTable.projectId,
projectInfo: projectKeysTable.projectInfo,
})
.from(projectKeysTable)
.all()

const allProjectsResult = this.#db
.select({
projectId: projectTable.docId,
createdAt: projectTable.createdAt,
Expand All @@ -159,6 +198,67 @@ export class MapeoManager {
})
.from(projectTable)
.all()
.map((value) => deNullify(value))

/** @type {Array<Pick<ProjectValue, 'name'> & { projectId: string, createdAt?: string, updatedAt?: string }>} */
const result = []

for (const { projectId, projectInfo } of allProjectKeysResult) {
const existingProject = allProjectsResult.find(
(p) => p.projectId === projectId
)

result.push(
deNullify({
projectId,
createdAt: existingProject?.createdAt,
updatedAt: existingProject?.updatedAt,
name: existingProject?.name || projectInfo.name,
})
)
}

return result
}

/**
* @param {import('./generated/rpc.js').Invite} invite
* @returns {Promise<string>}
*/
async addProject({ projectKey, encryptionKeys, projectInfo }) {
const projectId = projectKey.toString('hex')

// 1. Check for an active project
const activeProject = this.#activeProjects.get(projectId)

if (activeProject) {
throw new Error(`Project with ID ${projectId} already exists`)
}

// 2. Check if the project exists in the project keys table
// If it does, that means the project has already been either created or added before
const projectExists = this.#db
.select()
.from(projectKeysTable)
.where(eq(projectKeysTable.projectId, projectId))
.get()

if (projectExists) {
throw new Error(`Project with ID ${projectId} already exists`)
}

// TODO: Relies on completion of https://github.com/digidem/mapeo-core-next/issues/233
// 3. Sync auth + config cores

// 4. Update the project keys table
this.#saveToProjectKeysTable({
projectId,
projectKeys: {
projectKey,
encryptionKeys,
},
projectInfo,
})

return projectId
}
}
17 changes: 16 additions & 1 deletion src/schema/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,26 @@
import { blob, sqliteTable, text } from 'drizzle-orm/sqlite-core'
import { dereferencedDocSchemas as schemas } from '@mapeo/schema'
import { jsonSchemaToDrizzleColumns as toColumns } from './schema-to-drizzle.js'
import { backlinkTable } from './utils.js'
import { backlinkTable, customJson } from './utils.js'
achou11 marked this conversation as resolved.
Show resolved Hide resolved

const projectInfoColumn =
/** @type {ReturnType<typeof import('drizzle-orm/sqlite-core').customType<{data: import('../generated/rpc.js').Invite_ProjectInfo }>>} */ (
customJson
)

/** @type {import('../generated/rpc.js').Invite_ProjectInfo} */
const PROJECT_INFO_DEFAULT_VALUE = {}

export const projectTable = sqliteTable('project', toColumns(schemas.project))
export const projectBacklinkTable = backlinkTable(projectTable)
export const projectKeysTable = sqliteTable('projectKeys', {
projectId: text('projectId').notNull().primaryKey(),
keysCipher: blob('keysCipher', { mode: 'buffer' }).notNull(),
projectInfo: projectInfoColumn('projectInfo')
.default(
// TODO: There's a bug in Drizzle where the default value does not get transformed by the custom type
// @ts-expect-error
JSON.stringify(PROJECT_INFO_DEFAULT_VALUE)
)
.notNull(),
})
16 changes: 2 additions & 14 deletions src/schema/schema-to-drizzle.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { text, integer, real, customType } from 'drizzle-orm/sqlite-core'
import { text, integer, real } from 'drizzle-orm/sqlite-core'
import { customJson } from './utils.js'

/**
@typedef {import('@mapeo/schema').MapeoDoc} MapeoDoc
Expand All @@ -7,19 +8,6 @@ import { text, integer, real, customType } from 'drizzle-orm/sqlite-core'
@typedef {import('../types.js').MapeoDocMap} MapeoDocMap
*/

const customJson = customType({
dataType() {
return 'text'
},
fromDriver(value) {
// @ts-ignore
return JSON.parse(value)
},
toDriver(value) {
return JSON.stringify(value)
},
})

/**
Convert a JSONSchema definition to a Drizzle Columns Map (the parameter for
`sqliteTable()`).
Expand Down
20 changes: 19 additions & 1 deletion src/schema/utils.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { text, getTableConfig, sqliteTable } from 'drizzle-orm/sqlite-core'
import {
text,
getTableConfig,
sqliteTable,
customType,
} from 'drizzle-orm/sqlite-core'

/**
* @template {string} [TName=string]
Expand Down Expand Up @@ -30,3 +35,16 @@ export function backlinkTable(tableSchema) {
export function getBacklinkTableName(tableName) {
return tableName + BACKLINK_TABLE_POSTFIX
}

export const customJson = customType({
dataType() {
return 'text'
},
fromDriver(value) {
// @ts-ignore
return JSON.parse(value)
},
toDriver(value) {
return JSON.stringify(value)
},
})
55 changes: 45 additions & 10 deletions test-e2e/manager-basic.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { test } from 'brittle'
import { randomBytes } from 'crypto'
import { KeyManager } from '@mapeo/crypto'
import { MapeoManager } from '../src/mapeo-manager.js'

Expand All @@ -13,17 +14,51 @@ test('Managing multiple projects', async (t) => {
'no projects exist when manager is initially created'
)

const createdProjectIds = [
await manager.createProject(),
await manager.createProject(),
await manager.createProject(),
]
const createdProjectId = await manager.createProject({
name: 'created project',
})

const allProjects = await manager.listProjects()
const addedProjectId = await manager.addProject({
projectKey: KeyManager.generateProjectKeypair().publicKey,
encryptionKeys: { auth: randomBytes(32) },
projectInfo: { name: 'added project' },
})

t.is(allProjects.length, createdProjectIds.length)
t.ok(
allProjects.every((p) => createdProjectIds.includes(p.projectId)),
'all created projects are listed'
const listedProjects = await manager.listProjects()

t.is(listedProjects.length, 2)

const createdProject = listedProjects.find(
({ projectId }) => projectId === createdProjectId
)
t.ok(createdProject, 'created project is listed')
t.is(createdProject?.name, 'created project')

const addedProject = listedProjects.find(
({ projectId }) => projectId === addedProjectId
)
t.ok(addedProject, 'added project is listed')
t.is(addedProject?.name, 'added project')
})

test('Manager cannot add project that already exists', async (t) => {
const manager = new MapeoManager({ rootKey: KeyManager.generateRootKey() })

const existingProjectId = await manager.createProject()

const existingProjectsCountBefore = (await manager.listProjects()).length

t.exception(
manager.addProject({
projectKey: Buffer.from(existingProjectId, 'hex'),
encryptionKeys: {
auth: randomBytes(32),
},
}),
'attempting to add project that already exists throws'
)

const existingProjectsCountAfter = (await manager.listProjects()).length

t.is(existingProjectsCountBefore, existingProjectsCountAfter)
})
Loading