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

add project settings functionality to MapeoProject #187

Merged
merged 38 commits into from
Aug 24, 2023
Merged
Show file tree
Hide file tree
Changes from 36 commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
7102623
wip: initial implementation
achou11 Aug 16, 2023
79c7feb
instantiate project datastore
achou11 Aug 17, 2023
cebc1ab
project info config opt
achou11 Aug 17, 2023
98ebec2
add project to NAMESPACE_SCHEMAS
achou11 Aug 18, 2023
bf0bcf3
update naming from project info to project settings"
achou11 Aug 18, 2023
636028c
fix batch function in config datastore
achou11 Aug 18, 2023
4bd5110
fix crud tests
achou11 Aug 18, 2023
d5f761e
implement setProjectSettings
achou11 Aug 18, 2023
4288d76
add basic test for project settings indexing
achou11 Aug 18, 2023
51bb7f3
use a project index writer for project crud test setup
achou11 Aug 18, 2023
22a6ba5
extract client and project setup to e2e test utils
achou11 Aug 18, 2023
bee4f4c
replace bind with call
achou11 Aug 18, 2023
df47358
add test for multiple projects
achou11 Aug 21, 2023
8204c21
update tests to pass versionId for setProjectSettings
achou11 Aug 21, 2023
fe9c376
add private field for project id
achou11 Aug 21, 2023
6018a46
implement getProjectSettings
achou11 Aug 21, 2023
bdbca4f
add settings test for create, read, update
achou11 Aug 21, 2023
1b953f3
remove unused import
achou11 Aug 21, 2023
6763de9
ignore errors raised by decode
achou11 Aug 22, 2023
2cc9d44
update setProjectSettings
achou11 Aug 22, 2023
82563b4
update return type of getProjectSettings
achou11 Aug 22, 2023
d5ed306
update tsconfig
achou11 Aug 22, 2023
6c5822a
simplify constructor for MapeoProject
achou11 Aug 22, 2023
1925230
Merge branch 'main' into 167/project-info-index
achou11 Aug 22, 2023
c6259d2
update @mapeo/schema to use decodeBlockPrefix
achou11 Aug 22, 2023
bd311a2
create private method for handling config entries
achou11 Aug 22, 2023
a6a468f
update getProjectSettings
achou11 Aug 22, 2023
9f80bc7
add comment about 'existing' check in setProjectSettings
achou11 Aug 23, 2023
fe56a1a
return empty object in getProjectSettings instead of null
achou11 Aug 23, 2023
26e9310
Remove test
achou11 Aug 23, 2023
13a8638
update @mapeo/schema to fix optional settings fields
achou11 Aug 23, 2023
b7dcdb4
cast return type in getProjectSettings
achou11 Aug 23, 2023
19057ce
improve types for set and get project settings
achou11 Aug 23, 2023
903183d
update tests after return type changes
achou11 Aug 23, 2023
21c9ee8
remove unused imports and variables in test
achou11 Aug 23, 2023
690afab
create helper function to extract editable settings fields
achou11 Aug 23, 2023
f0dcf62
rename dataStoreIndexWriter -> projectIndexWriter
gmaclennan Aug 24, 2023
1213be6
fix param type for handleConfigEntries
achou11 Aug 24, 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 @@ -14,7 +14,7 @@ CREATE TABLE `project` (
`createdAt` text NOT NULL,
`updatedAt` text NOT NULL,
`links` text NOT NULL,
`name` text NOT NULL,
`defaultPresets` text NOT NULL,
`name` text,
`defaultPresets` text,
`forks` text NOT NULL
);
6 changes: 3 additions & 3 deletions 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": "9bca3bea-0cdd-4406-891f-9b75e68a94e7",
"id": "922e15d1-08b4-424d-bb86-b4d11cae3be4",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"project_backlink": {
Expand Down Expand Up @@ -92,14 +92,14 @@
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"notNull": false,
"autoincrement": false
},
"defaultPresets": {
"name": "defaultPresets",
"type": "text",
"primaryKey": false,
"notNull": true,
"notNull": false,
"autoincrement": false
},
"forks": {
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": 1692208651901,
"tag": "0000_green_sir_ram",
"when": 1692799838836,
"tag": "0000_medical_ser_duncan",
"breakpoints": true
}
]
Expand Down
14 changes: 7 additions & 7 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 @@ -96,7 +96,7 @@
"@fastify/type-provider-typebox": "^3.3.0",
"@hyperswarm/secret-stream": "^6.1.2",
"@mapeo/crypto": "^1.0.0-alpha.4",
"@mapeo/schema": "^3.0.0-next.6",
"@mapeo/schema": "^3.0.0-next.8",
"@mapeo/sqlite-indexer": "^1.0.0-alpha.5",
"@sinclair/typebox": "^0.29.6",
"b4a": "^1.6.3",
Expand Down
2 changes: 1 addition & 1 deletion src/datastore/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import pDefer from 'p-defer'

