@fireproof/core 0.0.3 → 0.0.5
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.
- package/hooks/use-fireproof.md +149 -0
- package/hooks/use-fireproof.ts +112 -0
- package/package.json +1 -1
- package/src/db-index.js +56 -39
- package/src/fireproof.js +34 -16
- package/src/listener.js +5 -3
- package/src/prolly.js +4 -4
- package/src/valet.js +9 -3
- package/test/fireproof.test.js +62 -1
- package/README.md +0 -148
@@ -0,0 +1,149 @@
|
|
1
|
+
# useFireproof hook for React
|
2
|
+
|
3
|
+
React hook to initialize a Fireproof database, automatically saving and loading the clock.
|
4
|
+
|
5
|
+
The hook takes two optional setup function arguments, `defineDatabaseFn` and `setupDatabaseFn`. See below for examples.
|
6
|
+
|
7
|
+
The return value looks like `{ ready, database, addSubscriber }` where the `database` is your Fireproof instance that you can interact with using `put` and `get`, or via your indexes. The `ready` flag turns true after setup completes, you can use this to activate your UI. The `addSubscriber` function is used to update your app in realtime, see example.
|
8
|
+
|
9
|
+
## Usage Example
|
10
|
+
|
11
|
+
In App.js:
|
12
|
+
|
13
|
+
```js
|
14
|
+
import { FireproofCtx, useFireproof } from '@fireproof/core/hooks/use-fireproof'
|
15
|
+
|
16
|
+
function App() {
|
17
|
+
// establish the Fireproof context value
|
18
|
+
const fpCtxValue = useFireproof()
|
19
|
+
|
20
|
+
// render the rest of the application wrapped in the Fireproof provider
|
21
|
+
return (
|
22
|
+
<FireproofCtx.Provider value={fpCtxValue}>
|
23
|
+
<MyComponent />
|
24
|
+
</FireproofCtx.Provider>
|
25
|
+
)
|
26
|
+
}
|
27
|
+
```
|
28
|
+
|
29
|
+
In your components:
|
30
|
+
|
31
|
+
```js
|
32
|
+
import { FireproofCtx } from '@fireproof/core/hooks/use-fireproof'
|
33
|
+
|
34
|
+
function MyComponent() {
|
35
|
+
// get Fireproof context
|
36
|
+
const { ready, database, addSubscriber } = useContext(FireproofCtx)
|
37
|
+
|
38
|
+
// set a default empty document
|
39
|
+
const [doc, setDoc] = useState({})
|
40
|
+
|
41
|
+
// function to load the document from the database
|
42
|
+
const getDataFn = async () => {
|
43
|
+
setDoc(await database.get("my-doc-id"))
|
44
|
+
}
|
45
|
+
|
46
|
+
// run that function when the database changes
|
47
|
+
addSubscriber('MyComponent', getDataFn)
|
48
|
+
|
49
|
+
// run the loader on first mount
|
50
|
+
useEffect(() => getDataFn(), [])
|
51
|
+
|
52
|
+
// a function to change the value of the document
|
53
|
+
const updateFn = async () => {
|
54
|
+
await database.put({ _id : "my-doc-id", hello: "world", updated_at: new Date()})
|
55
|
+
}
|
56
|
+
|
57
|
+
// render the document with a click handler to update it
|
58
|
+
return <pre onclick={updateFn}>JSON.stringify(doc)</pre>
|
59
|
+
}
|
60
|
+
```
|
61
|
+
|
62
|
+
This should result in a tiny application that updates the document when you click it. In a real appliction you'd probably query an index to present eg. all of the photos in a gallery.
|
63
|
+
|
64
|
+
## Setup Functions
|
65
|
+
|
66
|
+
### defineDatabaseFn
|
67
|
+
|
68
|
+
Synchronous function that defines the database, run this before any async calls. You can use it to do stuff like set up Indexes. Here's an example:
|
69
|
+
|
70
|
+
```js
|
71
|
+
const defineIndexes = (database) => {
|
72
|
+
database.allLists = new Index(database, function (doc, map) {
|
73
|
+
if (doc.type === 'list') map(doc.type, doc)
|
74
|
+
})
|
75
|
+
database.todosByList = new Index(database, function (doc, map) {
|
76
|
+
if (doc.type === 'todo' && doc.listId) {
|
77
|
+
map([doc.listId, doc.createdAt], doc)
|
78
|
+
}
|
79
|
+
})
|
80
|
+
window.fireproof = database // 🤫 for dev
|
81
|
+
return database
|
82
|
+
}
|
83
|
+
```
|
84
|
+
|
85
|
+
### setupDatabaseFn
|
86
|
+
|
87
|
+
Asynchronous function that uses the database when it's ready, run this to load fixture data, insert a dataset from somewhere else, etc. Here's a simple example:
|
88
|
+
|
89
|
+
|
90
|
+
```
|
91
|
+
async function setupDatabase(database)) {
|
92
|
+
const apiData = await (await fetch('https://dummyjson.com/products')).json()
|
93
|
+
for (const product of apiData.products) {
|
94
|
+
await database.put(product)
|
95
|
+
}
|
96
|
+
}
|
97
|
+
```
|
98
|
+
|
99
|
+
Note there are no protections against you running the same thing over and over again, so you probably want to put some logic in there to do the right thing.
|
100
|
+
|
101
|
+
Here is an example of generating deterministic fixtures, using `mulberry32` for determinstic randomness so re-runs give the same CID, avoiding unnecessary bloat at development time, taken from the TodoMVC demo app.
|
102
|
+
|
103
|
+
```js
|
104
|
+
function mulberry32(a) {
|
105
|
+
return function () {
|
106
|
+
let t = (a += 0x6d2b79f5)
|
107
|
+
t = Math.imul(t ^ (t >>> 15), t | 1)
|
108
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
|
109
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
110
|
+
}
|
111
|
+
}
|
112
|
+
const rand = mulberry32(1) // determinstic fixtures
|
113
|
+
|
114
|
+
export default async function loadFixtures(database) {
|
115
|
+
const nextId = (prefix = '') => prefix + rand().toString(32).slice(2)
|
116
|
+
const listTitles = ['Building Apps', 'Having Fun', 'Getting Groceries']
|
117
|
+
const todoTitles = [
|
118
|
+
[
|
119
|
+
'In the browser',
|
120
|
+
'On the phone',
|
121
|
+
'With or without Redux',
|
122
|
+
'Login components',
|
123
|
+
'GraphQL queries',
|
124
|
+
'Automatic replication and versioning',
|
125
|
+
],
|
126
|
+
['Rollerskating meetup', 'Motorcycle ride', 'Write a sci-fi story with ChatGPT'],
|
127
|
+
['Macadamia nut milk', 'Avocado toast', 'Coffee', 'Bacon', 'Sourdough bread', 'Fruit salad'],
|
128
|
+
]
|
129
|
+
let ok
|
130
|
+
for (let j = 0; j < 3; j++) {
|
131
|
+
ok = await database.put({
|
132
|
+
title: listTitles[j],
|
133
|
+
type: 'list',
|
134
|
+
_id: nextId('' + j)
|
135
|
+
})
|
136
|
+
for (let i = 0; i < todoTitles[j].length; i++) {
|
137
|
+
await database.put({
|
138
|
+
_id: nextId(),
|
139
|
+
title: todoTitles[j][i],
|
140
|
+
listId: ok.id,
|
141
|
+
completed: rand() > 0.75,
|
142
|
+
type: 'todo',
|
143
|
+
})
|
144
|
+
}
|
145
|
+
}
|
146
|
+
}
|
147
|
+
```
|
148
|
+
|
149
|
+
|
@@ -0,0 +1,112 @@
|
|
1
|
+
/* global localStorage */
|
2
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
3
|
+
// @ts-ignore
|
4
|
+
import { useEffect, useState, createContext } from 'react'
|
5
|
+
import { Fireproof, Listener } from '../index'
|
6
|
+
|
7
|
+
export interface FireproofCtxValue {
|
8
|
+
addSubscriber: (label: String, fn: Function) => void
|
9
|
+
database: Fireproof
|
10
|
+
ready: boolean
|
11
|
+
}
|
12
|
+
export const FireproofCtx = createContext<FireproofCtxValue>({
|
13
|
+
addSubscriber: () => {},
|
14
|
+
database: null,
|
15
|
+
ready: false,
|
16
|
+
})
|
17
|
+
|
18
|
+
|
19
|
+
|
20
|
+
const inboundSubscriberQueue = new Map()
|
21
|
+
const database = Fireproof.storage()
|
22
|
+
const listener = new Listener(database)
|
23
|
+
|
24
|
+
/**
|
25
|
+
* @function useFireproof
|
26
|
+
* React hook to initialize a Fireproof database, automatically saving and loading the clock.
|
27
|
+
* @param [defineDatabaseFn] Synchronous function that defines the database, run this before any async calls
|
28
|
+
* @param [setupDatabaseFn] Asynchronous function that sets up the database, run this to load fixture data etc
|
29
|
+
* @returns {FireproofCtxValue} { addSubscriber, database, ready }
|
30
|
+
*/
|
31
|
+
export function useFireproof(defineDatabaseFn: Function, setupDatabaseFn: Function): FireproofCtxValue {
|
32
|
+
const [ready, setReady] = useState(false)
|
33
|
+
defineDatabaseFn = defineDatabaseFn || (() => {})
|
34
|
+
setupDatabaseFn = setupDatabaseFn || (() => {})
|
35
|
+
|
36
|
+
if (!ready) {
|
37
|
+
defineDatabaseFn(database)
|
38
|
+
}
|
39
|
+
|
40
|
+
const addSubscriber = (label: String, fn: Function) => {
|
41
|
+
inboundSubscriberQueue.set(label, fn)
|
42
|
+
}
|
43
|
+
|
44
|
+
const listenerCallback = async () => {
|
45
|
+
localSet('fireproof', JSON.stringify(database))
|
46
|
+
for (const [, fn] of inboundSubscriberQueue) fn()
|
47
|
+
}
|
48
|
+
|
49
|
+
useEffect(() => {
|
50
|
+
const doSetup = async () => {
|
51
|
+
if (ready) return
|
52
|
+
const fp = localGet('fireproof')
|
53
|
+
if (fp) {
|
54
|
+
const { clock } = JSON.parse(fp)
|
55
|
+
console.log("Loading previous database clock. (localStorage.removeItem('fireproof') to reset)")
|
56
|
+
await database.setClock(clock)
|
57
|
+
try {
|
58
|
+
await database.changesSince()
|
59
|
+
} catch (e) {
|
60
|
+
console.error('Error loading previous database clock.', e)
|
61
|
+
await database.setClock([])
|
62
|
+
await setupDatabaseFn(database)
|
63
|
+
localSet('fireproof', JSON.stringify(database))
|
64
|
+
}
|
65
|
+
} else {
|
66
|
+
await setupDatabaseFn(database)
|
67
|
+
localSet('fireproof', JSON.stringify(database))
|
68
|
+
}
|
69
|
+
setReady(true)
|
70
|
+
listener.on('*', hushed('*', listenerCallback, 250))
|
71
|
+
}
|
72
|
+
doSetup()
|
73
|
+
}, [ready])
|
74
|
+
|
75
|
+
return {
|
76
|
+
addSubscriber,
|
77
|
+
database,
|
78
|
+
ready,
|
79
|
+
}
|
80
|
+
}
|
81
|
+
|
82
|
+
const husherMap = new Map()
|
83
|
+
const husher = (id: string, workFn: { (): Promise<any> }, ms: number) => {
|
84
|
+
if (!husherMap.has(id)) {
|
85
|
+
husherMap.set(
|
86
|
+
id,
|
87
|
+
workFn().finally(() => setTimeout(() => husherMap.delete(id), ms))
|
88
|
+
)
|
89
|
+
}
|
90
|
+
return husherMap.get(id)
|
91
|
+
}
|
92
|
+
const hushed = (id: string, workFn: { (): Promise<any> }, ms: number) => () => husher(id, workFn, ms)
|
93
|
+
|
94
|
+
let storageSupported = false
|
95
|
+
try {
|
96
|
+
storageSupported = window.localStorage && true
|
97
|
+
} catch (e) {}
|
98
|
+
export function localGet(key: string) {
|
99
|
+
if (storageSupported) {
|
100
|
+
return localStorage && localStorage.getItem(key)
|
101
|
+
}
|
102
|
+
}
|
103
|
+
function localSet(key: string, value: string) {
|
104
|
+
if (storageSupported) {
|
105
|
+
return localStorage && localStorage.setItem(key, value)
|
106
|
+
}
|
107
|
+
}
|
108
|
+
// function localRemove(key) {
|
109
|
+
// if (storageSupported) {
|
110
|
+
// return localStorage && localStorage.removeItem(key)
|
111
|
+
// }
|
112
|
+
// }
|
package/package.json
CHANGED
package/src/db-index.js
CHANGED
@@ -1,14 +1,30 @@
|
|
1
1
|
import { create, load } from 'prolly-trees/db-index'
|
2
2
|
import { sha256 as hasher } from 'multiformats/hashes/sha2'
|
3
3
|
import { nocache as cache } from 'prolly-trees/cache'
|
4
|
-
import { bf, simpleCompare
|
4
|
+
import { bf, simpleCompare } from 'prolly-trees/utils'
|
5
5
|
import * as codec from '@ipld/dag-cbor'
|
6
6
|
import { create as createBlock } from 'multiformats/block'
|
7
7
|
import { doTransaction } from './blockstore.js'
|
8
8
|
import charwise from 'charwise'
|
9
|
-
const opts = { cache, chunker: bf(3), codec, hasher, compare }
|
10
9
|
|
11
|
-
const
|
10
|
+
const arrayCompare = (a, b) => {
|
11
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
12
|
+
const len = Math.min(a.length, b.length)
|
13
|
+
for (let i = 0; i < len; i++) {
|
14
|
+
const comp = simpleCompare(a[i], b[i])
|
15
|
+
if (comp !== 0) {
|
16
|
+
return comp
|
17
|
+
}
|
18
|
+
}
|
19
|
+
return simpleCompare(a.length, b.length)
|
20
|
+
} else {
|
21
|
+
return simpleCompare(a, b)
|
22
|
+
}
|
23
|
+
}
|
24
|
+
|
25
|
+
const opts = { cache, chunker: bf(3), codec, hasher, compare: arrayCompare }
|
26
|
+
|
27
|
+
const ALWAYS_REBUILD = false // todo: remove this
|
12
28
|
|
13
29
|
const makeGetBlock = (blocks) => async (address) => {
|
14
30
|
const { cid, bytes } = await blocks.get(address)
|
@@ -16,13 +32,32 @@ const makeGetBlock = (blocks) => async (address) => {
|
|
16
32
|
}
|
17
33
|
const makeDoc = ({ key, value }) => ({ _id: key, ...value })
|
18
34
|
|
35
|
+
/**
|
36
|
+
* JDoc for the result row type.
|
37
|
+
* @typedef {Object} ChangeEvent
|
38
|
+
* @property {string} key - The key of the document.
|
39
|
+
* @property {Object} value - The new value of the document.
|
40
|
+
* @property {boolean} [del] - Is the row deleted?
|
41
|
+
* @memberof DbIndex
|
42
|
+
*/
|
43
|
+
|
44
|
+
/**
|
45
|
+
* JDoc for the result row type.
|
46
|
+
* @typedef {Object} DbIndexEntry
|
47
|
+
* @property {string[]} key - The key for the DbIndex entry.
|
48
|
+
* @property {Object} value - The value of the document.
|
49
|
+
* @property {boolean} [del] - Is the row deleted?
|
50
|
+
* @memberof DbIndex
|
51
|
+
*/
|
52
|
+
|
19
53
|
/**
|
20
54
|
* Transforms a set of changes to DbIndex entries using a map function.
|
21
55
|
*
|
22
|
-
* @param {
|
56
|
+
* @param {ChangeEvent[]} changes
|
23
57
|
* @param {Function} mapFun
|
24
|
-
* @returns {
|
58
|
+
* @returns {DbIndexEntry[]} The DbIndex entries generated by the map function.
|
25
59
|
* @private
|
60
|
+
* @memberof DbIndex
|
26
61
|
*/
|
27
62
|
const indexEntriesForChanges = (changes, mapFun) => {
|
28
63
|
const indexEntries = []
|
@@ -41,7 +76,7 @@ const indexEntriesForChanges = (changes, mapFun) => {
|
|
41
76
|
const indexEntriesForOldChanges = async (blocks, byIDindexRoot, ids, mapFun) => {
|
42
77
|
const getBlock = makeGetBlock(blocks)
|
43
78
|
const byIDindex = await load({ cid: byIDindexRoot.cid, get: getBlock, ...opts })
|
44
|
-
|
79
|
+
|
45
80
|
const result = await byIDindex.getMany(ids)
|
46
81
|
return result.result
|
47
82
|
}
|
@@ -52,7 +87,7 @@ const indexEntriesForOldChanges = async (blocks, byIDindexRoot, ids, mapFun) =>
|
|
52
87
|
* @class DbIndex
|
53
88
|
* @classdesc An DbIndex can be used to order and filter the documents in a Fireproof database.
|
54
89
|
*
|
55
|
-
* @param {
|
90
|
+
* @param {Fireproof} database - The Fireproof database instance to DbIndex.
|
56
91
|
* @param {Function} mapFun - The map function to apply to each entry in the database.
|
57
92
|
*
|
58
93
|
*/
|
@@ -60,7 +95,7 @@ export default class DbIndex {
|
|
60
95
|
constructor (database, mapFun) {
|
61
96
|
/**
|
62
97
|
* The database instance to DbIndex.
|
63
|
-
* @type {
|
98
|
+
* @type {Fireproof}
|
64
99
|
*/
|
65
100
|
this.database = database
|
66
101
|
/**
|
@@ -73,9 +108,16 @@ export default class DbIndex {
|
|
73
108
|
this.dbHead = null
|
74
109
|
}
|
75
110
|
|
111
|
+
/**
|
112
|
+
* JSDoc for Query type.
|
113
|
+
* @typedef {Object} DbQuery
|
114
|
+
* @property {string[]} [range] - The range to query.
|
115
|
+
* @memberof DbIndex
|
116
|
+
*/
|
117
|
+
|
76
118
|
/**
|
77
119
|
* Query object can have {range}
|
78
|
-
* @param {
|
120
|
+
* @param {DbQuery} query - the query range to use
|
79
121
|
* @param {CID} [root] - an optional root to query a snapshot
|
80
122
|
* @returns {Promise<{rows: Array<{id: string, key: string, value: any}>}>}
|
81
123
|
* @memberof DbIndex
|
@@ -116,26 +158,16 @@ export default class DbIndex {
|
|
116
158
|
result.rows.map(({ key }) => key),
|
117
159
|
this.mapFun
|
118
160
|
)
|
119
|
-
)
|
120
|
-
// .map((key) => ({ key, value: null })) // tombstone just adds more rows...
|
121
|
-
.map((key) => ({ key, del: true })) // should be this
|
122
|
-
// .map((key) => ({ key: undefined, del: true })) // todo why does this work?
|
123
|
-
|
161
|
+
).map((key) => ({ key, del: true })) // should be this
|
124
162
|
this.indexRoot = await bulkIndex(blocks, this.indexRoot, oldIndexEntries, opts)
|
125
|
-
|
126
|
-
|
127
|
-
// [ { key: [ 5, 'x' ], del: true } ]
|
128
|
-
// for now we just let the by id DbIndex grow and then don't use the results...
|
129
|
-
// const removeByIdIndexEntries = oldIndexEntries.map(({ key }) => ({ key: key[1], del: true }))
|
130
|
-
// this.byIDindexRoot = await bulkIndex(blocks, this.byIDindexRoot, removeByIdIndexEntries, opts)
|
163
|
+
const removeByIdIndexEntries = oldIndexEntries.map(({ key }) => ({ key: key[1], del: true }))
|
164
|
+
this.byIDindexRoot = await bulkIndex(blocks, this.byIDindexRoot, removeByIdIndexEntries, opts)
|
131
165
|
}
|
132
166
|
const indexEntries = indexEntriesForChanges(result.rows, this.mapFun)
|
133
167
|
const byIdIndexEntries = indexEntries.map(({ key }) => ({ key: key[1], value: key }))
|
134
|
-
// [{key: 'xxxx-3c3a-4b5e-9c1c-8c5c0c5c0c5c', value : [ 53, 'xxxx-3c3a-4b5e-9c1c-8c5c0c5c0c5c' ]}]
|
135
168
|
this.byIDindexRoot = await bulkIndex(blocks, this.byIDindexRoot, byIdIndexEntries, opts)
|
136
169
|
// console.log('indexEntries', indexEntries)
|
137
170
|
this.indexRoot = await bulkIndex(blocks, this.indexRoot, indexEntries, opts)
|
138
|
-
// console.log('did DbIndex', this.indexRoot)
|
139
171
|
this.dbHead = result.clock
|
140
172
|
}
|
141
173
|
|
@@ -148,8 +180,8 @@ export default class DbIndex {
|
|
148
180
|
/**
|
149
181
|
* Update the DbIndex with the given entries
|
150
182
|
* @param {Blockstore} blocks
|
151
|
-
* @param {
|
152
|
-
* @param {
|
183
|
+
* @param {Block} inRoot
|
184
|
+
* @param {DbIndexEntry[]} indexEntries
|
153
185
|
* @private
|
154
186
|
*/
|
155
187
|
async function bulkIndex (blocks, inRoot, indexEntries) {
|
@@ -157,37 +189,22 @@ async function bulkIndex (blocks, inRoot, indexEntries) {
|
|
157
189
|
const putBlock = blocks.put.bind(blocks)
|
158
190
|
const getBlock = makeGetBlock(blocks)
|
159
191
|
if (!inRoot) {
|
160
|
-
// make a new DbIndex
|
161
|
-
|
162
192
|
for await (const node of await create({ get: getBlock, list: indexEntries, ...opts })) {
|
163
193
|
const block = await node.block
|
164
194
|
await putBlock(block.cid, block.bytes)
|
165
195
|
inRoot = block
|
166
196
|
}
|
167
|
-
// console.x('created DbIndex', inRoot.cid)
|
168
197
|
return inRoot
|
169
198
|
} else {
|
170
|
-
// load existing DbIndex
|
171
|
-
// console.x('loading DbIndex', inRoot.cid)
|
172
199
|
const DbIndex = await load({ cid: inRoot.cid, get: getBlock, ...opts })
|
173
|
-
// console.log('new indexEntries', indexEntries)
|
174
200
|
const { root, blocks } = await DbIndex.bulk(indexEntries)
|
175
201
|
for await (const block of blocks) {
|
176
202
|
await putBlock(block.cid, block.bytes)
|
177
203
|
}
|
178
|
-
// console.x('updated DbIndex', root.block.cid)
|
179
204
|
return await root.block // if we hold the root we won't have to load every time
|
180
205
|
}
|
181
206
|
}
|
182
207
|
|
183
|
-
/**
|
184
|
-
* Query the DbIndex for the given range
|
185
|
-
* @param {Blockstore} blocks
|
186
|
-
* @param {import('multiformats/block').Block} inRoot
|
187
|
-
* @param {import('prolly-trees/db-DbIndex').Query} query
|
188
|
-
* @returns {Promise<import('prolly-trees/db-DbIndex').QueryResult>}
|
189
|
-
* @private
|
190
|
-
**/
|
191
208
|
async function doIndexQuery (blocks, root, query) {
|
192
209
|
const cid = root && root.cid
|
193
210
|
if (!cid) return { result: [] }
|
package/src/fireproof.js
CHANGED
@@ -161,7 +161,7 @@ export default class Fireproof {
|
|
161
161
|
async put ({ _id, ...doc }) {
|
162
162
|
const id = _id || 'f' + Math.random().toString(36).slice(2)
|
163
163
|
await this.#runValidation({ _id: id, ...doc })
|
164
|
-
return await this.#putToProllyTree({ key: id, value: doc })
|
164
|
+
return await this.#putToProllyTree({ key: id, value: doc }, doc._clock)
|
165
165
|
}
|
166
166
|
|
167
167
|
/**
|
@@ -171,20 +171,37 @@ export default class Fireproof {
|
|
171
171
|
* @memberof Fireproof
|
172
172
|
* @instance
|
173
173
|
*/
|
174
|
-
async del (
|
174
|
+
async del (docOrId) {
|
175
|
+
let id
|
176
|
+
let clock = null
|
177
|
+
if (docOrId._id) {
|
178
|
+
id = docOrId._id
|
179
|
+
clock = docOrId._clock
|
180
|
+
} else {
|
181
|
+
id = docOrId
|
182
|
+
}
|
175
183
|
await this.#runValidation({ _id: id, _deleted: true })
|
176
184
|
// return await this.#putToProllyTree({ key: id, del: true }) // not working at prolly tree layer?
|
177
185
|
// this tombstone is temporary until we can get the prolly tree to delete
|
178
|
-
return await this.#putToProllyTree({ key: id, value: null })
|
186
|
+
return await this.#putToProllyTree({ key: id, value: null }, clock)
|
179
187
|
}
|
180
188
|
|
181
189
|
/**
|
182
190
|
* Updates the underlying storage with the specified event.
|
183
191
|
* @private
|
184
|
-
* @param {
|
185
|
-
* @returns {Object<{ id: string, clock:
|
192
|
+
* @param {Object<{key : string, value: any}>} event - the event to add
|
193
|
+
* @returns {Object<{ id: string, clock: CID[] }>} - The result of adding the event to storage
|
186
194
|
*/
|
187
|
-
async #putToProllyTree (event) {
|
195
|
+
async #putToProllyTree (event, clock = null) {
|
196
|
+
if (clock && JSON.stringify(clock) !== JSON.stringify(this.clock)) {
|
197
|
+
// we need to check and see what version of the document exists at the clock specified
|
198
|
+
// if it is the same as the one we are trying to put, then we can proceed
|
199
|
+
const resp = await eventsSince(this.blocks, this.clock, event.value._clock)
|
200
|
+
const missedChange = resp.find(({ key }) => key === event.key)
|
201
|
+
if (missedChange) {
|
202
|
+
throw new Error('MVCC conflict, document is changed, please reload the document and try again.')
|
203
|
+
}
|
204
|
+
}
|
188
205
|
const result = await doTransaction(
|
189
206
|
'#putToProllyTree',
|
190
207
|
this.blocks,
|
@@ -203,8 +220,6 @@ export default class Fireproof {
|
|
203
220
|
// /**
|
204
221
|
// * Advances the clock to the specified event and updates the root CID
|
205
222
|
// * Will be used by replication
|
206
|
-
// * @param {import('../clock').EventLink<import('../crdt').EventData>} event - the event to advance to
|
207
|
-
// * @returns {import('../clock').EventLink<import('../crdt').EventData>[]} - the new clock after advancing
|
208
223
|
// */
|
209
224
|
// async advance (event) {
|
210
225
|
// this.clock = await advance(this.blocks, this.clock, event)
|
@@ -230,16 +245,25 @@ export default class Fireproof {
|
|
230
245
|
* Retrieves the document with the specified ID from the database
|
231
246
|
*
|
232
247
|
* @param {string} key - the ID of the document to retrieve
|
248
|
+
* @param {Object} [opts] - options
|
233
249
|
* @returns {Object<{_id: string, ...doc: Object}>} - the document with the specified ID
|
234
250
|
* @memberof Fireproof
|
235
251
|
* @instance
|
236
252
|
*/
|
237
|
-
async get (key) {
|
238
|
-
|
253
|
+
async get (key, opts = {}) {
|
254
|
+
let got
|
255
|
+
if (opts.clock) {
|
256
|
+
got = await get(this.blocks, opts.clock, key)
|
257
|
+
} else {
|
258
|
+
got = await get(this.blocks, this.clock, key)
|
259
|
+
}
|
239
260
|
// this tombstone is temporary until we can get the prolly tree to delete
|
240
261
|
if (got === null) {
|
241
262
|
throw new Error('Not found')
|
242
263
|
}
|
264
|
+
if (opts.mvcc === true) {
|
265
|
+
got._clock = this.clock
|
266
|
+
}
|
243
267
|
got._id = key
|
244
268
|
return got
|
245
269
|
}
|
@@ -250,12 +274,6 @@ export default class Fireproof {
|
|
250
274
|
this.blocks.valet.uploadFunction = carUploaderFn
|
251
275
|
}
|
252
276
|
|
253
|
-
/**
|
254
|
-
* Sets the function that will be used to read blocks from a remote peer.
|
255
|
-
* @param {Function} remoteBlockReaderFn - the function that will be used to read blocks from a remote peer
|
256
|
-
* @memberof Fireproof
|
257
|
-
* @instance
|
258
|
-
*/
|
259
277
|
setRemoteBlockReader (remoteBlockReaderFn) {
|
260
278
|
// console.log('registering remote block reader')
|
261
279
|
this.blocks.valet.remoteBlockFunction = remoteBlockReaderFn
|
package/src/listener.js
CHANGED
@@ -4,9 +4,11 @@
|
|
4
4
|
* @class Listener
|
5
5
|
* @classdesc An listener attaches to a Fireproof database and runs a routing function on each change, sending the results to subscribers.
|
6
6
|
*
|
7
|
-
* @param {
|
7
|
+
* @param {Fireproof} database - The Fireproof database instance to index.
|
8
8
|
* @param {Function} routingFn - The routing function to apply to each entry in the database.
|
9
9
|
*/
|
10
|
+
// import { ChangeEvent } from './db-index'
|
11
|
+
|
10
12
|
export default class Listener {
|
11
13
|
#subcribers = new Map()
|
12
14
|
|
@@ -20,7 +22,7 @@ export default class Listener {
|
|
20
22
|
constructor (database, routingFn) {
|
21
23
|
/** routingFn
|
22
24
|
* The database instance to index.
|
23
|
-
* @type {
|
25
|
+
* @type {Fireproof}
|
24
26
|
*/
|
25
27
|
this.database = database
|
26
28
|
this.#doStopListening = database.registerListener((changes) => this.#onChanges(changes))
|
@@ -94,7 +96,7 @@ const makeDoc = ({ key, value }) => ({ _id: key, ...value })
|
|
94
96
|
/**
|
95
97
|
* Transforms a set of changes to events using an emitter function.
|
96
98
|
*
|
97
|
-
* @param {
|
99
|
+
* @param {ChangeEvent[]} changes
|
98
100
|
* @param {Function} routingFn
|
99
101
|
* @returns {Array<string>} The topics emmitted by the event function.
|
100
102
|
* @private
|
package/src/prolly.js
CHANGED
@@ -35,15 +35,15 @@ const makeGetBlock = (blocks) => async (address) => {
|
|
35
35
|
* @param {Function} bigPut - A function that puts a block.
|
36
36
|
* @param {import('prolly-trees/map').Root} root - The root node.
|
37
37
|
* @param {Object<{ key: string, value: any, del: boolean }>} event - The update event.
|
38
|
-
* @param {
|
38
|
+
* @param {CID[]} head - The head of the event chain.
|
39
39
|
* @param {Array<import('multiformats/block').Block>} additions - A array of additions.
|
40
40
|
* @param {Array<mport('multiformats/block').Block>>} removals - An array of removals.
|
41
41
|
* @returns {Promise<{
|
42
42
|
* root: import('prolly-trees/map').Root,
|
43
43
|
* additions: Map<string, import('multiformats/block').Block>,
|
44
44
|
* removals: Array<string>,
|
45
|
-
* head:
|
46
|
-
* event:
|
45
|
+
* head: CID[],
|
46
|
+
* event: CID[]
|
47
47
|
* }>}
|
48
48
|
*/
|
49
49
|
async function createAndSaveNewEvent (
|
@@ -135,7 +135,7 @@ const prollyRootFromAncestor = async (events, ancestor, getBlock) => {
|
|
135
135
|
* @param {import('./block').BlockFetcher} blocks Bucket block storage.
|
136
136
|
* @param {import('./clock').EventLink<EventData>[]} head Merkle clock head.
|
137
137
|
* @param {string} key The key of the value to put.
|
138
|
-
* @param {
|
138
|
+
* @param {CID} value The value to put.
|
139
139
|
* @param {object} [options]
|
140
140
|
* @returns {Promise<Result>}
|
141
141
|
*/
|
package/src/valet.js
CHANGED
@@ -32,9 +32,15 @@ export default class Valet {
|
|
32
32
|
)
|
33
33
|
if (this.uploadFunction) {
|
34
34
|
// todo we can coalesce these into a single car file
|
35
|
-
|
36
|
-
|
37
|
-
|
35
|
+
return await this.withDB(async (db) => {
|
36
|
+
for (const task of tasks) {
|
37
|
+
await this.uploadFunction(task.carCid, task.value)
|
38
|
+
// update the indexedb to mark this car as no longer pending
|
39
|
+
const carMeta = await db.get('cidToCar', task.carCid)
|
40
|
+
delete carMeta.pending
|
41
|
+
await db.put('cidToCar', carMeta)
|
42
|
+
}
|
43
|
+
})
|
38
44
|
}
|
39
45
|
callback()
|
40
46
|
})
|
package/test/fireproof.test.js
CHANGED
@@ -20,12 +20,73 @@ describe('Fireproof', () => {
|
|
20
20
|
it('put and get document', async () => {
|
21
21
|
assert(resp0.id, 'should have id')
|
22
22
|
assert.equal(resp0.id, '1ef3b32a-3c3a-4b5e-9c1c-8c5c0c5c0c5c')
|
23
|
-
|
24
23
|
const avalue = await database.get('1ef3b32a-3c3a-4b5e-9c1c-8c5c0c5c0c5c')
|
25
24
|
assert.equal(avalue.name, 'alice')
|
26
25
|
assert.equal(avalue.age, 42)
|
27
26
|
assert.equal(avalue._id, '1ef3b32a-3c3a-4b5e-9c1c-8c5c0c5c0c5c')
|
28
27
|
})
|
28
|
+
it('mvcc put and get document with _clock that matches', async () => {
|
29
|
+
assert(resp0.clock, 'should have clock')
|
30
|
+
assert.equal(resp0.clock[0].toString(), 'bafyreieth2ckopwivda5mf6vu76xwqvox3q5wsaxgbmxy2dgrd4hfuzmma')
|
31
|
+
const theDoc = await database.get('1ef3b32a-3c3a-4b5e-9c1c-8c5c0c5c0c5c')
|
32
|
+
theDoc._clock = database.clock
|
33
|
+
const put2 = await database.put(theDoc)
|
34
|
+
assert.equal(put2.id, '1ef3b32a-3c3a-4b5e-9c1c-8c5c0c5c0c5c')
|
35
|
+
assert.equal(put2.clock[0].toString(), 'bafyreida2c2ckhjfoz5ulmbbfe66ey4svvedrl4tzbvtoxags2qck7lj2i')
|
36
|
+
})
|
37
|
+
it('get should return an object instance that is not the same as the one in the db', async () => {
|
38
|
+
const theDoc = await database.get('1ef3b32a-3c3a-4b5e-9c1c-8c5c0c5c0c5c')
|
39
|
+
const theDoc2 = await database.get('1ef3b32a-3c3a-4b5e-9c1c-8c5c0c5c0c5c')
|
40
|
+
assert.notEqual(theDoc, theDoc2)
|
41
|
+
theDoc.name = 'really alice'
|
42
|
+
assert.equal(theDoc.name, 'really alice')
|
43
|
+
assert.equal(theDoc2.name, 'alice')
|
44
|
+
})
|
45
|
+
it('get with mvcc option', async () => {
|
46
|
+
const theDoc = await database.get('1ef3b32a-3c3a-4b5e-9c1c-8c5c0c5c0c5c', { mvcc: true })
|
47
|
+
assert(theDoc._clock, 'should have _clock')
|
48
|
+
assert.equal(theDoc._clock[0].toString(), 'bafyreieth2ckopwivda5mf6vu76xwqvox3q5wsaxgbmxy2dgrd4hfuzmma')
|
49
|
+
})
|
50
|
+
it('get from an old snapshot with mvcc option', async () => {
|
51
|
+
const ogClock = resp0.clock
|
52
|
+
const theDoc = await database.get('1ef3b32a-3c3a-4b5e-9c1c-8c5c0c5c0c5c')
|
53
|
+
theDoc.name = 'not alice'
|
54
|
+
const put2 = await database.put(theDoc)
|
55
|
+
assert.equal(put2.id, '1ef3b32a-3c3a-4b5e-9c1c-8c5c0c5c0c5c')
|
56
|
+
assert.notEqual(put2.clock.toString(), ogClock.toString())
|
57
|
+
const theDoc2 = await database.get('1ef3b32a-3c3a-4b5e-9c1c-8c5c0c5c0c5c', { clock: ogClock })
|
58
|
+
assert.equal(theDoc2.name, 'alice')
|
59
|
+
})
|
60
|
+
it('put and get document with _clock that does not match b/c the doc changed', async () => {
|
61
|
+
const theDoc = await database.get('1ef3b32a-3c3a-4b5e-9c1c-8c5c0c5c0c5c', { mvcc: true })
|
62
|
+
theDoc.name = 'not alice'
|
63
|
+
const put2 = await database.put(theDoc)
|
64
|
+
assert.equal(put2.id, '1ef3b32a-3c3a-4b5e-9c1c-8c5c0c5c0c5c')
|
65
|
+
assert.notEqual(put2.clock.toString(), theDoc._clock.toString())
|
66
|
+
|
67
|
+
const err = await database.put(theDoc).catch((err) => err)
|
68
|
+
assert.match(err.message, /MVCC conflict/)
|
69
|
+
})
|
70
|
+
it('put and get document with _clock that does not match b/c a different doc changed should succeed', async () => {
|
71
|
+
const theDoc = await database.get('1ef3b32a-3c3a-4b5e-9c1c-8c5c0c5c0c5c', { mvcc: true })
|
72
|
+
assert.equal(theDoc.name, 'alice')
|
73
|
+
|
74
|
+
const putAnotherDoc = await database.put({ nothing: 'to see here' })
|
75
|
+
assert.notEqual(putAnotherDoc.clock.toString(), theDoc._clock.toString())
|
76
|
+
|
77
|
+
const ok = await database.put({ name: "isn't alice", ...theDoc })
|
78
|
+
assert.equal(ok.id, '1ef3b32a-3c3a-4b5e-9c1c-8c5c0c5c0c5c')
|
79
|
+
})
|
80
|
+
it('put and get document with _clock that does not match b/c the doc was deleted', async () => {
|
81
|
+
const theDoc = await database.get('1ef3b32a-3c3a-4b5e-9c1c-8c5c0c5c0c5c', { mvcc: true })
|
82
|
+
assert.equal(theDoc.name, 'alice')
|
83
|
+
const del = await database.del(theDoc)
|
84
|
+
assert(del.id)
|
85
|
+
const err = await database.put(theDoc).catch((err) => err)
|
86
|
+
console.log('err', err)
|
87
|
+
assert.match(err.message, /MVCC conflict/)
|
88
|
+
})
|
89
|
+
|
29
90
|
it('has a factory for making new instances with default settings', async () => {
|
30
91
|
// TODO if you pass it an email it asks the local keyring, and if no key, does the email validation thing
|
31
92
|
const db = await Fireproof.storage({ email: 'jchris@gmail.com' })
|
package/README.md
DELETED
@@ -1,148 +0,0 @@
|
|
1
|
-
# 🔥 Fireproof
|
2
|
-
|
3
|
-
Fireproof is a realtime database for today's interactive applications. It uses immutable data and distributed protocols
|
4
|
-
to offer a new kind of database that:
|
5
|
-
- can be embedded in any page or app, with a flexible data ownership model
|
6
|
-
- scales without incurring developer costs, thanks to Filecoin
|
7
|
-
- uses cryptographically verifiable protocols (what plants crave)
|
8
|
-
|
9
|
-
Learn more about the concepts and architecture behind Fireproof [in our plan,](https://hackmd.io/@j-chris/SyoE-Plpj) or jump to the [quick start](#quick-start) for React and server-side examples.
|
10
|
-
|
11
|
-
### Status
|
12
|
-
|
13
|
-
Fireproof is alpha software, you should only use it if you are planning to contribute. For now, [check out our React TodoMVC implementation running in browser-local mode.](https://main--lucky-naiad-5aa507.netlify.app/) It demonstrates document persistence, index queries, and event subscriptions, and uses the [`useFireproof()` React hook.](https://github.com/jchris/fireproof/blob/main/examples/todomvc/src/hooks/useFireproof.js)
|
14
|
-
|
15
|
-
[](https://github.com/jchris/fireproof/actions/workflows/test.yml)
|
16
|
-
[](https://standardjs.com)
|
17
|
-
|
18
|
-
## Usage
|
19
|
-
|
20
|
-
```js
|
21
|
-
import Fireproof from 'fireproof';
|
22
|
-
|
23
|
-
async function main() {
|
24
|
-
const database = new Fireproof();
|
25
|
-
const ok = await database.put({
|
26
|
-
name: 'alice',
|
27
|
-
age: 42
|
28
|
-
});
|
29
|
-
|
30
|
-
const doc = await database.get(ok.id);
|
31
|
-
console.log(doc.name); // 'alice'
|
32
|
-
}
|
33
|
-
|
34
|
-
main();
|
35
|
-
```
|
36
|
-
|
37
|
-
## Features
|
38
|
-
|
39
|
-
### Document Store
|
40
|
-
|
41
|
-
A simple put, get, and delete interface for keeping track of all your JSON documents. Once your data is in Fireproof you can access it from any app or website. Fireproof document store uses MVCC versioning and Merkle clocks so you can always recover the version you are looking for.
|
42
|
-
|
43
|
-
```js
|
44
|
-
const { id, ref } = await database.put({
|
45
|
-
_id: 'three-thousand'
|
46
|
-
name: 'André',
|
47
|
-
age: 47
|
48
|
-
});
|
49
|
-
const doc = await database.get('three-thousand')
|
50
|
-
// {
|
51
|
-
// _id : 'three-thousand'
|
52
|
-
// _ref : CID(bafy84...agfw7)
|
53
|
-
// name : 'André',
|
54
|
-
// age : 47
|
55
|
-
// }
|
56
|
-
```
|
57
|
-
|
58
|
-
The `_ref` allows you to query a stable snapshot of that version of the database. Fireproof uses immutable data structures under the hood, so you can always rollback to old data. Files can be embedded anywhere in your document using IPFS links like `{"/":"bafybeih3e3zdiehbqfpxzpppxrb6kaaw4xkbqzyr2f5pwr5refq2te2ape"}`, with API sugar coming soon.
|
59
|
-
|
60
|
-
### Flexible Indexes
|
61
|
-
|
62
|
-
Fireproof indexes are defined by custom JavaScript functions that you write, allowing you to easily index and search your data in the way that works best for your application. Easily handle data variety and schema drift by normalizing any data to the desired index.
|
63
|
-
|
64
|
-
```js
|
65
|
-
const index = new Index(database, function (doc, map) {
|
66
|
-
map(doc.age, doc.name)
|
67
|
-
})
|
68
|
-
const { rows, ref } = await index.query({ range: [40, 52] })
|
69
|
-
// [ { key: 42, value: 'alice', id: 'a1s3b32a-3c3a-4b5e-9c1c-8c5c0c5c0c5c' },
|
70
|
-
// { key: 47, value: 'André', id: 'three-thousand' } ]
|
71
|
-
```
|
72
|
-
|
73
|
-
### Realtime Updates
|
74
|
-
|
75
|
-
Subscribe to query changes in your application, so your UI updates automatically. Use the supplied React hooks, our Redux connector, or simple function calls to be notified of relevant changes.
|
76
|
-
|
77
|
-
```js
|
78
|
-
const listener = new Listener(database, function(doc, emit) {
|
79
|
-
if (doc.type == 'member') {
|
80
|
-
emit('member')
|
81
|
-
}
|
82
|
-
})
|
83
|
-
listener.on('member', (id) => {
|
84
|
-
const doc = await db.get(id)
|
85
|
-
alert(`Member update ${doc.name}`)
|
86
|
-
})
|
87
|
-
```
|
88
|
-
|
89
|
-
### Self-soverign Identity
|
90
|
-
|
91
|
-
Fireproof is so easy to integrate with any site or app because you can get started right away, and set up an account later. By default users write to their own database copy, so you can get pretty far before you even have to think about API keys. [Authorization is via non-extractable keypair](https://ucan.xyz), like TouchID / FaceID.
|
92
|
-
|
93
|
-
### Automatic Replication
|
94
|
-
|
95
|
-
Documents changes are persisted to [Filecoin](https://filecoin.io) via [web3.storage](https://web3.storage), and made available over [IPFS] and on a global content delivery network. All you need to do to sync state is send a link to the latest database head, and Fireproof will take care of the rest. [Learn how to enable replication.](#status)
|
96
|
-
|
97
|
-
### Cryptographic Proofs
|
98
|
-
|
99
|
-
The [UCAN protocol](https://ucan.xyz) verifably links Fireproof updates to authorized agents via cryptographic proof chains. These proofs are portable like bearer tokens, but because invocations are signed by end-user device keys, UCAN proofs don't need to be hidden to be secure, allowing for delegation of service capabilities across devices and parties. Additionally, Fireproof's Merkle clocks and hash trees are immutable and self-validating, making merging changes safe and efficient. Fireproof makes cryptographic proofs available for all of it's operations, making it an ideal verfiable document database for smart contracts and other applications running in trustless environments. [Proof chains provide performance benefits as well](https://purrfect-tracker-45c.notion.site/Data-Routing-23c37b269b4c4c3dacb60d0077113bcb), by allowing recipients to skip costly I/O operations and instead cryptographically verify that changes contain all of the required context.
|
100
|
-
|
101
|
-
## Limitations 💣
|
102
|
-
|
103
|
-
### Security
|
104
|
-
|
105
|
-
Until encryption support is enabled, all data written to Fireproof is public. There are no big hurdles for this feature but it's not ready yet.
|
106
|
-
|
107
|
-
### Persistence
|
108
|
-
|
109
|
-
Currently Fireproof writes transactions and proofs to in-memory [CAR files](https://ipld.io/specs/transport/car/carv2/) which are well suited for peer and cloud replication. Durability coming soon.
|
110
|
-
|
111
|
-
### Pre-beta Software
|
112
|
-
|
113
|
-
While the underlying data structures and libraries Fireproof uses are trusted with billions of dollars worth of data, Fireproof started in February of 2023. Results may vary.
|
114
|
-
|
115
|
-
## Thanks 🙏
|
116
|
-
|
117
|
-
Fireproof is a synthesis of work done by people in the web community over the years. I couldn't even begin to name all the folks who made pivotal contributions. Without npm, React, and VS Code all this would have taken so much longer. Thanks to everyone who supported me getting into database development via Apache CouchDB, one of the original document databases. The distinguishing work on immutable datastructures comes from the years of consideration [IPFS](https://ipfs.tech), [IPLD](https://ipld.io), and the [Filecoin APIs](https://docs.filecoin.io) have enjoyed.
|
118
|
-
|
119
|
-
Thanks to Alan Shaw and Mikeal Rogers without whom this project would have never got started. The core Merkle hash-tree clock is based on [Alan's Pail](https://github.com/alanshaw/pail), and you can see the repository history goes all the way back to work begun as a branch of that repo. Mikeal wrote [the prolly trees implementation](https://github.com/mikeal/prolly-trees).
|
120
|
-
|
121
|
-
## Quick Start
|
122
|
-
|
123
|
-
Look in the `examples/` directory for projects using the database. It's not picky how you use it, but we want to provide convenient jumping off places. Think of the examples as great to fork when starting your next project.
|
124
|
-
|
125
|
-
If are adding Fireproof to an existing page, just install it and try some operations.
|
126
|
-
|
127
|
-
```
|
128
|
-
npm install @fireproof/core
|
129
|
-
```
|
130
|
-
|
131
|
-
In your `app.js` or `app.tsx` file:
|
132
|
-
|
133
|
-
```
|
134
|
-
import { Fireproof } from '@fireproof/core'
|
135
|
-
const fireproof = Fireproof.storage()
|
136
|
-
const ok = await fireproof.put({ hello: 'world' })
|
137
|
-
const doc = await fireproof.get(ok.id)
|
138
|
-
```
|
139
|
-
|
140
|
-
🤫 I like to drop a `window.fireproof = fireproof` in there as a development aid.
|
141
|
-
|
142
|
-
# Contributing
|
143
|
-
|
144
|
-
Feel free to join in. All welcome. [Open an issue](https://github.com/jchris/fireproof/issues)!
|
145
|
-
|
146
|
-
# License
|
147
|
-
|
148
|
-
Dual-licensed under [MIT or Apache 2.0](https://github.com/jchris/fireproof/blob/main/LICENSE.md)
|