Skip to content

Commit

Permalink
feat: add IndexedDB Store implementation (#120)
Browse files Browse the repository at this point in the history
An IndexedDB store implementation that uses RSA keys for the browser.
  • Loading branch information
Alan Shaw authored Oct 26, 2022
1 parent 653e514 commit 9d73a26
Show file tree
Hide file tree
Showing 9 changed files with 378 additions and 22 deletions.
6 changes: 5 additions & 1 deletion packages/access/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@
"scripts": {
"lint": "tsc && eslint '**/*.{js,ts}' && prettier --check '**/*.{js,ts,yml,json}' --ignore-path ../../.gitignore",
"build": "tsc --build",
"test": "pnpm -r --filter @web3-storage/access-ws run build && mocha 'test/**/*.test.js' -n experimental-vm-modules -n no-warnings",
"test": "pnpm -r --filter @web3-storage/access-ws run build && npm run test:node && npm run test:browser",
"test:node": "mocha 'test/**/!(*.browser).test.js' -n experimental-vm-modules -n no-warnings",
"test:browser": "playwright-test 'test/**/!(*.node).test.js'",
"testw": "watch 'pnpm test' src test --interval 1",
"rc": "npm version prerelease --preid rc"
},
Expand Down Expand Up @@ -72,6 +74,7 @@
"nanoid": "^4.0.0",
"one-webcrypto": "^1.0.3",
"ora": "^6.1.2",
"p-defer": "^4.0.0",
"p-queue": "^7.3.0",
"p-retry": "^5.1.1",
"p-wait-for": "^5.0.0",
Expand All @@ -92,6 +95,7 @@
"hd-scripts": "^3.0.2",
"miniflare": "^2.9.0",
"mocha": "^10.1.0",
"playwright-test": "^8.1.1",
"sade": "^1.7.4",
"typescript": "4.8.4",
"watch": "^1.0.2"
Expand Down
253 changes: 253 additions & 0 deletions packages/access/src/stores/store-indexeddb.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,253 @@
import { importDAG } from '@ucanto/core/delegation'
import * as Signer from '@ucanto/principal/rsa'
import defer from 'p-defer'
import { Delegations } from '../delegations.js'

/**
* @typedef {import('./types').StoreDataKeyRSA} StoreData
* @typedef {import('./types').StoreKeyRSA} Store
*/

const STORE_NAME = 'AccessStore'
const DATA_ID = 1

/**
* Store implementation for the browser.
*
* @implements {Store}
*/
export class StoreIndexedDB {
/** @type {string} */
#dbName

/** @type {number|undefined} */
#dbVersion

/** @type {string} */
#dbStoreName

/** @type {IDBDatabase|undefined} */
#db

/**
* @param {string} dbName
* @param {object} [options]
* @param {number} [options.dbVersion]
* @param {string} [options.dbStoreName]
*/
constructor(dbName, options = {}) {
this.#dbName = dbName
this.#dbVersion = options.dbVersion
this.#dbStoreName = options.dbStoreName ?? STORE_NAME
}

async open() {
/** @type {import('p-defer').DeferredPromise<Store>} */
const { resolve, reject, promise } = defer()
const openReq = indexedDB.open(this.#dbName, this.#dbVersion)

openReq.addEventListener('upgradeneeded', () => {
const db = openReq.result
db.createObjectStore(this.#dbStoreName, { keyPath: 'id' })
})

openReq.addEventListener('success', () => {
this.#db = openReq.result
resolve(this)
})

openReq.addEventListener('error', () => reject(openReq.error))

return promise
}

async close() {
const db = this.#db
if (!db) throw new Error('Store is not open')

db.close()
this.#db = undefined
}

async exists() {
const db = this.#db
if (!db) throw new Error('Store is not open')

const getExists = withObjectStore(
db,
'readonly',
this.#dbStoreName,
async (store) => {
/** @type {import('p-defer').DeferredPromise<boolean>} */
const { resolve, reject, promise } = defer()

const getReq = store.get(DATA_ID)
getReq.addEventListener('success', () =>
resolve(Boolean(getReq.result))
)
getReq.addEventListener('error', () =>
reject(new Error('failed to query DB', { cause: getReq.error }))
)
return promise
}
)

return await getExists()
}

/**
* Creates a new, opened and initialized store.
*
* @param {string} dbName
* @param {object} [options]
* @param {number} [options.dbVersion]
* @param {string} [options.dbStoreName]
*/
static async create(dbName, options) {
const store = new StoreIndexedDB(dbName, options)
await store.open()
await store.init({})
return store
}

/** @type {Store['init']} */
async init(data) {
const principal =
data.principal || (await Signer.generate({ extractable: false }))
const delegations = data.delegations || new Delegations({ principal })
const storeData = {
accounts: data.accounts || [],
meta: data.meta || { name: 'agent', type: 'device' },
principal,
delegations,
}

await this.save(storeData)
return storeData
}

/** @param {StoreData} data */
async save(data) {
const db = this.#db
if (!db) throw new Error('Store is not open')

const putData = withObjectStore(
db,
'readwrite',
this.#dbStoreName,
async (store) => {
/** @type {import('p-defer').DeferredPromise<Store>} */
const { resolve, reject, promise } = defer()

const putReq = store.put({
id: DATA_ID,
accounts: data.accounts.map((a) => a.toArchive()),
delegations: {
created: data.delegations.created.map((d) => [...d.export()]),
received: data.delegations.received.map((d) => [...d.export()]),
meta: [...data.delegations.meta.entries()],
},
meta: data.meta,
principal: data.principal.toArchive(),
})
putReq.addEventListener('success', () => resolve(this))
putReq.addEventListener('error', () =>
reject(new Error('failed to query DB', { cause: putReq.error }))
)

return promise
}
)

return await putData()
}

/** @type {Store['load']} */
async load() {
const db = this.#db
if (!db) throw new Error('Store is not open')

const getData = withObjectStore(
db,
'readonly',
this.#dbStoreName,
async (store) => {
/** @type {import('p-defer').DeferredPromise<StoreData>} */
const { resolve, reject, promise } = defer()

const getReq = store.get(DATA_ID)
getReq.addEventListener('success', () => {
try {
/** @type {import('./types').IDBStoreData} */
const raw = getReq.result
if (!raw) throw new Error('Store is not initialized')

const principal = Signer.from(raw.principal)
const data = {
accounts: raw.accounts.map((a) => Signer.from(a)),
delegations: new Delegations({
principal,
received: raw.delegations.received.map((blocks) =>
importDAG(blocks)
),
created: raw.delegations.created.map((blocks) =>
importDAG(blocks)
),
meta: new Map(raw.delegations.meta),
}),
meta: raw.meta,
principal,
}
resolve(data)
} catch (error) {
reject(error)
}
})
getReq.addEventListener('error', () =>
reject(new Error('failed to query DB', { cause: getReq.error }))
)

return promise
}
)

return await getData()
}

async createAccount() {
return await Signer.generate({ extractable: false })
}
}

/**
* @template T
* @param {IDBDatabase} db
* @param {IDBTransactionMode} txnMode
* @param {string} storeName
* @param {(s: IDBObjectStore) => Promise<T>} fn
* @returns
*/
function withObjectStore(db, txnMode, storeName, fn) {
return async () => {
const tx = db.transaction(storeName, txnMode)
/** @type {import('p-defer').DeferredPromise<T>} */
const { resolve, reject, promise } = defer()
/** @type {T} */
let result
tx.addEventListener('complete', () => resolve(result))
tx.addEventListener('abort', () =>
reject(tx.error || new Error('transaction aborted'))
)
tx.addEventListener('error', () =>
reject(new Error('transaction error', { cause: tx.error }))
)
try {
result = await fn(tx.objectStore(STORE_NAME))
tx.commit()
} catch (error) {
reject(error)
tx.abort()
}
return promise
}
}
17 changes: 17 additions & 0 deletions packages/access/src/stores/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { AgentMeta } from '../types.js'
import { Delegations } from '../delegations.js'
import ed25519 from '@ucanto/principal/ed25519'
import { RSASigner } from '@ucanto/principal/rsa'
import { SignerArchive } from '@ucanto/interface'

export interface DelegationsAsJSON {
created: string
Expand All @@ -27,3 +29,18 @@ export interface Store<T> {

export interface StoreKeyEd extends Store<ed25519.Signer.EdSigner> {}
export interface StoreDataKeyEd extends StoreData<ed25519.Signer.EdSigner> {}

export interface StoreKeyRSA extends Store<RSASigner> {}
export interface StoreDataKeyRSA extends StoreData<RSASigner> {}

export interface IDBStoreData {
id: number
accounts: Array<SignerArchive<RSASigner>>
delegations: {
created: Array<Array<import('@ucanto/interface').Block>>
received: Array<Array<import('@ucanto/interface').Block>>
meta: Array<[string, import('../awake/types').PeerMeta]>
}
meta: import('../types').AgentMeta
principal: SignerArchive<RSASigner>
}
File renamed without changes.
File renamed without changes.
4 changes: 2 additions & 2 deletions packages/access/test/capabilities/store.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ describe('store capabilities', function () {
})

assert.equal(result.error, true)
assert.match(String(result), /violation: 2048 > 1024/)
assert(String(result).includes('violation: 2048 > 1024'))
}
})

Expand Down Expand Up @@ -264,7 +264,7 @@ describe('store capabilities', function () {
})

assert.equal(result.error, true)
assert.match(String(result), /Expected value of type/)
assert(String(result).includes('Expected value of type'))
})
}

Expand Down
Loading

0 comments on commit 9d73a26

Please sign in to comment.