@cero-base/core 1.4.0 → 1.5.1
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 +25 -12
- 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 +33 -1
- package/src/network/transports/ble.js +15 -5
- 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 +14 -1
- package/types/network/transports/ble.d.ts +7 -4
- 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.1",
|
|
4
4
|
"description": "cero p2p primitives — identity, storage, network, database, blobs, rpc, pairing.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -121,6 +121,10 @@
|
|
|
121
121
|
"crypto": {
|
|
122
122
|
"bare": "bare-crypto",
|
|
123
123
|
"default": "crypto"
|
|
124
|
+
},
|
|
125
|
+
"events": {
|
|
126
|
+
"bare": "bare-events",
|
|
127
|
+
"default": "events"
|
|
124
128
|
}
|
|
125
129
|
},
|
|
126
130
|
"scripts": {
|
|
@@ -129,28 +133,31 @@
|
|
|
129
133
|
"build:types": "rm -rf types && tsc -p .",
|
|
130
134
|
"pretest": "npm run build:test",
|
|
131
135
|
"prepublishOnly": "npm run build:types",
|
|
132
|
-
"test": "
|
|
133
|
-
"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"
|
|
134
140
|
},
|
|
135
141
|
"dependencies": {
|
|
136
142
|
"@hyperswarm/secret-stream": "^6.9.1",
|
|
137
|
-
"autobee": "^1.0.
|
|
143
|
+
"autobee": "^1.0.10",
|
|
138
144
|
"b4a": "^1.8.1",
|
|
139
145
|
"bare-crypto": "^1.15.3",
|
|
140
|
-
"bare-fs": "^4.7.
|
|
141
|
-
"bare-path": "^3.
|
|
146
|
+
"bare-fs": "^4.7.4",
|
|
147
|
+
"bare-path": "^3.1.1",
|
|
142
148
|
"bip39-mnemonic": "^2.5.0",
|
|
143
149
|
"blind-pairing": "^2.3.1",
|
|
144
|
-
"
|
|
145
|
-
"
|
|
150
|
+
"blind-peering": "^2.5.0",
|
|
151
|
+
"compact-encoding": "^3.3.0",
|
|
152
|
+
"corestore": "^7.11.1",
|
|
146
153
|
"framed-stream": "^1.0.1",
|
|
147
154
|
"hrpc": "^4.3.0",
|
|
148
155
|
"hyperblobs": "^2.12.1",
|
|
149
|
-
"hypercore": "^11.
|
|
150
|
-
"hypercore-blob-server": "^1.
|
|
156
|
+
"hypercore": "^11.34.1",
|
|
157
|
+
"hypercore-blob-server": "^1.15.0",
|
|
151
158
|
"hypercore-crypto": "^3.7.0",
|
|
152
159
|
"hypercore-id-encoding": "^1.3.0",
|
|
153
|
-
"hypercore-storage": "^3.
|
|
160
|
+
"hypercore-storage": "^3.2.0",
|
|
154
161
|
"hyperdb": "^6.7.0",
|
|
155
162
|
"hyperdispatch": "^1.6.0",
|
|
156
163
|
"hyperschema": "^1.21.0",
|
|
@@ -165,7 +172,13 @@
|
|
|
165
172
|
},
|
|
166
173
|
"devDependencies": {
|
|
167
174
|
"@hyperswarm/testnet": "^3.1.4",
|
|
168
|
-
"
|
|
175
|
+
"bare-abort-controller": "^1.1.2",
|
|
176
|
+
"bare-events": "^2.9.1",
|
|
177
|
+
"bare-fetch": "^3.2.0",
|
|
178
|
+
"bare-process": "^4.5.1",
|
|
179
|
+
"bare-url": "^2.4.6",
|
|
180
|
+
"blind-peer": "^3.12.3",
|
|
181
|
+
"brittle": "^4.1.0",
|
|
169
182
|
"typescript": "^5.9.3"
|
|
170
183
|
},
|
|
171
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
|
}
|
package/src/network/index.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import NoiseSecretStream from '@hyperswarm/secret-stream'
|
|
2
2
|
import BlindPairing from 'blind-pairing'
|
|
3
|
+
import BlindPeering from 'blind-peering'
|
|
3
4
|
import ProtomuxWakeup from 'protomux-wakeup'
|
|
4
5
|
import ReadyResource from 'ready-resource'
|
|
5
6
|
import safetyCatch from 'safety-catch'
|
|
7
|
+
import { decode as decodeKey } from 'hypercore-id-encoding'
|
|
6
8
|
import b4a from 'b4a'
|
|
7
9
|
|
|
8
10
|
import { ACTIVE, PASSIVE } from '../lib/constants.js'
|
|
@@ -19,6 +21,8 @@ export { channelTopic }
|
|
|
19
21
|
* @property {(remotePublicKey: Uint8Array, payload: any) => boolean} [firewall] Incoming-connection filter.
|
|
20
22
|
* @property {Uint8Array[]} [relayThrough] Relay public keys to tunnel through.
|
|
21
23
|
* @property {string} [channel] Optional network-isolation label; only same-channel peers meet.
|
|
24
|
+
* @property {any} [store] Corestore; required for mirrors (blind peers replicate its cores).
|
|
25
|
+
* @property {Array<string | Uint8Array>} [mirrors] Blind-peer public keys; each attached room/blob core is mirrored through them for offline sync.
|
|
22
26
|
*
|
|
23
27
|
* @typedef {{ replicate: (stream: any) => any }} Replicable
|
|
24
28
|
*/
|
|
@@ -29,13 +33,15 @@ export { channelTopic }
|
|
|
29
33
|
*/
|
|
30
34
|
export class Network extends ReadyResource {
|
|
31
35
|
/** @param {NetworkOpts} [opts] */
|
|
32
|
-
constructor({ identity, bootstrap, firewall, relayThrough, channel } = {}) {
|
|
36
|
+
constructor({ identity, bootstrap, firewall, relayThrough, channel, store, mirrors } = {}) {
|
|
33
37
|
super()
|
|
34
38
|
this.identity = identity || null
|
|
35
39
|
this.bootstrap = bootstrap || null
|
|
36
40
|
this.firewall = firewall || null
|
|
37
41
|
this.relayThrough = relayThrough || null
|
|
38
42
|
this.channel = channel || null
|
|
43
|
+
this.store = store || null
|
|
44
|
+
this.mirrors = (mirrors || []).map((k) => (typeof k === 'string' ? decodeKey(k) : k))
|
|
39
45
|
|
|
40
46
|
this._dht = null
|
|
41
47
|
this.wakeup = new ProtomuxWakeup()
|
|
@@ -44,6 +50,7 @@ export class Network extends ReadyResource {
|
|
|
44
50
|
this._discoveries = new Set()
|
|
45
51
|
this._injected = new Set()
|
|
46
52
|
this._blind = null
|
|
53
|
+
this._blindPeering = null
|
|
47
54
|
}
|
|
48
55
|
|
|
49
56
|
/** @returns {any} The underlying hyperswarm, or null before ready / after close. */
|
|
@@ -171,6 +178,14 @@ export class Network extends ReadyResource {
|
|
|
171
178
|
})
|
|
172
179
|
swarm.on('peer-add', (peer) => this.emit('peer-add', peer))
|
|
173
180
|
swarm.on('peer-remove', (peer) => this.emit('peer-remove', peer))
|
|
181
|
+
|
|
182
|
+
if (this.store && this.mirrors.length) {
|
|
183
|
+
this._blindPeering = new BlindPeering(swarm.dht, this.store, {
|
|
184
|
+
keys: this.mirrors,
|
|
185
|
+
wakeup: this.wakeup,
|
|
186
|
+
pick: 2
|
|
187
|
+
})
|
|
188
|
+
}
|
|
174
189
|
}
|
|
175
190
|
|
|
176
191
|
/**
|
|
@@ -190,6 +205,7 @@ export class Network extends ReadyResource {
|
|
|
190
205
|
*/
|
|
191
206
|
async suspend() {
|
|
192
207
|
if (this.closing || this.closed) return
|
|
208
|
+
await this._blindPeering?.suspend()
|
|
193
209
|
await this._dht?.suspend()
|
|
194
210
|
}
|
|
195
211
|
|
|
@@ -201,6 +217,7 @@ export class Network extends ReadyResource {
|
|
|
201
217
|
async resume() {
|
|
202
218
|
if (this.closing || this.closed) return
|
|
203
219
|
await this._dht?.resume()
|
|
220
|
+
await this._blindPeering?.resume()
|
|
204
221
|
}
|
|
205
222
|
|
|
206
223
|
async _close() {
|
|
@@ -221,6 +238,15 @@ export class Network extends ReadyResource {
|
|
|
221
238
|
}
|
|
222
239
|
}
|
|
223
240
|
|
|
241
|
+
if (this._blindPeering) {
|
|
242
|
+
try {
|
|
243
|
+
await this._blindPeering.close()
|
|
244
|
+
} catch (err) {
|
|
245
|
+
safetyCatch(err)
|
|
246
|
+
}
|
|
247
|
+
this._blindPeering = null
|
|
248
|
+
}
|
|
249
|
+
|
|
224
250
|
if (this._blind) {
|
|
225
251
|
try {
|
|
226
252
|
await (await this._blind).close()
|
|
@@ -284,6 +310,12 @@ export class Network extends ReadyResource {
|
|
|
284
310
|
if (!core) throw CeroError.REQUIRED('core')
|
|
285
311
|
this._replicateables.add(core)
|
|
286
312
|
for (const stream of this.connections) replicateInto(core, stream)
|
|
313
|
+
// mirror the core so it stays available when its writers are offline —
|
|
314
|
+
// autobees announce their whole writer set, plain cores (blobs) just themselves
|
|
315
|
+
if (this._blindPeering) {
|
|
316
|
+
if (core.wakeupCapability) this._blindPeering.addAutobaseBackground(core)
|
|
317
|
+
else this._blindPeering.addCoreBackground(core)
|
|
318
|
+
}
|
|
287
319
|
}
|
|
288
320
|
|
|
289
321
|
/**
|
|
@@ -18,7 +18,8 @@ const TYPE_HELLO = 4
|
|
|
18
18
|
const SID_LEN = 8
|
|
19
19
|
const HEADER = 1 + SID_LEN
|
|
20
20
|
|
|
21
|
-
const
|
|
21
|
+
const DEFAULT_MAX_OUTBOUND = 4
|
|
22
|
+
const DEFAULT_MAX_INBOUND = 8
|
|
22
23
|
const CONNECT_TIMEOUT = 15000
|
|
23
24
|
// per-peer dial backoff: eager while unlinked, patient once linked; the cooldown
|
|
24
25
|
// grows exponentially per consecutive failure.
|
|
@@ -126,7 +127,8 @@ export class BLETransport extends ReadyResource {
|
|
|
126
127
|
* @param {Uint8Array} opts.uuid The 32-byte topic the service UUID derives from.
|
|
127
128
|
* @param {Uint8Array} opts.nodeId Stable local id (identity/device key) for the initiate tie-break.
|
|
128
129
|
* @param {string} [opts.tag] UUID namespace (channel mesh vs invite mesh).
|
|
129
|
-
* @param {number} [opts.
|
|
130
|
+
* @param {number} [opts.maxOutbound] Max concurrent outbound dials/links; gossip covers the rest.
|
|
131
|
+
* @param {number} [opts.maxInbound] Max concurrent inbound sessions; newcomers past this are refused.
|
|
130
132
|
* @param {{ scanMode?: any }} [opts.scanOptions] Platform scan options (e.g. Android low-power).
|
|
131
133
|
* @param {boolean} [opts.keepLinks] On close, stop the radio but leave established links alive (invite rendezvous: the link outlives the QR and carries the initial replication).
|
|
132
134
|
* @param {string} [opts.name] Local app-user display name, sent to peers over a hello frame.
|
|
@@ -137,7 +139,8 @@ export class BLETransport extends ReadyResource {
|
|
|
137
139
|
uuid,
|
|
138
140
|
nodeId,
|
|
139
141
|
tag = 'cero-ble',
|
|
140
|
-
|
|
142
|
+
maxOutbound = DEFAULT_MAX_OUTBOUND,
|
|
143
|
+
maxInbound = DEFAULT_MAX_INBOUND,
|
|
141
144
|
scanOptions,
|
|
142
145
|
keepLinks = false,
|
|
143
146
|
name
|
|
@@ -149,7 +152,8 @@ export class BLETransport extends ReadyResource {
|
|
|
149
152
|
this.nodeId = nodeId
|
|
150
153
|
this.nodeHex = b4a.toString(nodeId, 'hex')
|
|
151
154
|
this.serviceUUID = toServiceUUID(uuid, tag)
|
|
152
|
-
this.
|
|
155
|
+
this.maxOutbound = maxOutbound
|
|
156
|
+
this.maxInbound = maxInbound
|
|
153
157
|
this.scanOptions = scanOptions
|
|
154
158
|
this.keepLinks = keepLinks
|
|
155
159
|
|
|
@@ -271,6 +275,12 @@ export class BLETransport extends ReadyResource {
|
|
|
271
275
|
if (!f) return
|
|
272
276
|
if (f.type === TYPE_OPEN) {
|
|
273
277
|
if (this._sessions.has(f.sidHex)) return
|
|
278
|
+
if (this._sessions.size >= this.maxInbound) {
|
|
279
|
+
// established links win: refuse newcomers with a CLOSE so the dialer's
|
|
280
|
+
// stream ends cleanly and backs off — the mesh converges transitively.
|
|
281
|
+
this._enqueueNotify(frame(TYPE_CLOSE, f.sid)).catch(safetyCatch)
|
|
282
|
+
return
|
|
283
|
+
}
|
|
274
284
|
const sid = b4a.from(f.sid) // copy: f.sid views the transient request buffer
|
|
275
285
|
const stream = new GattStream({
|
|
276
286
|
send: (payload) => this._enqueueNotify(frame(TYPE_DATA, sid, payload)),
|
|
@@ -414,7 +424,7 @@ export class BLETransport extends ReadyResource {
|
|
|
414
424
|
if (d.coolUntil > Date.now()) return // failed recently — back off
|
|
415
425
|
if (d.timer) return // already connecting to this one
|
|
416
426
|
}
|
|
417
|
-
if (this.linkCount >= this.
|
|
427
|
+
if (this.linkCount >= this.maxOutbound) return // gossip covers the rest
|
|
418
428
|
if (Date.now() - this._lastDial < DIAL_MIN_INTERVAL) return
|
|
419
429
|
// dial every discovery and open a session; a redundant link is dropped by
|
|
420
430
|
// _track's dedup
|
package/src/rpc/index.js
CHANGED
|
@@ -12,6 +12,7 @@ const EMPTY = b4a.alloc(0)
|
|
|
12
12
|
// Default envelope encodings for the @cero namespace, used when the supplied
|
|
13
13
|
// spec does not provide its own rows/query/create types in its schema.
|
|
14
14
|
const DEFAULT_ROWS = getEncoding('@cero/rows')
|
|
15
|
+
const DEFAULT_CHANGES = getEncoding('@cero/changes')
|
|
15
16
|
const DEFAULT_QUERY = getEncoding('@cero/query')
|
|
16
17
|
const DEFAULT_CREATE = getEncoding('@cero/create')
|
|
17
18
|
|
|
@@ -26,6 +27,8 @@ const DEFAULT_CREATE = getEncoding('@cero/create')
|
|
|
26
27
|
* @property {(type: string, row: any) => Uint8Array} encodeRow
|
|
27
28
|
* @property {(type: string, buf: Uint8Array) => any} decodeRow
|
|
28
29
|
* @property {(type: string, rows: any[]) => Uint8Array} encodeRows
|
|
30
|
+
* @property {(type: string, changes: Array<{ prev: any, next: any }>) => Uint8Array} encodeChanges
|
|
31
|
+
* @property {(type: string, buf: Uint8Array) => Array<{ prev: any, next: any }>} decodeChanges
|
|
29
32
|
* @property {(type: string, buf: Uint8Array) => any[]} decodeRows
|
|
30
33
|
* @property {(q: any) => Uint8Array} encodeQuery
|
|
31
34
|
* @property {(buf: Uint8Array) => any} decodeQuery
|
|
@@ -51,6 +54,7 @@ export function bindCodec(spec) {
|
|
|
51
54
|
|
|
52
55
|
const ns = spec.meta?.ns
|
|
53
56
|
const ROWS = ns ? `@${ns}/rows` : null
|
|
57
|
+
const CHANGES = ns ? `@${ns}/changes` : null
|
|
54
58
|
const QUERY = ns ? `@${ns}/query` : null
|
|
55
59
|
const CREATE = ns ? `@${ns}/create` : null
|
|
56
60
|
|
|
@@ -73,6 +77,25 @@ export function bindCodec(spec) {
|
|
|
73
77
|
const data = env?.data || []
|
|
74
78
|
return data.map((b) => schema.decode(type, b))
|
|
75
79
|
},
|
|
80
|
+
encodeChanges(type, changes) {
|
|
81
|
+
const prev = changes.map((x) => (x.prev ? schema.encode(type, x.prev) : EMPTY))
|
|
82
|
+
const next = changes.map((x) => (x.next ? schema.encode(type, x.next) : EMPTY))
|
|
83
|
+
return encodeEnvelope(schema, CHANGES, DEFAULT_CHANGES, { prev, next })
|
|
84
|
+
},
|
|
85
|
+
decodeChanges(type, buf) {
|
|
86
|
+
if (!buf || buf.length === 0) return []
|
|
87
|
+
const env = decodeEnvelope(schema, CHANGES, DEFAULT_CHANGES, buf)
|
|
88
|
+
const prev = env?.prev || []
|
|
89
|
+
const next = env?.next || []
|
|
90
|
+
const out = []
|
|
91
|
+
for (let i = 0; i < Math.max(prev.length, next.length); i++) {
|
|
92
|
+
out.push({
|
|
93
|
+
prev: prev[i]?.length ? schema.decode(type, prev[i]) : null,
|
|
94
|
+
next: next[i]?.length ? schema.decode(type, next[i]) : null
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
return out
|
|
98
|
+
},
|
|
76
99
|
encodeQuery(q) {
|
|
77
100
|
if (q == null) return encodeEnvelope(schema, QUERY, DEFAULT_QUERY, {})
|
|
78
101
|
const { gt, gte, lt, lte, limit, reverse, ...rest } = q
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pull-driven delta stream over a collection: batches of `{ prev, next }`
|
|
3
|
+
* pairs diffed between per-stream snapshot cursors. Update ticks only mark
|
|
4
|
+
* the stream dirty — the diff runs when the reader demands, so backpressure
|
|
5
|
+
* folds bursts into one bigger batch and nothing is ever dropped. The first
|
|
6
|
+
* batch, and any batch after the view is swapped or fast-forwarded to a new
|
|
7
|
+
* core, carries the full matching state as inserts with `reset: true`.
|
|
8
|
+
*
|
|
9
|
+
* @param {import('./index.js').Database} db
|
|
10
|
+
* @param {string} name Ref name (scopes the update ticks).
|
|
11
|
+
* @param {string} col Collection path (`@ns/name`).
|
|
12
|
+
* @param {(row: any) => boolean} matches
|
|
13
|
+
* @returns {import('streamx').Readable}
|
|
14
|
+
*/
|
|
15
|
+
export function makeChanges(db: import("./index.js").Database, name: string, col: string, matches: (row: any) => boolean): import("streamx").Readable;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prefix an encoded op with the app's contract version.
|
|
3
|
+
*
|
|
4
|
+
* @param {number} version
|
|
5
|
+
* @param {Uint8Array} body
|
|
6
|
+
* @returns {Uint8Array}
|
|
7
|
+
*/
|
|
8
|
+
export function wrap(version: number, body: Uint8Array): Uint8Array;
|
|
9
|
+
/**
|
|
10
|
+
* Split an op into contract version and payload. Ops written before the
|
|
11
|
+
* envelope existed carry no sentinel and read as version 0.
|
|
12
|
+
*
|
|
13
|
+
* @param {Uint8Array} buf
|
|
14
|
+
* @returns {{ version: number, body: Uint8Array }}
|
|
15
|
+
*/
|
|
16
|
+
export function unwrap(buf: Uint8Array): {
|
|
17
|
+
version: number;
|
|
18
|
+
body: Uint8Array;
|
|
19
|
+
};
|
|
@@ -54,6 +54,8 @@ export class Database extends ReadyResource {
|
|
|
54
54
|
kind?: string;
|
|
55
55
|
verb?: string;
|
|
56
56
|
}>;
|
|
57
|
+
version: any;
|
|
58
|
+
behind: any;
|
|
57
59
|
routes: Record<string, Function>;
|
|
58
60
|
namespace: string;
|
|
59
61
|
encryptionKey: Uint8Array<ArrayBufferLike>;
|
|
@@ -216,6 +218,24 @@ export class Database extends ReadyResource {
|
|
|
216
218
|
* @returns {Promise<void>}
|
|
217
219
|
*/
|
|
218
220
|
write(ops: Array<[string, any]>): Promise<void>;
|
|
221
|
+
/**
|
|
222
|
+
* Record that the log contains ops from a newer app version than this
|
|
223
|
+
* peer understands. Fires `'behind'` once per version so apps can prompt
|
|
224
|
+
* an upgrade; the marker survives restarts via local core userData.
|
|
225
|
+
*
|
|
226
|
+
* @param {number} version
|
|
227
|
+
*/
|
|
228
|
+
_onFuture(version: number): void;
|
|
229
|
+
/**
|
|
230
|
+
* Validate ops against the committed view before appending: each op runs in
|
|
231
|
+
* a throwaway transaction with host effects stubbed, so a throwing handler
|
|
232
|
+
* rejects the write and nothing enters the permanent log. Permission gates
|
|
233
|
+
* that `return` are not rejections — apply stays the authority at
|
|
234
|
+
* linearization time.
|
|
235
|
+
*
|
|
236
|
+
* @param {Uint8Array[]} encoded
|
|
237
|
+
*/
|
|
238
|
+
_dryRun(encoded: Uint8Array[]): Promise<void>;
|
|
219
239
|
/**
|
|
220
240
|
* Read a row. With no `query`: list all (collection) or fetch the one
|
|
221
241
|
* record (single). With a string id: fetch that specific row.
|
|
@@ -247,6 +267,18 @@ export class Database extends ReadyResource {
|
|
|
247
267
|
* @returns {import('streamx').Readable}
|
|
248
268
|
*/
|
|
249
269
|
watch(name: string, query?: Query): import("streamx").Readable;
|
|
270
|
+
/**
|
|
271
|
+
* Delta subscription: batches of `{ prev, next }` row pairs instead of
|
|
272
|
+
* full snapshots. The first batch (and any batch after a view swap) carries
|
|
273
|
+
* the current matching rows as inserts with `reset: true` — replaying every
|
|
274
|
+
* batch into a Map keyed by row id always reconstructs current state.
|
|
275
|
+
* `limit`/`reverse` are not applied; deltas are unwindowed by design.
|
|
276
|
+
*
|
|
277
|
+
* @param {string} name
|
|
278
|
+
* @param {Query} [query]
|
|
279
|
+
* @returns {import('streamx').Readable}
|
|
280
|
+
*/
|
|
281
|
+
changes(name: string, query?: Query): import("streamx").Readable;
|
|
250
282
|
/**
|
|
251
283
|
* First-run bootstrap: create the device writer, save it, and swap into it.
|
|
252
284
|
*
|
|
@@ -2,12 +2,8 @@ export function resolveStruct(name: any, v?: number): {
|
|
|
2
2
|
preencode(state: any, m: any): void;
|
|
3
3
|
encode(state: any, m: any): void;
|
|
4
4
|
decode(state: any): {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
blockLength: any;
|
|
8
|
-
byteOffset: any;
|
|
9
|
-
byteLength: any;
|
|
10
|
-
type: any;
|
|
5
|
+
prev: any;
|
|
6
|
+
next: any;
|
|
11
7
|
} | {
|
|
12
8
|
status: any;
|
|
13
9
|
reason: any;
|
|
@@ -21,18 +17,21 @@ export function resolveStruct(name: any, v?: number): {
|
|
|
21
17
|
name: any;
|
|
22
18
|
role: any;
|
|
23
19
|
noAccept: boolean;
|
|
24
|
-
}
|
|
25
|
-
};
|
|
26
|
-
export function getStruct(name: any, v?: number): {
|
|
27
|
-
preencode(state: any, m: any): void;
|
|
28
|
-
encode(state: any, m: any): void;
|
|
29
|
-
decode(state: any): {
|
|
20
|
+
} | {
|
|
30
21
|
coreKey: any;
|
|
31
22
|
blockOffset: any;
|
|
32
23
|
blockLength: any;
|
|
33
24
|
byteOffset: any;
|
|
34
25
|
byteLength: any;
|
|
35
26
|
type: any;
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
export function getStruct(name: any, v?: number): {
|
|
30
|
+
preencode(state: any, m: any): void;
|
|
31
|
+
encode(state: any, m: any): void;
|
|
32
|
+
decode(state: any): {
|
|
33
|
+
prev: any;
|
|
34
|
+
next: any;
|
|
36
35
|
} | {
|
|
37
36
|
status: any;
|
|
38
37
|
reason: any;
|
|
@@ -46,6 +45,13 @@ export function getStruct(name: any, v?: number): {
|
|
|
46
45
|
name: any;
|
|
47
46
|
role: any;
|
|
48
47
|
noAccept: boolean;
|
|
48
|
+
} | {
|
|
49
|
+
coreKey: any;
|
|
50
|
+
blockOffset: any;
|
|
51
|
+
blockLength: any;
|
|
52
|
+
byteOffset: any;
|
|
53
|
+
byteLength: any;
|
|
54
|
+
type: any;
|
|
49
55
|
};
|
|
50
56
|
};
|
|
51
57
|
export function getEnum(name: any): void;
|
|
@@ -85,6 +91,13 @@ export function getEncoding(name: any): {
|
|
|
85
91
|
byteLength: any;
|
|
86
92
|
type: any;
|
|
87
93
|
};
|
|
94
|
+
} | {
|
|
95
|
+
preencode(state: any, m: any): void;
|
|
96
|
+
encode(state: any, m: any): void;
|
|
97
|
+
decode(state: any): {
|
|
98
|
+
prev: any;
|
|
99
|
+
next: any;
|
|
100
|
+
};
|
|
88
101
|
};
|
|
89
102
|
export function encode(name: any, value: any, v?: number): any;
|
|
90
103
|
export function decode(name: any, buffer: any, v?: number): any;
|
package/types/network/index.d.ts
CHANGED
|
@@ -6,6 +6,8 @@ export { channelTopic };
|
|
|
6
6
|
* @property {(remotePublicKey: Uint8Array, payload: any) => boolean} [firewall] Incoming-connection filter.
|
|
7
7
|
* @property {Uint8Array[]} [relayThrough] Relay public keys to tunnel through.
|
|
8
8
|
* @property {string} [channel] Optional network-isolation label; only same-channel peers meet.
|
|
9
|
+
* @property {any} [store] Corestore; required for mirrors (blind peers replicate its cores).
|
|
10
|
+
* @property {Array<string | Uint8Array>} [mirrors] Blind-peer public keys; each attached room/blob core is mirrored through them for offline sync.
|
|
9
11
|
*
|
|
10
12
|
* @typedef {{ replicate: (stream: any) => any }} Replicable
|
|
11
13
|
*/
|
|
@@ -15,7 +17,7 @@ export { channelTopic };
|
|
|
15
17
|
*/
|
|
16
18
|
export class Network extends ReadyResource {
|
|
17
19
|
/** @param {NetworkOpts} [opts] */
|
|
18
|
-
constructor({ identity, bootstrap, firewall, relayThrough, channel }?: NetworkOpts);
|
|
20
|
+
constructor({ identity, bootstrap, firewall, relayThrough, channel, store, mirrors }?: NetworkOpts);
|
|
19
21
|
identity: import("../index.js").Identity;
|
|
20
22
|
bootstrap: {
|
|
21
23
|
host: string;
|
|
@@ -24,12 +26,15 @@ export class Network extends ReadyResource {
|
|
|
24
26
|
firewall: (remotePublicKey: Uint8Array, payload: any) => boolean;
|
|
25
27
|
relayThrough: Uint8Array<ArrayBufferLike>[];
|
|
26
28
|
channel: string;
|
|
29
|
+
store: any;
|
|
30
|
+
mirrors: any[];
|
|
27
31
|
_dht: DHTTransport;
|
|
28
32
|
wakeup: any;
|
|
29
33
|
_replicateables: Set<any>;
|
|
30
34
|
_discoveries: Set<any>;
|
|
31
35
|
_injected: Set<any>;
|
|
32
36
|
_blind: any;
|
|
37
|
+
_blindPeering: any;
|
|
33
38
|
/** @returns {any} The underlying hyperswarm, or null before ready / after close. */
|
|
34
39
|
get swarm(): any;
|
|
35
40
|
/**
|
|
@@ -151,6 +156,14 @@ export type NetworkOpts = {
|
|
|
151
156
|
* Optional network-isolation label; only same-channel peers meet.
|
|
152
157
|
*/
|
|
153
158
|
channel?: string;
|
|
159
|
+
/**
|
|
160
|
+
* Corestore; required for mirrors (blind peers replicate its cores).
|
|
161
|
+
*/
|
|
162
|
+
store?: any;
|
|
163
|
+
/**
|
|
164
|
+
* Blind-peer public keys; each attached room/blob core is mirrored through them for offline sync.
|
|
165
|
+
*/
|
|
166
|
+
mirrors?: Array<string | Uint8Array>;
|
|
154
167
|
};
|
|
155
168
|
export type Replicable = {
|
|
156
169
|
replicate: (stream: any) => any;
|
|
@@ -28,18 +28,20 @@ export class BLETransport extends ReadyResource {
|
|
|
28
28
|
* @param {Uint8Array} opts.uuid The 32-byte topic the service UUID derives from.
|
|
29
29
|
* @param {Uint8Array} opts.nodeId Stable local id (identity/device key) for the initiate tie-break.
|
|
30
30
|
* @param {string} [opts.tag] UUID namespace (channel mesh vs invite mesh).
|
|
31
|
-
* @param {number} [opts.
|
|
31
|
+
* @param {number} [opts.maxOutbound] Max concurrent outbound dials/links; gossip covers the rest.
|
|
32
|
+
* @param {number} [opts.maxInbound] Max concurrent inbound sessions; newcomers past this are refused.
|
|
32
33
|
* @param {{ scanMode?: any }} [opts.scanOptions] Platform scan options (e.g. Android low-power).
|
|
33
34
|
* @param {boolean} [opts.keepLinks] On close, stop the radio but leave established links alive (invite rendezvous: the link outlives the QR and carries the initial replication).
|
|
34
35
|
* @param {string} [opts.name] Local app-user display name, sent to peers over a hello frame.
|
|
35
36
|
*/
|
|
36
|
-
constructor({ backend, network, uuid, nodeId, tag,
|
|
37
|
+
constructor({ backend, network, uuid, nodeId, tag, maxOutbound, maxInbound, scanOptions, keepLinks, name }: {
|
|
37
38
|
backend: any;
|
|
38
39
|
network: import("../index.js").Network;
|
|
39
40
|
uuid: Uint8Array;
|
|
40
41
|
nodeId: Uint8Array;
|
|
41
42
|
tag?: string;
|
|
42
|
-
|
|
43
|
+
maxOutbound?: number;
|
|
44
|
+
maxInbound?: number;
|
|
43
45
|
scanOptions?: {
|
|
44
46
|
scanMode?: any;
|
|
45
47
|
};
|
|
@@ -52,7 +54,8 @@ export class BLETransport extends ReadyResource {
|
|
|
52
54
|
nodeId: Uint8Array<ArrayBufferLike>;
|
|
53
55
|
nodeHex: any;
|
|
54
56
|
serviceUUID: string;
|
|
55
|
-
|
|
57
|
+
maxOutbound: number;
|
|
58
|
+
maxInbound: number;
|
|
56
59
|
scanOptions: {
|
|
57
60
|
scanMode?: any;
|
|
58
61
|
};
|
package/types/rpc/index.d.ts
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
* @property {(type: string, row: any) => Uint8Array} encodeRow
|
|
10
10
|
* @property {(type: string, buf: Uint8Array) => any} decodeRow
|
|
11
11
|
* @property {(type: string, rows: any[]) => Uint8Array} encodeRows
|
|
12
|
+
* @property {(type: string, changes: Array<{ prev: any, next: any }>) => Uint8Array} encodeChanges
|
|
13
|
+
* @property {(type: string, buf: Uint8Array) => Array<{ prev: any, next: any }>} decodeChanges
|
|
12
14
|
* @property {(type: string, buf: Uint8Array) => any[]} decodeRows
|
|
13
15
|
* @property {(q: any) => Uint8Array} encodeQuery
|
|
14
16
|
* @property {(buf: Uint8Array) => any} decodeQuery
|
|
@@ -57,6 +59,14 @@ export type Codec = {
|
|
|
57
59
|
encodeRow: (type: string, row: any) => Uint8Array;
|
|
58
60
|
decodeRow: (type: string, buf: Uint8Array) => any;
|
|
59
61
|
encodeRows: (type: string, rows: any[]) => Uint8Array;
|
|
62
|
+
encodeChanges: (type: string, changes: Array<{
|
|
63
|
+
prev: any;
|
|
64
|
+
next: any;
|
|
65
|
+
}>) => Uint8Array;
|
|
66
|
+
decodeChanges: (type: string, buf: Uint8Array) => Array<{
|
|
67
|
+
prev: any;
|
|
68
|
+
next: any;
|
|
69
|
+
}>;
|
|
60
70
|
decodeRows: (type: string, buf: Uint8Array) => any[];
|
|
61
71
|
encodeQuery: (q: any) => Uint8Array;
|
|
62
72
|
decodeQuery: (buf: Uint8Array) => any;
|