@cero-base/core 1.3.0 → 1.5.0
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/package.json +32 -15
- package/src/database/bootstrap.js +62 -8
- package/src/database/changes.js +120 -0
- package/src/database/dispatch.js +6 -6
- package/src/database/envelope.js +32 -0
- package/src/database/index.js +109 -7
- package/src/lib/spec/index.js +35 -2
- package/src/lib/spec/schema.json +23 -1
- package/src/network/index.js +63 -53
- package/src/network/{bluetooth.js → transports/ble.js} +152 -167
- package/src/network/transports/dht.js +110 -0
- package/src/network/{gatt-stream.js → transports/gatt.js} +4 -6
- package/src/rpc/index.js +23 -0
- package/types/database/changes.d.ts +15 -0
- package/types/database/envelope.d.ts +19 -0
- package/types/database/index.d.ts +32 -0
- package/types/lib/spec/index.d.ts +25 -12
- package/types/network/index.d.ts +21 -4
- package/types/network/{bluetooth.d.ts → transports/ble.d.ts} +25 -51
- package/types/network/transports/dht.d.ts +75 -0
- package/types/network/{gatt-stream.d.ts → transports/gatt.d.ts} +2 -3
- package/types/rpc/index.d.ts +10 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cero-base/core",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"description": "cero p2p primitives — identity, storage, network, database, blobs, rpc, pairing.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -41,9 +41,13 @@
|
|
|
41
41
|
"types": "./types/network/index.d.ts",
|
|
42
42
|
"default": "./src/network/index.js"
|
|
43
43
|
},
|
|
44
|
-
"./network/
|
|
45
|
-
"types": "./types/network/
|
|
46
|
-
"default": "./src/network/
|
|
44
|
+
"./network/transports/ble": {
|
|
45
|
+
"types": "./types/network/transports/ble.d.ts",
|
|
46
|
+
"default": "./src/network/transports/ble.js"
|
|
47
|
+
},
|
|
48
|
+
"./network/transports/dht": {
|
|
49
|
+
"types": "./types/network/transports/dht.d.ts",
|
|
50
|
+
"default": "./src/network/transports/dht.js"
|
|
47
51
|
},
|
|
48
52
|
"./database": {
|
|
49
53
|
"types": "./types/database/index.d.ts",
|
|
@@ -117,6 +121,10 @@
|
|
|
117
121
|
"crypto": {
|
|
118
122
|
"bare": "bare-crypto",
|
|
119
123
|
"default": "crypto"
|
|
124
|
+
},
|
|
125
|
+
"events": {
|
|
126
|
+
"bare": "bare-events",
|
|
127
|
+
"default": "events"
|
|
120
128
|
}
|
|
121
129
|
},
|
|
122
130
|
"scripts": {
|
|
@@ -125,28 +133,31 @@
|
|
|
125
133
|
"build:types": "rm -rf types && tsc -p .",
|
|
126
134
|
"pretest": "npm run build:test",
|
|
127
135
|
"prepublishOnly": "npm run build:types",
|
|
128
|
-
"test": "
|
|
129
|
-
"test:bare": "
|
|
136
|
+
"test": "npm run test:node",
|
|
137
|
+
"test:bare": "brittle-bare test/bare.js test/*.test.js",
|
|
138
|
+
"pretest:bare": "npm run build:test",
|
|
139
|
+
"test:node": "brittle-node test/*.test.js"
|
|
130
140
|
},
|
|
131
141
|
"dependencies": {
|
|
132
142
|
"@hyperswarm/secret-stream": "^6.9.1",
|
|
133
|
-
"autobee": "^1.0.
|
|
143
|
+
"autobee": "^1.0.10",
|
|
134
144
|
"b4a": "^1.8.1",
|
|
135
145
|
"bare-crypto": "^1.15.3",
|
|
136
|
-
"bare-fs": "^4.7.
|
|
137
|
-
"bare-path": "^3.
|
|
146
|
+
"bare-fs": "^4.7.4",
|
|
147
|
+
"bare-path": "^3.1.1",
|
|
138
148
|
"bip39-mnemonic": "^2.5.0",
|
|
139
149
|
"blind-pairing": "^2.3.1",
|
|
140
|
-
"
|
|
141
|
-
"
|
|
150
|
+
"blind-peering": "^2.5.0",
|
|
151
|
+
"compact-encoding": "^3.3.0",
|
|
152
|
+
"corestore": "^7.11.1",
|
|
142
153
|
"framed-stream": "^1.0.1",
|
|
143
154
|
"hrpc": "^4.3.0",
|
|
144
155
|
"hyperblobs": "^2.12.1",
|
|
145
|
-
"hypercore": "^11.
|
|
146
|
-
"hypercore-blob-server": "^1.
|
|
156
|
+
"hypercore": "^11.34.1",
|
|
157
|
+
"hypercore-blob-server": "^1.15.0",
|
|
147
158
|
"hypercore-crypto": "^3.7.0",
|
|
148
159
|
"hypercore-id-encoding": "^1.3.0",
|
|
149
|
-
"hypercore-storage": "^3.
|
|
160
|
+
"hypercore-storage": "^3.2.0",
|
|
150
161
|
"hyperdb": "^6.7.0",
|
|
151
162
|
"hyperdispatch": "^1.6.0",
|
|
152
163
|
"hyperschema": "^1.21.0",
|
|
@@ -161,7 +172,13 @@
|
|
|
161
172
|
},
|
|
162
173
|
"devDependencies": {
|
|
163
174
|
"@hyperswarm/testnet": "^3.1.4",
|
|
164
|
-
"
|
|
175
|
+
"bare-abort-controller": "^1.1.2",
|
|
176
|
+
"bare-events": "^2.9.1",
|
|
177
|
+
"bare-fetch": "^3.0.3",
|
|
178
|
+
"bare-process": "^4.4.1",
|
|
179
|
+
"bare-url": "^2.2.1",
|
|
180
|
+
"blind-peer": "^3.12.2",
|
|
181
|
+
"brittle": "^4.1.0",
|
|
165
182
|
"typescript": "^5.9.3"
|
|
166
183
|
},
|
|
167
184
|
"license": "Apache-2.0"
|
|
@@ -21,16 +21,39 @@ export async function bootstrap(db, { name, isMobile, recovering = false, timeou
|
|
|
21
21
|
}
|
|
22
22
|
const writerKey = Hypercore.key(manifest)
|
|
23
23
|
|
|
24
|
-
if (recovering)
|
|
25
|
-
|
|
24
|
+
if (recovering) {
|
|
25
|
+
await waitForFirstPeerAppend(db, timeout)
|
|
26
|
+
// reopen so the bee's writer state is built over the replicated genesis
|
|
27
|
+
// core — a writer opened before replication stamps ops at stale lengths
|
|
28
|
+
// and autobee silently drops them
|
|
29
|
+
await reopenBee(db)
|
|
30
|
+
}
|
|
31
|
+
if (recovering) {
|
|
32
|
+
// gate the swap on the admission actually applying: write() can resolve
|
|
33
|
+
// with the ops stalled in the drain (update() bumps heal that) or dropped
|
|
34
|
+
// outright when they were stamped behind a mid-replication core — each
|
|
35
|
+
// retry stamps past the collision, and the ops are idempotent
|
|
36
|
+
await admitWriter(db, writerKey, { name, isMobile }, timeout)
|
|
37
|
+
} else {
|
|
38
|
+
await saveWriter(db, writerKey, { name, isMobile })
|
|
39
|
+
}
|
|
26
40
|
await swapWriter(db, keyPair, manifest)
|
|
27
41
|
|
|
28
42
|
return { id: writerKey, writer: keyPair }
|
|
29
43
|
}
|
|
30
44
|
|
|
31
45
|
async function waitForFirstPeerAppend(db, timeout = 30000) {
|
|
32
|
-
if (
|
|
33
|
-
await
|
|
46
|
+
if (!db.network?.swarm) return
|
|
47
|
+
if (db.bee.local.length === 0) await waitForLength(db, 1, timeout)
|
|
48
|
+
await db.bee.update()
|
|
49
|
+
// the first block may not be the whole core — writes stamped below the
|
|
50
|
+
// length the system already attributes to this writer are silently dropped
|
|
51
|
+
const info = await db.bee.system.get(db.bee.local.key).catch(() => null)
|
|
52
|
+
if (info && info.length > db.bee.local.length) await waitForLength(db, info.length, timeout)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function waitForLength(db, length, timeout) {
|
|
56
|
+
return new Promise((resolve, reject) => {
|
|
34
57
|
let done = false
|
|
35
58
|
const finish = (err) => {
|
|
36
59
|
if (done) return
|
|
@@ -43,7 +66,7 @@ async function waitForFirstPeerAppend(db, timeout = 30000) {
|
|
|
43
66
|
}
|
|
44
67
|
// timeout + close handling so a peer that never replicates can't hang the
|
|
45
68
|
// recovery forever, and the append listener is always removed.
|
|
46
|
-
const onAppend = () => db.bee.local.length
|
|
69
|
+
const onAppend = () => db.bee.local.length >= length && finish()
|
|
47
70
|
const onClose = () => finish(CeroError.CLOSED('Database'))
|
|
48
71
|
const timer = setTimeout(
|
|
49
72
|
() => finish(CeroError.TIMED_OUT('recovery — no peer append')),
|
|
@@ -52,17 +75,46 @@ async function waitForFirstPeerAppend(db, timeout = 30000) {
|
|
|
52
75
|
db.bee.local.on('append', onAppend)
|
|
53
76
|
db.on('close', onClose)
|
|
54
77
|
})
|
|
55
|
-
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function admitWriter(db, writerKey, opts, timeout) {
|
|
81
|
+
const deadline = Date.now() + timeout
|
|
82
|
+
const id = toId(writerKey)
|
|
83
|
+
while (true) {
|
|
84
|
+
await saveWriter(db, writerKey, opts)
|
|
85
|
+
if (await deviceApplied(db, id, Math.min(3000, deadline - Date.now()))) return
|
|
86
|
+
if (Date.now() >= deadline) throw CeroError.TIMED_OUT('recovery — writer not admitted')
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function deviceApplied(db, id, timeout) {
|
|
91
|
+
const deadline = Date.now() + timeout
|
|
92
|
+
while (Date.now() < deadline) {
|
|
93
|
+
const { data } = await db.get('devices', id)
|
|
94
|
+
if (data) return true
|
|
95
|
+
await db.bee.update()
|
|
96
|
+
await new Promise((resolve) => setTimeout(resolve, 25))
|
|
97
|
+
}
|
|
98
|
+
return false
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function reopenBee(db) {
|
|
102
|
+
if (db.network) db.network.detach(db.bee)
|
|
103
|
+
await db.bee.close()
|
|
104
|
+
db.bee = null
|
|
105
|
+
await db.openBee()
|
|
56
106
|
}
|
|
57
107
|
|
|
58
108
|
async function saveWriter(db, writerKey, { name, isMobile }) {
|
|
109
|
+
const ts = Date.now()
|
|
59
110
|
await db.write([
|
|
60
111
|
[
|
|
61
112
|
'add-writer',
|
|
62
113
|
{
|
|
63
114
|
master: db.identity.publicKey,
|
|
64
115
|
writer: writerKey,
|
|
65
|
-
sig: db.identity.sign(addWriterPayload(db.key, writerKey, db.writerKey))
|
|
116
|
+
sig: db.identity.sign(addWriterPayload(db.key, writerKey, db.writerKey)),
|
|
117
|
+
ts
|
|
66
118
|
}
|
|
67
119
|
],
|
|
68
120
|
[
|
|
@@ -70,7 +122,9 @@ async function saveWriter(db, writerKey, { name, isMobile }) {
|
|
|
70
122
|
{
|
|
71
123
|
id: toId(writerKey),
|
|
72
124
|
name: name || null,
|
|
73
|
-
isMobile: isMobile === true
|
|
125
|
+
isMobile: isMobile === true,
|
|
126
|
+
createdAt: ts,
|
|
127
|
+
updatedAt: ts
|
|
74
128
|
}
|
|
75
129
|
]
|
|
76
130
|
])
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { Readable } from 'streamx'
|
|
2
|
+
import b4a from 'b4a'
|
|
3
|
+
|
|
4
|
+
// the pinned hyperbee2 snapshot inside a hyperdb snapshot — internals reach
|
|
5
|
+
// (pear-sdk does the same); kept in one helper so an upstream rename is a
|
|
6
|
+
// one-line fix
|
|
7
|
+
const beeOf = (snap) => snap.engineSnapshot.snapshot
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Pull-driven delta stream over a collection: batches of `{ prev, next }`
|
|
11
|
+
* pairs diffed between per-stream snapshot cursors. Update ticks only mark
|
|
12
|
+
* the stream dirty — the diff runs when the reader demands, so backpressure
|
|
13
|
+
* folds bursts into one bigger batch and nothing is ever dropped. The first
|
|
14
|
+
* batch, and any batch after the view is swapped or fast-forwarded to a new
|
|
15
|
+
* core, carries the full matching state as inserts with `reset: true`.
|
|
16
|
+
*
|
|
17
|
+
* @param {import('./index.js').Database} db
|
|
18
|
+
* @param {string} name Ref name (scopes the update ticks).
|
|
19
|
+
* @param {string} col Collection path (`@ns/name`).
|
|
20
|
+
* @param {(row: any) => boolean} matches
|
|
21
|
+
* @returns {import('streamx').Readable}
|
|
22
|
+
*/
|
|
23
|
+
export function makeChanges(db, name, col, matches) {
|
|
24
|
+
let cursor = null
|
|
25
|
+
let cursorView = null
|
|
26
|
+
let dirty = true
|
|
27
|
+
let busy = false
|
|
28
|
+
let off = null
|
|
29
|
+
|
|
30
|
+
const stream = new Readable({
|
|
31
|
+
open(cb) {
|
|
32
|
+
off = db.onUpdate(mark, name)
|
|
33
|
+
pump()
|
|
34
|
+
cb(null)
|
|
35
|
+
},
|
|
36
|
+
read(cb) {
|
|
37
|
+
pump()
|
|
38
|
+
cb(null)
|
|
39
|
+
},
|
|
40
|
+
predestroy() {
|
|
41
|
+
if (off) off()
|
|
42
|
+
},
|
|
43
|
+
destroy(cb) {
|
|
44
|
+
release().then(() => cb(null), cb)
|
|
45
|
+
}
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
const mark = () => {
|
|
49
|
+
dirty = true
|
|
50
|
+
pump()
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const pump = () => {
|
|
54
|
+
if (busy || !dirty || stream.destroyed) return
|
|
55
|
+
busy = true
|
|
56
|
+
run()
|
|
57
|
+
.catch((err) => stream.destroy(err))
|
|
58
|
+
.finally(() => {
|
|
59
|
+
busy = false
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const run = async () => {
|
|
64
|
+
while (dirty && !stream.destroyed) {
|
|
65
|
+
dirty = false
|
|
66
|
+
const batch = await diff()
|
|
67
|
+
if (batch && stream.push(batch) === false) return
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const diff = async () => {
|
|
72
|
+
const snap = db.view.snapshot()
|
|
73
|
+
try {
|
|
74
|
+
const fresh =
|
|
75
|
+
!cursor ||
|
|
76
|
+
cursorView !== db.view ||
|
|
77
|
+
!b4a.equals(beeOf(cursor).core.key, beeOf(snap).core.key)
|
|
78
|
+
const batch = fresh ? await initial(snap) : await delta(snap)
|
|
79
|
+
if (cursor) await cursor.close()
|
|
80
|
+
cursor = snap
|
|
81
|
+
cursorView = db.view
|
|
82
|
+
return batch
|
|
83
|
+
} catch (err) {
|
|
84
|
+
await snap.close().catch(() => {})
|
|
85
|
+
throw err
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const initial = async (snap) => {
|
|
90
|
+
const changes = []
|
|
91
|
+
for (const next of await snap.find(col, {}).toArray()) {
|
|
92
|
+
if (matches(next)) changes.push({ prev: null, next })
|
|
93
|
+
}
|
|
94
|
+
return { changes, reset: true }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const delta = async (snap) => {
|
|
98
|
+
const collection = db.view.definition.resolveCollection(col)
|
|
99
|
+
const range = collection.encodeKeyRange({})
|
|
100
|
+
const changes = []
|
|
101
|
+
for await (const { left, right } of beeOf(cursor).createDiffStream(beeOf(snap), range)) {
|
|
102
|
+
const prev = left ? collection.reconstruct(db.view.versions, left.key, left.value) : null
|
|
103
|
+
const next = right ? collection.reconstruct(db.view.versions, right.key, right.value) : null
|
|
104
|
+
const from = prev && matches(prev) ? prev : null
|
|
105
|
+
const to = next && matches(next) ? next : null
|
|
106
|
+
if (!from && !to) continue
|
|
107
|
+
changes.push({ prev: from, next: to })
|
|
108
|
+
}
|
|
109
|
+
return changes.length ? { changes, reset: false } : null
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const release = async () => {
|
|
113
|
+
if (!cursor) return
|
|
114
|
+
const held = cursor
|
|
115
|
+
cursor = null
|
|
116
|
+
await held.close().catch(() => {})
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return stream
|
|
120
|
+
}
|
package/src/database/dispatch.js
CHANGED
|
@@ -105,7 +105,7 @@ export function makeDispatcher(spec, ns, routes, onerror = safetyCatch, getDbKey
|
|
|
105
105
|
if (!Identity.verify(op.master, addWriterPayload(ctx.dbKey, op.writer, ctx.key), op.sig)) return
|
|
106
106
|
if (!(await isGenesis(ctx.view)) && !can(await getRole(ctx.view, op.master), INVITE)) return
|
|
107
107
|
await ctx.host.addWriter(op.writer, { isIndexer: op.isIndexer !== false })
|
|
108
|
-
const ts =
|
|
108
|
+
const ts = op.ts || 0
|
|
109
109
|
await insert(ctx.view, 'devices', `@${ns}/devices`, {
|
|
110
110
|
id: toId(op.writer),
|
|
111
111
|
memberId: toId(op.master),
|
|
@@ -131,7 +131,7 @@ export function makeDispatcher(spec, ns, routes, onerror = safetyCatch, getDbKey
|
|
|
131
131
|
const memberId = toId(op.identity)
|
|
132
132
|
if (!can(await getRole(ctx.view, op.identity), WRITE)) return
|
|
133
133
|
await ctx.host.addWriter(op.writer, { isIndexer: true })
|
|
134
|
-
const ts =
|
|
134
|
+
const ts = op.ts || 0
|
|
135
135
|
await insert(ctx.view, 'devices', `@${ns}/devices`, {
|
|
136
136
|
id: toId(op.writer),
|
|
137
137
|
memberId,
|
|
@@ -149,13 +149,13 @@ export function makeDispatcher(spec, ns, routes, onerror = safetyCatch, getDbKey
|
|
|
149
149
|
await insert(ctx.view, 'members', `@${ns}/members`, op)
|
|
150
150
|
const deviceId = toId(op.key)
|
|
151
151
|
const existingDevice = await getDevice(ctx.view, deviceId)
|
|
152
|
-
const ts =
|
|
152
|
+
const ts = op.updatedAt || 0
|
|
153
153
|
await insert(ctx.view, 'devices', `@${ns}/devices`, {
|
|
154
154
|
id: deviceId,
|
|
155
155
|
memberId: op.id,
|
|
156
156
|
name: existingDevice?.name ?? null,
|
|
157
157
|
isMobile: existingDevice?.isMobile ?? false,
|
|
158
|
-
createdAt: existingDevice?.createdAt
|
|
158
|
+
createdAt: existingDevice?.createdAt || op.createdAt || ts,
|
|
159
159
|
updatedAt: ts
|
|
160
160
|
})
|
|
161
161
|
})
|
|
@@ -203,11 +203,11 @@ export function makeDispatcher(spec, ns, routes, onerror = safetyCatch, getDbKey
|
|
|
203
203
|
})
|
|
204
204
|
add(`set-${b.verb}`, async (op, ctx) => {
|
|
205
205
|
const existing = await getDevice(ctx.view, op.id)
|
|
206
|
-
const ts =
|
|
206
|
+
const ts = op.updatedAt || 0
|
|
207
207
|
await insert(ctx.view, b.name, col, {
|
|
208
208
|
...op,
|
|
209
209
|
memberId: existing?.memberId ?? op.memberId ?? null,
|
|
210
|
-
createdAt: existing?.createdAt
|
|
210
|
+
createdAt: existing?.createdAt || op.createdAt || ts,
|
|
211
211
|
updatedAt: ts
|
|
212
212
|
})
|
|
213
213
|
})
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import b4a from 'b4a'
|
|
2
|
+
import c from 'compact-encoding'
|
|
3
|
+
|
|
4
|
+
// 0xff is the c.uint prefix for a 64-bit route id — unreachable for real
|
|
5
|
+
// hyperdispatch routes, so it safely marks a version-enveloped op. Peers too
|
|
6
|
+
// old to know the envelope throw on it and skip deterministically.
|
|
7
|
+
const SENTINEL = 0xff
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Prefix an encoded op with the app's contract version.
|
|
11
|
+
*
|
|
12
|
+
* @param {number} version
|
|
13
|
+
* @param {Uint8Array} body
|
|
14
|
+
* @returns {Uint8Array}
|
|
15
|
+
*/
|
|
16
|
+
export function wrap(version, body) {
|
|
17
|
+
return b4a.concat([b4a.from([SENTINEL]), c.encode(c.uint, version), body])
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Split an op into contract version and payload. Ops written before the
|
|
22
|
+
* envelope existed carry no sentinel and read as version 0.
|
|
23
|
+
*
|
|
24
|
+
* @param {Uint8Array} buf
|
|
25
|
+
* @returns {{ version: number, body: Uint8Array }}
|
|
26
|
+
*/
|
|
27
|
+
export function unwrap(buf) {
|
|
28
|
+
if (buf.byteLength === 0 || buf[0] !== SENTINEL) return { version: 0, body: buf }
|
|
29
|
+
const state = { buffer: buf, start: 1, end: buf.byteLength }
|
|
30
|
+
const version = c.uint.decode(state)
|
|
31
|
+
return { version, body: buf.subarray(state.start) }
|
|
32
|
+
}
|
package/src/database/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import Autobee from 'autobee'
|
|
2
2
|
import HyperDB from 'hyperdb'
|
|
3
|
+
import c from 'compact-encoding'
|
|
3
4
|
import Hypercore from 'hypercore'
|
|
4
5
|
import ReadyResource from 'ready-resource'
|
|
5
6
|
import safetyCatch from 'safety-catch'
|
|
@@ -7,8 +8,10 @@ import b4a from 'b4a'
|
|
|
7
8
|
|
|
8
9
|
import { NAMESPACE, SINGLE, COLLECTION, ACTION, ACTIVE, PASSIVE } from '../lib/constants.js'
|
|
9
10
|
import { genId, subscribe, addWriterPayload, claimWriterPayload } from '../lib/utils.js'
|
|
11
|
+
import { wrap, unwrap } from './envelope.js'
|
|
10
12
|
import { CeroError } from '../lib/errors.js'
|
|
11
13
|
import { bootstrap } from './bootstrap.js'
|
|
14
|
+
import { makeChanges } from './changes.js'
|
|
12
15
|
import { makeDispatcher } from './dispatch.js'
|
|
13
16
|
|
|
14
17
|
/**
|
|
@@ -57,6 +60,8 @@ export class Database extends ReadyResource {
|
|
|
57
60
|
this.meta = opts.spec.meta || { ns: NAMESPACE, refs: {} }
|
|
58
61
|
this.ns = this.meta.ns || NAMESPACE
|
|
59
62
|
this.refs = this.meta.refs || {}
|
|
63
|
+
this.version = this.meta.version || 1
|
|
64
|
+
this.behind = null
|
|
60
65
|
this.routes = opts.routes || {}
|
|
61
66
|
this.namespace = opts.namespace || NAMESPACE
|
|
62
67
|
this.encryptionKey = opts.encryptionKey || opts.identity.encryptionKey || null
|
|
@@ -124,9 +129,21 @@ export class Database extends ReadyResource {
|
|
|
124
129
|
wakeup: wakeup || undefined,
|
|
125
130
|
open: (b) => HyperDB.bee2(b, this.spec.database, { autoUpdate: true }),
|
|
126
131
|
apply: async (nodes, view, host) => {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
132
|
+
// envelope handling lives here, once: unwrap every node and skip ops
|
|
133
|
+
// from a newer app version (deterministic — they stay in the log), so
|
|
134
|
+
// the dispatcher AND custom apply overrides see plain op bytes
|
|
135
|
+
const ready = []
|
|
136
|
+
for (const node of nodes) {
|
|
137
|
+
const { version, body } = unwrap(node.value)
|
|
138
|
+
if (version > this.version) {
|
|
139
|
+
this._onFuture(version)
|
|
140
|
+
continue
|
|
141
|
+
}
|
|
142
|
+
ready.push(version === 0 ? node : { ...node, value: body })
|
|
143
|
+
}
|
|
144
|
+
const result = await (this.applyOverride || this.dispatcher.apply)(ready, view, host)
|
|
145
|
+
if (this.updaters.size) this._touch(ready)
|
|
146
|
+
if (this.onApplyHooks.size) this._observe(ready)
|
|
130
147
|
return result
|
|
131
148
|
},
|
|
132
149
|
update: async (db) => {
|
|
@@ -149,6 +166,21 @@ export class Database extends ReadyResource {
|
|
|
149
166
|
this.bee = bee
|
|
150
167
|
this.key = bee.key
|
|
151
168
|
|
|
169
|
+
const behind = await bee.local.getUserData('cero/behind')
|
|
170
|
+
this.behind = behind ? c.decode(c.uint, behind) : null
|
|
171
|
+
|
|
172
|
+
// ops we once skipped are now within our contract — wipe the boot record
|
|
173
|
+
// so the next open replays the whole log through the current handlers
|
|
174
|
+
if (this.behind !== null && this.behind <= this.version) {
|
|
175
|
+
await bee.local.setUserData('autobee/head', null)
|
|
176
|
+
await bee.local.setUserData('cero/behind', null)
|
|
177
|
+
await bee.close()
|
|
178
|
+
this.behind = null
|
|
179
|
+
await this.openBee()
|
|
180
|
+
this.emit('rebuild')
|
|
181
|
+
return
|
|
182
|
+
}
|
|
183
|
+
|
|
152
184
|
bee.on('writable', () => this.emit('writable'))
|
|
153
185
|
// without a listener autobee escalates apply/view errors to a process crash
|
|
154
186
|
bee.on('error', this._onerror)
|
|
@@ -503,7 +535,49 @@ export class Database extends ReadyResource {
|
|
|
503
535
|
const encoded = ops.map(([op, payload]) =>
|
|
504
536
|
this.spec.dispatch.encode(`@${this.ns}/${op}`, payload)
|
|
505
537
|
)
|
|
506
|
-
await this.
|
|
538
|
+
await this._dryRun(encoded)
|
|
539
|
+
const wrapped = encoded.map((value) => wrap(this.version, value))
|
|
540
|
+
await this.bee.append(wrapped.length === 1 ? wrapped[0] : wrapped)
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Record that the log contains ops from a newer app version than this
|
|
545
|
+
* peer understands. Fires `'behind'` once per version so apps can prompt
|
|
546
|
+
* an upgrade; the marker survives restarts via local core userData.
|
|
547
|
+
*
|
|
548
|
+
* @param {number} version
|
|
549
|
+
*/
|
|
550
|
+
_onFuture(version) {
|
|
551
|
+
if (this.behind !== null && version <= this.behind) return
|
|
552
|
+
this.behind = version
|
|
553
|
+
this.bee.local.setUserData('cero/behind', c.encode(c.uint, version)).catch(this._onerror)
|
|
554
|
+
this.emit('behind', version)
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Validate ops against the committed view before appending: each op runs in
|
|
559
|
+
* a throwaway transaction with host effects stubbed, so a throwing handler
|
|
560
|
+
* rejects the write and nothing enters the permanent log. Permission gates
|
|
561
|
+
* that `return` are not rejections — apply stays the authority at
|
|
562
|
+
* linearization time.
|
|
563
|
+
*
|
|
564
|
+
* @param {Uint8Array[]} encoded
|
|
565
|
+
*/
|
|
566
|
+
async _dryRun(encoded) {
|
|
567
|
+
const tx = this.view.transaction()
|
|
568
|
+
const host = { addWriter: async () => {}, removeWriter: async () => {} }
|
|
569
|
+
try {
|
|
570
|
+
for (const value of encoded) {
|
|
571
|
+
await this.dispatcher.dispatcher.dispatch(value, {
|
|
572
|
+
view: tx,
|
|
573
|
+
host,
|
|
574
|
+
key: this.writerKey,
|
|
575
|
+
dbKey: this.key
|
|
576
|
+
})
|
|
577
|
+
}
|
|
578
|
+
} finally {
|
|
579
|
+
await tx.close()
|
|
580
|
+
}
|
|
507
581
|
}
|
|
508
582
|
|
|
509
583
|
/**
|
|
@@ -634,6 +708,31 @@ export class Database extends ReadyResource {
|
|
|
634
708
|
})
|
|
635
709
|
}
|
|
636
710
|
|
|
711
|
+
/**
|
|
712
|
+
* Delta subscription: batches of `{ prev, next }` row pairs instead of
|
|
713
|
+
* full snapshots. The first batch (and any batch after a view swap) carries
|
|
714
|
+
* the current matching rows as inserts with `reset: true` — replaying every
|
|
715
|
+
* batch into a Map keyed by row id always reconstructs current state.
|
|
716
|
+
* `limit`/`reverse` are not applied; deltas are unwindowed by design.
|
|
717
|
+
*
|
|
718
|
+
* @param {string} name
|
|
719
|
+
* @param {Query} [query]
|
|
720
|
+
* @returns {import('streamx').Readable}
|
|
721
|
+
*/
|
|
722
|
+
changes(name, query = {}) {
|
|
723
|
+
this.guard()
|
|
724
|
+
const col = `@${this.ns}/${name}`
|
|
725
|
+
if (!this.view.definition.resolveCollection(col)) {
|
|
726
|
+
throw CeroError.UNKNOWN('collection', name)
|
|
727
|
+
}
|
|
728
|
+
const filter = { ...query }
|
|
729
|
+
delete filter.limit
|
|
730
|
+
delete filter.reverse
|
|
731
|
+
delete filter.total
|
|
732
|
+
const matches = (row) => paginate([row], filter).length > 0
|
|
733
|
+
return makeChanges(this, name, col, matches)
|
|
734
|
+
}
|
|
735
|
+
|
|
637
736
|
/**
|
|
638
737
|
* First-run bootstrap: create the device writer, save it, and swap into it.
|
|
639
738
|
*
|
|
@@ -661,9 +760,10 @@ export class Database extends ReadyResource {
|
|
|
661
760
|
const encoded = this.spec.dispatch.encode(`@${this.ns}/claim-writer`, {
|
|
662
761
|
identity: this.identity.publicKey,
|
|
663
762
|
writer: writerKey,
|
|
664
|
-
sig
|
|
763
|
+
sig,
|
|
764
|
+
ts: Date.now()
|
|
665
765
|
})
|
|
666
|
-
await this.bee.append(encoded, { optimistic: true })
|
|
766
|
+
await this.bee.append(wrap(this.version, encoded), { optimistic: true })
|
|
667
767
|
await this.bee.update()
|
|
668
768
|
if (!this.bee.writable) await this.whenWritable()
|
|
669
769
|
}
|
|
@@ -826,7 +926,9 @@ export class Database extends ReadyResource {
|
|
|
826
926
|
signers: [{ publicKey }]
|
|
827
927
|
})
|
|
828
928
|
const sig = this.identity.sign(addWriterPayload(this.key, writerKey, this.writerKey))
|
|
829
|
-
await this.write([
|
|
929
|
+
await this.write([
|
|
930
|
+
[verb, { master: this.identity.publicKey, writer: writerKey, sig, ts: Date.now() }]
|
|
931
|
+
])
|
|
830
932
|
await this.runAfter(hook, ctx)
|
|
831
933
|
}
|
|
832
934
|
}
|
package/src/lib/spec/index.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
// This file is autogenerated by the hyperschema compiler
|
|
2
|
-
// Schema Version:
|
|
2
|
+
// Schema Version: 2
|
|
3
3
|
/* eslint-disable camelcase */
|
|
4
4
|
/* eslint-disable quotes */
|
|
5
5
|
/* eslint-disable space-before-function-paren */
|
|
6
6
|
|
|
7
7
|
import { c } from 'hyperschema/runtime'
|
|
8
8
|
|
|
9
|
-
const VERSION =
|
|
9
|
+
const VERSION = 2
|
|
10
10
|
|
|
11
11
|
// eslint-disable-next-line no-unused-vars
|
|
12
12
|
let version = VERSION
|
|
@@ -274,6 +274,37 @@ const encoding6 = {
|
|
|
274
274
|
}
|
|
275
275
|
}
|
|
276
276
|
|
|
277
|
+
// @cero/changes.prev
|
|
278
|
+
const encoding7_0 = encoding3_0
|
|
279
|
+
// @cero/changes.next
|
|
280
|
+
const encoding7_1 = encoding3_0
|
|
281
|
+
|
|
282
|
+
// @cero/changes
|
|
283
|
+
const encoding7 = {
|
|
284
|
+
preencode(state, m) {
|
|
285
|
+
state.end++ // max flag is 2 so always one byte
|
|
286
|
+
|
|
287
|
+
if (version >= 2 && m.prev) encoding7_0.preencode(state, m.prev)
|
|
288
|
+
if (version >= 2 && m.next) encoding7_1.preencode(state, m.next)
|
|
289
|
+
},
|
|
290
|
+
encode(state, m) {
|
|
291
|
+
const flags = ((version >= 2 && m.prev) ? 1 : 0) | ((version >= 2 && m.next) ? 2 : 0)
|
|
292
|
+
|
|
293
|
+
c.uint.encode(state, flags)
|
|
294
|
+
|
|
295
|
+
if (version >= 2 && m.prev) encoding7_0.encode(state, m.prev)
|
|
296
|
+
if (version >= 2 && m.next) encoding7_1.encode(state, m.next)
|
|
297
|
+
},
|
|
298
|
+
decode(state) {
|
|
299
|
+
const flags = c.uint.decode(state)
|
|
300
|
+
|
|
301
|
+
return {
|
|
302
|
+
prev: (version >= 2 && (flags & 1) !== 0) ? encoding7_0.decode(state) : null,
|
|
303
|
+
next: (version >= 2 && (flags & 2) !== 0) ? encoding7_1.decode(state) : null
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
277
308
|
function setVersion(v) {
|
|
278
309
|
version = v
|
|
279
310
|
}
|
|
@@ -311,6 +342,8 @@ function getEncoding(name) {
|
|
|
311
342
|
return encoding5
|
|
312
343
|
case '@cero/blob-id':
|
|
313
344
|
return encoding6
|
|
345
|
+
case '@cero/changes':
|
|
346
|
+
return encoding7
|
|
314
347
|
default:
|
|
315
348
|
throw new Error('Encoder not found ' + name)
|
|
316
349
|
}
|
package/src/lib/spec/schema.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version":
|
|
2
|
+
"version": 2,
|
|
3
3
|
"schema": [
|
|
4
4
|
{
|
|
5
5
|
"name": "invite-body",
|
|
@@ -273,6 +273,28 @@
|
|
|
273
273
|
"version": 1
|
|
274
274
|
}
|
|
275
275
|
]
|
|
276
|
+
},
|
|
277
|
+
{
|
|
278
|
+
"name": "changes",
|
|
279
|
+
"namespace": "cero",
|
|
280
|
+
"compact": false,
|
|
281
|
+
"flagsPosition": 0,
|
|
282
|
+
"fields": [
|
|
283
|
+
{
|
|
284
|
+
"name": "prev",
|
|
285
|
+
"required": false,
|
|
286
|
+
"array": true,
|
|
287
|
+
"type": "buffer",
|
|
288
|
+
"version": 2
|
|
289
|
+
},
|
|
290
|
+
{
|
|
291
|
+
"name": "next",
|
|
292
|
+
"required": false,
|
|
293
|
+
"array": true,
|
|
294
|
+
"type": "buffer",
|
|
295
|
+
"version": 2
|
|
296
|
+
}
|
|
297
|
+
]
|
|
276
298
|
}
|
|
277
299
|
]
|
|
278
300
|
}
|