const NAMESPACE_SCHEMAS = /** @type {const} */ ({
data: ['observation'],
config: ['preset', 'field'],
config: ['preset', 'field', 'project'],
auth: [],
Comment on lines 26 to 29
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think there should be a comment here explaining what this is used for. took me a bit to figure out I needed to update it to add the project schema

})

Expand Down
118 changes: 115 additions & 3 deletions src/mapeo-project.js
Original file line number Diff line number Diff line change
@@ -1,17 +1,22 @@
// @ts-check
import { decodeBlockPrefix } from '@mapeo/schema'
import { drizzle } from 'drizzle-orm/better-sqlite3'
import { migrate } from 'drizzle-orm/better-sqlite3/migrator'

import { CoreManager } from './core-manager/index.js'
import { DataStore } from './datastore/index.js'
import { DataType } from './datatype/index.js'
import { DataType, kCreateWithDocId } from './datatype/index.js'
import { IndexWriter } from './index-writer/index.js'
import { projectTable } from './schema/client.js'
import { fieldTable, observationTable, presetTable } from './schema/project.js'
import RandomAccessFile from 'random-access-file'
import RAM from 'random-access-memory'
import Database from 'better-sqlite3'
import path from 'path'
import { RandomAccessFilePool } from './core-manager/random-access-file-pool.js'
import { valueOf } from './utils.js'

/** @typedef {Omit<import('@mapeo/schema').ProjectValue, 'schemaName'>} EditableProjectSettings */

const PROJECT_SQLITE_FILE_NAME = 'project.db'
const CORE_STORAGE_FOLDER_NAME = 'cores'
Expand All @@ -26,6 +31,7 @@ export class MapeoProject {
#coreManager
#dataStores
#dataTypes
#projectId

/**
* @param {Object} opts
Expand All @@ -34,8 +40,18 @@ export class MapeoProject {
* @param {Buffer} opts.projectKey 32-byte public key of the project creator core
* @param {Buffer} [opts.projectSecretKey] 32-byte secret key of the project creator core
* @param {Partial<Record<import('./core-manager/index.js').Namespace, Buffer>>} [opts.encryptionKeys] Encryption keys for each namespace
* @param {import('drizzle-orm/better-sqlite3').BetterSQLite3Database} opts.sharedDb
* @param {IndexWriter} opts.sharedIndexWriter
*/
constructor({ storagePath, ...coreManagerOpts }) {
constructor({
storagePath,
sharedDb,
sharedIndexWriter,
...coreManagerOpts
}) {
// TODO: Update to use @mapeo/crypto when ready (https://github.com/digidem/mapeo-core-next/issues/171)
this.#projectId = coreManagerOpts.projectKey.toString('hex')
Comment on lines +52 to +53
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just highlighting this as something that will eventually be updated


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

const dbPath =
Expand Down Expand Up @@ -86,7 +102,11 @@ export class MapeoProject {
config: new DataStore({
coreManager: this.#coreManager,
namespace: 'config',
batch: (entries) => indexWriter.batch(entries),
batch: (entries) =>
this.#handleConfigEntries(entries, {
datastoreIndexWriter: indexWriter,
sharedIndexWriter,
}),
storage: indexerStorage,
}),
data: new DataStore({
Expand All @@ -112,9 +132,47 @@ export class MapeoProject {
table: fieldTable,
db,
}),
project: new DataType({
dataStore: this.#dataStores.config,
table: projectTable,
db: sharedDb,
}),
}
}

/**
* @param {import('multi-core-indexer').Entry[]} entries
* @param {{datastoreIndexWriter: IndexWriter, sharedIndexWriter: IndexWriter}} indexWriters
*/
async #handleConfigEntries(
entries,
{ datastoreIndexWriter, sharedIndexWriter }
) {
/** @type {import('multi-core-indexer').Entry[]} */
const projectSettingsEntries = []
/** @type {import('multi-core-indexer').Entry[]} */
const otherEntries = []

for (const entry of entries) {
try {
const { schemaName } = decodeBlockPrefix(entry.block)

if (schemaName === 'project') {
projectSettingsEntries.push(entry)
} else {
otherEntries.push(entry)
}
} catch {
// Ignore errors thrown by values that can't be decoded for now
}
}

await Promise.all([
datastoreIndexWriter.batch(otherEntries),
sharedIndexWriter.batch(projectSettingsEntries),
])
}

get observation() {
return this.#dataTypes.observation
}
Expand All @@ -124,4 +182,58 @@ export class MapeoProject {
get field() {
return this.#dataTypes.field
}

/**
* @param {Partial<EditableProjectSettings>} settings
* @returns {Promise<EditableProjectSettings>}
*/
async $setProjectSettings(settings) {
const { project } = this.#dataTypes

// We only want to catch the error to the getByDocId call
// Using try/catch for this is a little verbose when dealing with TS types
const existing = await project.getByDocId(this.#projectId).catch(() => {
// project does not exist so return null
return null
})

if (existing) {
return extractEditableProjectSettings(
await project.update([existing.versionId, ...existing.forks], {
...valueOf(existing),
...settings,
})
)
}

return extractEditableProjectSettings(
await project[kCreateWithDocId](this.#projectId, {
...settings,
schemaName: 'project',
})
)
}

/**
* @returns {Promise<EditableProjectSettings>}
*/
async $getProjectSettings() {
try {
return extractEditableProjectSettings(
await this.#dataTypes.project.getByDocId(this.#projectId)
)
} catch {
return /** @type {EditableProjectSettings} */ ({})
}
}
}

/**
* @param {import("@mapeo/schema").Project & { forks: string[] }} projectDoc
* @returns {EditableProjectSettings}
*/
function extractEditableProjectSettings(projectDoc) {
// eslint-disable-next-line no-unused-vars
const { schemaName, ...result } = valueOf(projectDoc)
return result
}
11 changes: 11 additions & 0 deletions src/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,14 @@ export function deNullify(obj) {
}
return /** @type {import('./types.js').NullableToOptional<T>} */ (objNoNulls)
}

/**
* @template {import('@mapeo/schema').MapeoDoc & { forks: string[] }} T
* @param {T} doc
* @returns {Omit<T, 'docId' | 'versionId' | 'links' | 'forks' | 'createdAt' | 'updatedAt'>}
*/
export function valueOf(doc) {
// eslint-disable-next-line no-unused-vars
const { docId, versionId, links, forks, createdAt, updatedAt, ...rest } = doc
return rest
}
42 changes: 15 additions & 27 deletions test-e2e/project-crud.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { test } from 'brittle'
import { randomBytes } from 'crypto'
import { KeyManager } from '@mapeo/crypto'
import { MapeoProject } from '../src/mapeo-project.js'
import { valueOf } from '../src/utils.js'
import { setupSharedResources, createProject } from './utils.js'

/** @satisfies {Array<import('@mapeo/schema').MapeoValue>} */
const fixtures = [
Expand Down Expand Up @@ -61,18 +61,25 @@ function getUpdateFixture(value) {
}

test('CRUD operations', async (t) => {
const shared = setupSharedResources()
for (const value of fixtures) {
const { schemaName } = value
t.test(`create and read ${schemaName}`, async (t) => {
const project = await createProject()
const project = createProject({
sharedDb: shared.db,
sharedIndexWriter: shared.indexWriter,
})
// @ts-ignore - TS can't figure this out, but we're not testing types here so ok to ignore
const written = await project[schemaName].create(value)
const read = await project[schemaName].getByDocId(written.docId)
t.alike(valueOf(stripUndef(written)), value, 'expected value is written')
t.alike(written, read, 'return create() matches return of getByDocId()')
})
t.test('update', async (t) => {
const project = await createProject()
const project = createProject({
sharedDb: shared.db,
sharedIndexWriter: shared.indexWriter,
})
// @ts-ignore
const written = await project[schemaName].create(value)
const updateValue = getUpdateFixture(value)
Expand All @@ -96,7 +103,10 @@ test('CRUD operations', async (t) => {
t.is(written.createdAt, updated.createdAt, 'createdAt does not change')
})
t.test('getMany', async (t) => {
const project = await createProject()
const project = createProject({
sharedDb: shared.db,
sharedIndexWriter: shared.indexWriter,
})
const values = new Array(5).fill(null).map(() => {
return getUpdateFixture(value)
})
Expand All @@ -115,28 +125,6 @@ test('CRUD operations', async (t) => {
}
})

/**
* @template {import('@mapeo/schema').MapeoDoc & { forks: string[] }} T
* @param {T} doc
* @returns {Omit<T, 'docId' | 'versionId' | 'links' | 'forks' | 'createdAt' | 'updatedAt'>}
*/
function valueOf(doc) {
// eslint-disable-next-line no-unused-vars
const { docId, versionId, links, forks, createdAt, updatedAt, ...rest } = doc
return rest
}

function createProject({
rootKey = randomBytes(16),
projectKey = randomBytes(32),
} = {}) {
const keyManager = new KeyManager(rootKey)
return new MapeoProject({
keyManager,
projectKey,
})
}

/**
* Remove undefined properties from an object, to allow deep comparison
* @param {object} obj
Expand Down
33 changes: 33 additions & 0 deletions test-e2e/project-settings.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { test } from 'brittle'
import { setupSharedResources, createProject } from './utils.js'

test('Project settings create, read, and update operations', async (t) => {
const shared = setupSharedResources()

const project = createProject({
sharedDb: shared.db,
sharedIndexWriter: shared.indexWriter,
})

t.alike(
await project.$getProjectSettings(),
{},
'no settings when project initially created'
)

const expectedSettings = {
name: 'updated',
}

const updatedSettings = await project.$setProjectSettings(expectedSettings)

t.is(updatedSettings.name, expectedSettings.name, 'updatable fields change')

const settings = await project.$getProjectSettings()

t.alike(
updatedSettings,
settings,
'retrieved settings are equivalent to most recently updated'
)
})
Loading