@cero-base/core 0.8.9 → 1.0.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 -9
- package/src/blobs/codec.js +52 -0
- package/src/blobs/index.js +42 -164
- package/src/blobs/server.js +48 -0
- package/src/database/bootstrap.js +24 -12
- package/src/database/dispatch.js +91 -17
- package/src/database/index.js +112 -26
- package/src/identity/index.js +1 -1
- package/src/index.js +25 -2
- package/src/lib/constants.js +21 -1
- package/src/lib/schema.js +2 -0
- package/src/lib/spec/index.js +106 -120
- package/src/lib/spec/schema.json +74 -80
- package/src/lib/utils.js +33 -2
- package/src/network/index.js +1 -1
- package/src/pairing/index.js +57 -56
- package/src/pairing/invite.js +19 -18
- package/src/rpc/client.js +4 -42
- package/src/rpc/index.js +8 -4
- package/src/rpc/peer.js +46 -0
- package/src/rpc/server.js +4 -42
- package/src/storage/index.js +16 -6
- package/types/blobs/codec.d.ts +26 -0
- package/types/blobs/index.d.ts +25 -90
- package/types/blobs/server.d.ts +35 -0
- package/types/database/bootstrap.d.ts +1 -1
- package/types/database/dispatch.d.ts +26 -3
- package/types/database/index.d.ts +53 -17
- package/types/index.d.ts +50 -2
- package/types/lib/constants.d.ts +24 -1
- package/types/lib/schema.d.ts +1 -0
- package/types/lib/spec/index.d.ts +9 -12
- package/types/lib/utils.d.ts +12 -2
- package/types/pairing/index.d.ts +15 -4
- package/types/pairing/invite.d.ts +11 -11
- package/types/rpc/client.d.ts +4 -15
- package/types/rpc/peer.d.ts +18 -0
- package/types/rpc/server.d.ts +4 -15
- package/types/storage/index.d.ts +9 -4
package/src/database/dispatch.js
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
1
|
+
import b4a from 'b4a'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
SINGLE,
|
|
5
|
+
COLLECTION,
|
|
6
|
+
ACTION,
|
|
7
|
+
COUNTERS,
|
|
8
|
+
INVITE,
|
|
9
|
+
REMOVE,
|
|
10
|
+
ASSIGN,
|
|
11
|
+
WRITE
|
|
12
|
+
} from '../lib/constants.js'
|
|
13
|
+
import { toId, toKey, can, grants, outranks } from '../lib/utils.js'
|
|
3
14
|
import { Identity } from '../identity/index.js'
|
|
4
15
|
|
|
5
16
|
// Built-in collections wired into every cero spec.
|
|
@@ -7,15 +18,43 @@ export const BUILTINS = [
|
|
|
7
18
|
{ name: 'members', verb: 'member' },
|
|
8
19
|
{ name: 'devices', verb: 'device' },
|
|
9
20
|
{ name: 'invites', verb: 'invite' },
|
|
10
|
-
{ name: 'handles', verb: 'handle' }
|
|
21
|
+
{ name: 'handles', verb: 'handle' },
|
|
22
|
+
{ name: 'files', verb: 'file' }
|
|
11
23
|
]
|
|
12
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Build the hyperdispatch router for a spec: wires membership, builtin, and
|
|
27
|
+
* spec-defined collection/action ops, returning the router plus an apply loop.
|
|
28
|
+
*
|
|
29
|
+
* @param {{ dispatch: { Router: Function }, meta?: { refs?: Record<string, { kind?: string, builtin?: boolean, verb?: string }> } }} spec Generated hyperdispatch spec.
|
|
30
|
+
* @param {string} ns Namespace prefix for collection and op names.
|
|
31
|
+
* @param {Record<string, Function>} routes Custom action handlers keyed by route name.
|
|
32
|
+
* @returns {{ dispatcher: object, apply: (nodes: Array<{ value: Buffer, key: Buffer }>, view: object, host: object) => Promise<void> }}
|
|
33
|
+
*/
|
|
13
34
|
export function makeDispatcher(spec, ns, routes) {
|
|
14
35
|
const dispatcher = new spec.dispatch.Router()
|
|
15
36
|
|
|
16
37
|
const countersCol = `@${ns}/${COUNTERS}`
|
|
17
|
-
const
|
|
18
|
-
const
|
|
38
|
+
const getMember = (view, id) => view.get(`@${ns}/members`, { id })
|
|
39
|
+
const getDevice = (view, id) => view.get(`@${ns}/devices`, { id })
|
|
40
|
+
|
|
41
|
+
const getRole = async (view, identityKey) =>
|
|
42
|
+
identityKey ? ((await getMember(view, toId(identityKey)))?.role ?? null) : null
|
|
43
|
+
|
|
44
|
+
const getSignerRole = async (view, writerKey) => {
|
|
45
|
+
if (!writerKey) return null
|
|
46
|
+
const d = await getDevice(view, toId(writerKey))
|
|
47
|
+
return d?.memberId ? ((await getMember(view, d.memberId))?.role ?? null) : null
|
|
48
|
+
}
|
|
49
|
+
const isGenesis = async (view) => !(await view.findOne(`@${ns}/members`, {}))
|
|
50
|
+
const getSignerMember = async (view, key) =>
|
|
51
|
+
(key ? (await getDevice(view, toId(key)))?.memberId : null) ?? null
|
|
52
|
+
const canRemoveMember = async (view, signerKey, targetMemberId) => {
|
|
53
|
+
const r = await getSignerRole(view, signerKey)
|
|
54
|
+
if (!can(r, REMOVE)) return false
|
|
55
|
+
const tRole = targetMemberId ? (await getMember(view, targetMemberId))?.role : null
|
|
56
|
+
return !tRole || outranks(r, tRole)
|
|
57
|
+
}
|
|
19
58
|
|
|
20
59
|
async function insert(view, name, col, op) {
|
|
21
60
|
if (name !== COUNTERS) {
|
|
@@ -32,7 +71,7 @@ export function makeDispatcher(spec, ns, routes) {
|
|
|
32
71
|
}
|
|
33
72
|
|
|
34
73
|
const upsert = (name, col, kind) => async (op, ctx) => {
|
|
35
|
-
const d = ctx.key && (await
|
|
74
|
+
const d = ctx.key && (await getDevice(ctx.view, toId(ctx.key)))
|
|
36
75
|
op.memberId = d?.memberId || null
|
|
37
76
|
if (kind === COLLECTION) await insert(ctx.view, name, col, op)
|
|
38
77
|
else await ctx.view.insert(col, op)
|
|
@@ -46,7 +85,8 @@ export function makeDispatcher(spec, ns, routes) {
|
|
|
46
85
|
const add = (verb, fn) => dispatcher.add(`@${ns}/${verb}`, fn)
|
|
47
86
|
|
|
48
87
|
add('add-writer', async (op, ctx) => {
|
|
49
|
-
if (!Identity.verify(op.master, op.writer, op.sig)) return
|
|
88
|
+
if (!Identity.verify(op.master, b4a.concat([op.writer, ctx.key]), op.sig)) return
|
|
89
|
+
if (!(await isGenesis(ctx.view)) && !can(await getRole(ctx.view, op.master), INVITE)) return
|
|
50
90
|
await ctx.host.addWriter(op.writer, { isIndexer: op.isIndexer !== false })
|
|
51
91
|
const ts = Date.now()
|
|
52
92
|
await insert(ctx.view, 'devices', `@${ns}/devices`, {
|
|
@@ -58,15 +98,19 @@ export function makeDispatcher(spec, ns, routes) {
|
|
|
58
98
|
})
|
|
59
99
|
|
|
60
100
|
add('del-writer', async (op, ctx) => {
|
|
61
|
-
|
|
101
|
+
const target = await getDevice(ctx.view, toId(op.writer))
|
|
102
|
+
if (target?.memberId !== (await getSignerMember(ctx.view, ctx.key))) {
|
|
103
|
+
if (!(await canRemoveMember(ctx.view, ctx.key, target?.memberId))) return
|
|
104
|
+
}
|
|
62
105
|
await ctx.host.removeWriter(op.writer)
|
|
63
106
|
await ctx.view.delete(`@${ns}/devices`, { id: toId(op.writer) })
|
|
64
107
|
})
|
|
65
108
|
|
|
66
109
|
add('claim-writer', async (op, ctx) => {
|
|
67
110
|
if (!Identity.verify(op.identity, op.writer, op.sig)) return
|
|
111
|
+
if (!b4a.equals(ctx.key, op.writer)) return
|
|
68
112
|
const memberId = toId(op.identity)
|
|
69
|
-
if (!(await
|
|
113
|
+
if (!can(await getRole(ctx.view, op.identity), WRITE)) return
|
|
70
114
|
await ctx.host.addWriter(op.writer, { isIndexer: true })
|
|
71
115
|
const ts = Date.now()
|
|
72
116
|
await insert(ctx.view, 'devices', `@${ns}/devices`, {
|
|
@@ -78,9 +122,13 @@ export function makeDispatcher(spec, ns, routes) {
|
|
|
78
122
|
})
|
|
79
123
|
|
|
80
124
|
add('add-member', async (op, ctx) => {
|
|
125
|
+
if (!(await isGenesis(ctx.view))) {
|
|
126
|
+
const r = await getSignerRole(ctx.view, ctx.key)
|
|
127
|
+
if (!can(r, INVITE) || !grants(r, op.role)) return
|
|
128
|
+
}
|
|
81
129
|
await insert(ctx.view, 'members', `@${ns}/members`, op)
|
|
82
130
|
const deviceId = toId(op.key)
|
|
83
|
-
const existingDevice = await
|
|
131
|
+
const existingDevice = await getDevice(ctx.view, deviceId)
|
|
84
132
|
const ts = Date.now()
|
|
85
133
|
await insert(ctx.view, 'devices', `@${ns}/devices`, {
|
|
86
134
|
id: deviceId,
|
|
@@ -93,21 +141,35 @@ export function makeDispatcher(spec, ns, routes) {
|
|
|
93
141
|
})
|
|
94
142
|
|
|
95
143
|
add('set-member', async (op, ctx) => {
|
|
96
|
-
const existing = await
|
|
144
|
+
const existing = await getMember(ctx.view, op.id)
|
|
97
145
|
if (!existing) return
|
|
98
|
-
|
|
146
|
+
const next = { ...existing, ...op }
|
|
147
|
+
if (next.role !== existing.role) {
|
|
148
|
+
const r = await getSignerRole(ctx.view, ctx.key)
|
|
149
|
+
if (!can(r, ASSIGN) || !grants(r, next.role) || !outranks(r, existing.role)) {
|
|
150
|
+
next.role = existing.role
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
await insert(ctx.view, 'members', `@${ns}/members`, next)
|
|
99
154
|
})
|
|
100
155
|
|
|
101
156
|
add('del-member', async (op, ctx) => {
|
|
102
|
-
const existing = await
|
|
157
|
+
const existing = await getMember(ctx.view, op.id)
|
|
103
158
|
if (!existing) return
|
|
159
|
+
if (op.id !== (await getSignerMember(ctx.view, ctx.key))) {
|
|
160
|
+
const r = await getSignerRole(ctx.view, ctx.key)
|
|
161
|
+
if (!can(r, REMOVE) || !outranks(r, existing.role)) return
|
|
162
|
+
}
|
|
104
163
|
if (existing.key) await ctx.host.removeWriter(existing.key)
|
|
105
164
|
await ctx.view.delete(`@${ns}/members`, op)
|
|
106
165
|
})
|
|
107
166
|
|
|
108
167
|
add('del-device', async (op, ctx) => {
|
|
109
|
-
const existing = await
|
|
168
|
+
const existing = await getDevice(ctx.view, op.id)
|
|
110
169
|
if (!existing) return
|
|
170
|
+
if (existing.memberId !== (await getSignerMember(ctx.view, ctx.key))) {
|
|
171
|
+
if (!(await canRemoveMember(ctx.view, ctx.key, existing.memberId))) return
|
|
172
|
+
}
|
|
111
173
|
await ctx.host.removeWriter(toKey(existing.id))
|
|
112
174
|
await ctx.view.delete(`@${ns}/devices`, op)
|
|
113
175
|
})
|
|
@@ -120,7 +182,7 @@ export function makeDispatcher(spec, ns, routes) {
|
|
|
120
182
|
await insert(ctx.view, b.name, col, { ...op, memberId: op.memberId ?? null })
|
|
121
183
|
})
|
|
122
184
|
add(`set-${b.verb}`, async (op, ctx) => {
|
|
123
|
-
const existing = await
|
|
185
|
+
const existing = await getDevice(ctx.view, op.id)
|
|
124
186
|
const ts = Date.now()
|
|
125
187
|
await insert(ctx.view, b.name, col, {
|
|
126
188
|
...op,
|
|
@@ -131,7 +193,15 @@ export function makeDispatcher(spec, ns, routes) {
|
|
|
131
193
|
})
|
|
132
194
|
continue
|
|
133
195
|
}
|
|
134
|
-
|
|
196
|
+
if (b.verb === 'file') {
|
|
197
|
+
add('add-file', async (op, ctx) => {
|
|
198
|
+
if (!can(await getSignerRole(ctx.view, ctx.key), WRITE)) return
|
|
199
|
+
const memberId = await getSignerMember(ctx.view, ctx.key)
|
|
200
|
+
await insert(ctx.view, b.name, col, { id: op.id, name: op.name ?? null, memberId })
|
|
201
|
+
})
|
|
202
|
+
} else {
|
|
203
|
+
add(`add-${b.verb}`, upsert(b.name, col, COLLECTION))
|
|
204
|
+
}
|
|
135
205
|
add(`set-${b.verb}`, update(b.name, col))
|
|
136
206
|
add(`del-${b.verb}`, remove(col))
|
|
137
207
|
}
|
|
@@ -146,7 +216,11 @@ export function makeDispatcher(spec, ns, routes) {
|
|
|
146
216
|
const col = `@${ns}/${name}`
|
|
147
217
|
const kind = info.kind === SINGLE ? SINGLE : COLLECTION
|
|
148
218
|
add(`set-${name}`, kind === SINGLE ? upsert(name, col, kind) : update(name, col))
|
|
149
|
-
if (info.kind === SINGLE)
|
|
219
|
+
if (info.kind === SINGLE) {
|
|
220
|
+
// wipe the keyless single row (the dummy id in the op is ignored)
|
|
221
|
+
add(`del-${name}`, async (op, ctx) => ctx.view.delete(col, {}))
|
|
222
|
+
continue
|
|
223
|
+
}
|
|
150
224
|
add(`add-${name}`, upsert(name, col, COLLECTION))
|
|
151
225
|
add(`del-${name}`, remove(col))
|
|
152
226
|
}
|
package/src/database/index.js
CHANGED
|
@@ -5,7 +5,7 @@ import ReadyResource from 'ready-resource'
|
|
|
5
5
|
import safetyCatch from 'safety-catch'
|
|
6
6
|
import b4a from 'b4a'
|
|
7
7
|
|
|
8
|
-
import { NAMESPACE, SINGLE, COLLECTION } from '../lib/constants.js'
|
|
8
|
+
import { NAMESPACE, SINGLE, COLLECTION, ACTION } from '../lib/constants.js'
|
|
9
9
|
import { genId, subscribe } from '../lib/utils.js'
|
|
10
10
|
import { CeroError } from '../lib/errors.js'
|
|
11
11
|
import { bootstrap } from './bootstrap.js'
|
|
@@ -23,6 +23,7 @@ import { makeDispatcher } from './dispatch.js'
|
|
|
23
23
|
* @property {(nodes: any, view: any, host: any) => Promise<void>} [apply] Override the default apply function.
|
|
24
24
|
* @property {Uint8Array | null} [key] Existing autobee key to reopen.
|
|
25
25
|
* @property {import('../identity/index.js').KeyPair} [keyPair] Device writer keypair; defaults to identity's keypair.
|
|
26
|
+
* @property {(err: Error) => void} [onerror] Called when a background after-hook or onApply callback fails.
|
|
26
27
|
*
|
|
27
28
|
* @typedef {{ data: any | null }} SingleResult
|
|
28
29
|
* @typedef {{ data: any[], total: number, size: number }} ListResult
|
|
@@ -65,6 +66,7 @@ export class Database extends ReadyResource {
|
|
|
65
66
|
secretKey: opts.identity.secretKey
|
|
66
67
|
}
|
|
67
68
|
|
|
69
|
+
this._onerror = opts.onerror || safetyCatch
|
|
68
70
|
this.bee = null
|
|
69
71
|
this.dispatcher = null
|
|
70
72
|
|
|
@@ -89,6 +91,9 @@ export class Database extends ReadyResource {
|
|
|
89
91
|
this._discovery = null
|
|
90
92
|
}
|
|
91
93
|
if (this.bee) {
|
|
94
|
+
// detach before closing — else the closed bee lingers in the network's
|
|
95
|
+
// _replicateables set and gets replicated into every new connection
|
|
96
|
+
if (this.network) this.network.detach(this.bee)
|
|
92
97
|
await this.bee.close()
|
|
93
98
|
this.bee = null
|
|
94
99
|
}
|
|
@@ -126,26 +131,34 @@ export class Database extends ReadyResource {
|
|
|
126
131
|
|
|
127
132
|
if (this.network) {
|
|
128
133
|
this.network.attach(bee)
|
|
134
|
+
// a writer swap re-opens the bee — tear down the prior discovery session
|
|
135
|
+
// first so it isn't orphaned by the reassignment below
|
|
136
|
+
if (this._discovery) await this._discovery.destroy()
|
|
129
137
|
this._discovery = this.network.join(bee.discoveryKey)
|
|
130
138
|
}
|
|
131
139
|
}
|
|
132
140
|
|
|
141
|
+
/** @returns {Uint8Array | null} discovery key of the underlying bee */
|
|
133
142
|
get discoveryKey() {
|
|
134
143
|
return this.bee?.discoveryKey || null
|
|
135
144
|
}
|
|
136
145
|
|
|
146
|
+
/** @returns {Uint8Array | null} this device's local writer key */
|
|
137
147
|
get writerKey() {
|
|
138
148
|
return this.bee?.local?.key || null
|
|
139
149
|
}
|
|
140
150
|
|
|
151
|
+
/** @returns {boolean} whether the bee accepts local writes */
|
|
141
152
|
get writable() {
|
|
142
153
|
return this.bee?.writable === true
|
|
143
154
|
}
|
|
144
155
|
|
|
156
|
+
/** @returns {number} number of ops in the local writer */
|
|
145
157
|
get length() {
|
|
146
158
|
return this.bee?.local?.length || 0
|
|
147
159
|
}
|
|
148
160
|
|
|
161
|
+
/** @returns {object | null} the materialized HyperDB view */
|
|
149
162
|
get view() {
|
|
150
163
|
return this.bee?.view || null
|
|
151
164
|
}
|
|
@@ -220,7 +233,7 @@ export class Database extends ReadyResource {
|
|
|
220
233
|
try {
|
|
221
234
|
fn(event)
|
|
222
235
|
} catch (err) {
|
|
223
|
-
|
|
236
|
+
this._onerror(err)
|
|
224
237
|
}
|
|
225
238
|
}
|
|
226
239
|
}
|
|
@@ -259,7 +272,7 @@ export class Database extends ReadyResource {
|
|
|
259
272
|
try {
|
|
260
273
|
await fn(ctx)
|
|
261
274
|
} catch (err) {
|
|
262
|
-
|
|
275
|
+
this._onerror(err)
|
|
263
276
|
}
|
|
264
277
|
}
|
|
265
278
|
}
|
|
@@ -276,6 +289,7 @@ export class Database extends ReadyResource {
|
|
|
276
289
|
async put(name, row) {
|
|
277
290
|
this.guard()
|
|
278
291
|
const ref = this.ref(name)
|
|
292
|
+
this._checkFields(name, row)
|
|
279
293
|
const id = row.id || genId()
|
|
280
294
|
const ts = Date.now()
|
|
281
295
|
const stored = { createdAt: ts, updatedAt: ts, ...row, id }
|
|
@@ -303,6 +317,7 @@ export class Database extends ReadyResource {
|
|
|
303
317
|
async set(name, row, { upsert = true } = {}) {
|
|
304
318
|
this.guard()
|
|
305
319
|
const ref = this.ref(name)
|
|
320
|
+
this._checkFields(name, row)
|
|
306
321
|
const col = this.col(ref)
|
|
307
322
|
const ts = Date.now()
|
|
308
323
|
const existing =
|
|
@@ -347,7 +362,10 @@ export class Database extends ReadyResource {
|
|
|
347
362
|
const ctx = { op: 'del', name, id }
|
|
348
363
|
if ((await this.runBefore('del', ctx)) === false) return null
|
|
349
364
|
|
|
350
|
-
|
|
365
|
+
// Singles have no id — the apply handler wipes the keyless row; the dummy id
|
|
366
|
+
// just satisfies the shared del-by-id encoding.
|
|
367
|
+
const payload = ref.kind === SINGLE ? { id: '' } : { id }
|
|
368
|
+
await this.write([[`del-${ref.verb}`, payload]])
|
|
351
369
|
|
|
352
370
|
await this.runAfter('del', ctx)
|
|
353
371
|
}
|
|
@@ -361,6 +379,13 @@ export class Database extends ReadyResource {
|
|
|
361
379
|
*/
|
|
362
380
|
async call(op, data = {}) {
|
|
363
381
|
this.guard()
|
|
382
|
+
// A declared action with no local route would apply as a silent no-op — fail
|
|
383
|
+
// loud before writing a dead op. (Builtin verbs aren't refs, so they pass.)
|
|
384
|
+
if (this.refs[op]?.kind === ACTION && typeof this.routes[op] !== 'function') {
|
|
385
|
+
throw CeroError.INVALID(
|
|
386
|
+
`action '${op}' has no route — pass { routes: { ${op} } } when opening the handle`
|
|
387
|
+
)
|
|
388
|
+
}
|
|
364
389
|
await this.write([[op, data]])
|
|
365
390
|
}
|
|
366
391
|
|
|
@@ -373,19 +398,28 @@ export class Database extends ReadyResource {
|
|
|
373
398
|
* @returns {Promise<T>}
|
|
374
399
|
*/
|
|
375
400
|
async tx(fn) {
|
|
401
|
+
// Nested tx() (called while an outer tx's fn is running) joins the outer queue.
|
|
376
402
|
if (this.txQueue) return fn()
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
403
|
+
// Outer transactions serialize — the run is deferred onto a chain so two
|
|
404
|
+
// concurrent tx() calls each get their own queue instead of the second
|
|
405
|
+
// buffering into the first's (and being dropped if the first rolls back).
|
|
406
|
+
const run = async () => {
|
|
407
|
+
const queue = []
|
|
408
|
+
this.txQueue = queue
|
|
409
|
+
try {
|
|
410
|
+
const result = await fn()
|
|
411
|
+
this.txQueue = null
|
|
412
|
+
if (queue.length) await this.write(queue)
|
|
413
|
+
return result
|
|
414
|
+
} catch (err) {
|
|
415
|
+
this.txQueue = null
|
|
416
|
+
throw err
|
|
417
|
+
}
|
|
385
418
|
}
|
|
386
|
-
this.
|
|
387
|
-
|
|
388
|
-
|
|
419
|
+
const prev = this._txChain || Promise.resolve()
|
|
420
|
+
// chain past prev's outcome so one failure doesn't wedge later transactions
|
|
421
|
+
this._txChain = prev.then(run, run)
|
|
422
|
+
return this._txChain
|
|
389
423
|
}
|
|
390
424
|
|
|
391
425
|
/**
|
|
@@ -430,8 +464,9 @@ export class Database extends ReadyResource {
|
|
|
430
464
|
|
|
431
465
|
const idx = this.matchIndex(name, query)
|
|
432
466
|
if (idx) {
|
|
433
|
-
const
|
|
434
|
-
|
|
467
|
+
const rows = await this.view.find(idx.path, idx.range).toArray()
|
|
468
|
+
const data = paginate(rows, query)
|
|
469
|
+
return { data, total: rows.length, size: data.length }
|
|
435
470
|
}
|
|
436
471
|
|
|
437
472
|
const all = await this.view.find(col, {}).toArray()
|
|
@@ -466,7 +501,6 @@ export class Database extends ReadyResource {
|
|
|
466
501
|
*/
|
|
467
502
|
watch(name, query) {
|
|
468
503
|
this.guard()
|
|
469
|
-
this.ref(name)
|
|
470
504
|
return subscribe({
|
|
471
505
|
get: () => this.get(name, query),
|
|
472
506
|
watch: (fn) => this.onUpdate(fn)
|
|
@@ -571,11 +605,22 @@ export class Database extends ReadyResource {
|
|
|
571
605
|
return this._write('del-writer', 'removeWriter', publicKey)
|
|
572
606
|
}
|
|
573
607
|
|
|
608
|
+
/**
|
|
609
|
+
* Throw if the handle is closing/closed or the bee isn't ready yet.
|
|
610
|
+
*
|
|
611
|
+
* @returns {void}
|
|
612
|
+
*/
|
|
574
613
|
guard() {
|
|
575
614
|
if (this.closing || this.closed) throw CeroError.CLOSED('Database')
|
|
576
615
|
if (!this.bee) throw CeroError.NOT_READY('Database', 'db')
|
|
577
616
|
}
|
|
578
617
|
|
|
618
|
+
/**
|
|
619
|
+
* Resolve a table name to its normalized `{ name, kind, verb }` ref.
|
|
620
|
+
*
|
|
621
|
+
* @param {string} name
|
|
622
|
+
* @returns {Ref}
|
|
623
|
+
*/
|
|
579
624
|
ref(name) {
|
|
580
625
|
if (typeof name !== 'string' || !name)
|
|
581
626
|
throw CeroError.INVALID('name must be a non-empty string')
|
|
@@ -584,11 +629,37 @@ export class Database extends ReadyResource {
|
|
|
584
629
|
return { name, kind: ref.kind || COLLECTION, verb: ref.verb || name }
|
|
585
630
|
}
|
|
586
631
|
|
|
632
|
+
/**
|
|
633
|
+
* Namespaced collection path for a ref.
|
|
634
|
+
*
|
|
635
|
+
* @param {Ref} ref
|
|
636
|
+
* @returns {string}
|
|
637
|
+
*/
|
|
587
638
|
col(ref) {
|
|
588
639
|
return `@${this.ns}/${ref.name}`
|
|
589
640
|
}
|
|
590
641
|
|
|
591
|
-
//
|
|
642
|
+
// Reject a write that carries a field the schema doesn't declare — the encoder
|
|
643
|
+
// would silently drop it, so the returned row would lie. Builtins (and specs
|
|
644
|
+
// built before field info shipped) carry no `fields`, so they skip validation.
|
|
645
|
+
_checkFields(name, row) {
|
|
646
|
+
const declared = this.refs[name]?.fields
|
|
647
|
+
if (!declared || !row) return
|
|
648
|
+
for (const key of Object.keys(row)) {
|
|
649
|
+
if (declared.includes(key) || SYSTEM_FIELDS.has(key)) continue
|
|
650
|
+
throw CeroError.INVALID(
|
|
651
|
+
`unknown field '${key}' on '${name}' — declared: ${declared.join(', ') || '(none)'}`
|
|
652
|
+
)
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* Find a secondary index whose fields exactly match the query's equality keys.
|
|
658
|
+
*
|
|
659
|
+
* @param {string} name
|
|
660
|
+
* @param {Query} [query]
|
|
661
|
+
* @returns {{ path: string, range: { gte: object, lte: object } } | null}
|
|
662
|
+
*/
|
|
592
663
|
matchIndex(name, query) {
|
|
593
664
|
const indexes = this.refs[name]?.indexes
|
|
594
665
|
if (!indexes || !query) return null
|
|
@@ -598,10 +669,8 @@ export class Database extends ReadyResource {
|
|
|
598
669
|
if (idxFields.length === fields.length && idxFields.every((f) => fields.includes(f))) {
|
|
599
670
|
const point = {}
|
|
600
671
|
for (const f of idxFields) point[f] = query[f]
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
if (query.reverse) range.reverse = true
|
|
604
|
-
return { path: `@${this.ns}/${name}-${idx}`, range }
|
|
672
|
+
// point lookup only — search/range/reverse/limit are applied by paginate
|
|
673
|
+
return { path: `@${this.ns}/${name}-${idx}`, range: { gte: point, lte: point } }
|
|
605
674
|
}
|
|
606
675
|
}
|
|
607
676
|
return null
|
|
@@ -620,7 +689,7 @@ export class Database extends ReadyResource {
|
|
|
620
689
|
|
|
621
690
|
async _write(verb, hook, publicKey) {
|
|
622
691
|
this.guard()
|
|
623
|
-
if (!b4a.isBuffer(publicKey)
|
|
692
|
+
if (!b4a.isBuffer(publicKey)) {
|
|
624
693
|
throw CeroError.INVALID('publicKey must be a buffer')
|
|
625
694
|
}
|
|
626
695
|
const ctx = { op: hook, publicKey }
|
|
@@ -629,17 +698,32 @@ export class Database extends ReadyResource {
|
|
|
629
698
|
version: this.store.manifestVersion,
|
|
630
699
|
signers: [{ publicKey }]
|
|
631
700
|
})
|
|
632
|
-
const sig = this.identity.sign(writerKey)
|
|
701
|
+
const sig = this.identity.sign(b4a.concat([writerKey, this.writerKey]))
|
|
633
702
|
await this.write([[verb, { master: this.identity.publicKey, writer: writerKey, sig }]])
|
|
634
703
|
await this.runAfter(hook, ctx)
|
|
635
704
|
}
|
|
636
705
|
}
|
|
637
706
|
|
|
638
707
|
const INDEX_RESERVED = new Set(['gt', 'gte', 'lt', 'lte', 'limit', 'reverse', 'search', 'fields'])
|
|
708
|
+
// Fields the write path stamps itself — always allowed even if not user-declared.
|
|
709
|
+
const SYSTEM_FIELDS = new Set(['id', 'memberId', 'index', 'createdAt', 'updatedAt'])
|
|
710
|
+
|
|
711
|
+
// Match a query value against a stored value (bytes compared by content).
|
|
712
|
+
const valueEq = (a, b) => {
|
|
713
|
+
if (a instanceof Uint8Array && b instanceof Uint8Array) return b4a.equals(a, b)
|
|
714
|
+
return a === b
|
|
715
|
+
}
|
|
639
716
|
|
|
640
717
|
function paginate(rows, query) {
|
|
641
718
|
if (!query) return rows
|
|
642
719
|
let out = rows
|
|
720
|
+
// Exact-field equality: every non-reserved key filters in memory. The secondary
|
|
721
|
+
// index is an optimization, not a correctness gate — so an unindexed field, or
|
|
722
|
+
// one passed alongside search/range, still filters instead of silently dropping.
|
|
723
|
+
for (const key of Object.keys(query)) {
|
|
724
|
+
if (INDEX_RESERVED.has(key)) continue
|
|
725
|
+
out = out.filter((r) => valueEq(r[key], query[key]))
|
|
726
|
+
}
|
|
643
727
|
if (query.gt !== undefined) out = out.filter((r) => r.id > query.gt)
|
|
644
728
|
if (query.gte !== undefined) out = out.filter((r) => r.id >= query.gte)
|
|
645
729
|
if (query.lt !== undefined) out = out.filter((r) => r.id < query.lt)
|
|
@@ -662,7 +746,9 @@ const fold = (s) =>
|
|
|
662
746
|
// every string field). 'smith' → 'John Smith', 'jo sm' → both must appear.
|
|
663
747
|
function searchHit(row, term, fields) {
|
|
664
748
|
const terms = String(term).split(/\s+/).map(fold).filter(Boolean)
|
|
665
|
-
|
|
749
|
+
// default to all fields except memberId — a framework-stamped random z32 whose value would
|
|
750
|
+
// otherwise produce spurious, non-deterministic search hits (user-set id/code stay searchable)
|
|
751
|
+
const keys = fields && fields.length ? fields : Object.keys(row).filter((k) => k !== 'memberId')
|
|
666
752
|
const hay = keys.map((k) => (typeof row[k] === 'string' ? fold(row[k]) : '')).join(' ')
|
|
667
753
|
return terms.every((t) => hay.includes(t))
|
|
668
754
|
}
|
package/src/identity/index.js
CHANGED
package/src/index.js
CHANGED
|
@@ -8,13 +8,36 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
export * from './identity/index.js'
|
|
11
|
-
|
|
11
|
+
// `database`, `pairing` and `pairing/invite` declare a few generically-named
|
|
12
|
+
// result/option typedefs (`Ref`, `SingleResult`, `ListResult`,
|
|
13
|
+
// `CreateInviteOpts`) that also exist on `storage`/`pairing`. Re-export the
|
|
14
|
+
// lower-level modules' colliding names under namespaced aliases so the barrel
|
|
15
|
+
// stays unambiguous while the canonical (`database`/`pairing`) versions own the
|
|
16
|
+
// bare names.
|
|
17
|
+
export { Storage } from './storage/index.js'
|
|
18
|
+
/**
|
|
19
|
+
* `Ref` / `SingleResult` / `ListResult` are JSDoc typedefs (types, not runtime exports), so they
|
|
20
|
+
* are re-exported as namespaced type aliases — `export { Ref as StorageRef }` fails at import
|
|
21
|
+
* because the module provides no runtime `Ref` export.
|
|
22
|
+
* @typedef {import('./storage/index.js').Ref} StorageRef
|
|
23
|
+
* @typedef {import('./storage/index.js').SingleResult} StorageSingleResult
|
|
24
|
+
* @typedef {import('./storage/index.js').ListResult} StorageListResult
|
|
25
|
+
* @typedef {import('./storage/index.js').StorageOpts} StorageOpts
|
|
26
|
+
* @typedef {import('./storage/index.js').StoredRow} StoredRow
|
|
27
|
+
* @typedef {import('./storage/index.js').GetByIdResult} GetByIdResult
|
|
28
|
+
*/
|
|
12
29
|
export * from './network/index.js'
|
|
13
30
|
export * from './database/index.js'
|
|
14
31
|
export * from './blobs/index.js'
|
|
15
32
|
export * from './rpc/index.js'
|
|
16
33
|
export * from './pairing/index.js'
|
|
17
|
-
export
|
|
34
|
+
export { Invite } from './pairing/invite.js'
|
|
35
|
+
/**
|
|
36
|
+
* `CreateInviteOpts` is a typedef (not a runtime export); re-export it as a type alias.
|
|
37
|
+
* @typedef {import('./pairing/invite.js').CreateInviteOpts} MintInviteOpts
|
|
38
|
+
* @typedef {import('./pairing/invite.js').InviteFields} InviteFields
|
|
39
|
+
* @typedef {import('./pairing/invite.js').ParseInviteOpts} ParseInviteOpts
|
|
40
|
+
*/
|
|
18
41
|
export * from './lib/schema.js'
|
|
19
42
|
export * from './lib/utils.js'
|
|
20
43
|
export * from './lib/errors.js'
|
package/src/lib/constants.js
CHANGED
|
@@ -14,8 +14,28 @@ export const BEE = 'bee'
|
|
|
14
14
|
|
|
15
15
|
// Roles
|
|
16
16
|
export const OWNER = 'owner'
|
|
17
|
-
export const
|
|
17
|
+
export const ADMIN = 'admin'
|
|
18
|
+
export const MEMBER = 'member'
|
|
19
|
+
export const READER = 'reader'
|
|
20
|
+
|
|
21
|
+
// Permissions
|
|
18
22
|
export const READ = 'read'
|
|
23
|
+
export const WRITE = 'write'
|
|
24
|
+
export const DELETE = 'delete'
|
|
25
|
+
export const INVITE = 'invite'
|
|
26
|
+
export const REMOVE = 'remove'
|
|
27
|
+
export const ASSIGN = 'assign'
|
|
28
|
+
|
|
29
|
+
// Default role → permissions
|
|
30
|
+
export const ROLE_PERMS = {
|
|
31
|
+
[OWNER]: ['*'],
|
|
32
|
+
[ADMIN]: [READ, WRITE, DELETE, INVITE, REMOVE, ASSIGN],
|
|
33
|
+
[MEMBER]: [READ, WRITE, INVITE],
|
|
34
|
+
[READER]: [READ]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Role ordering (higher = more authority)
|
|
38
|
+
export const RANK = { [OWNER]: 3, [ADMIN]: 2, [MEMBER]: 1, [READER]: 0 }
|
|
19
39
|
|
|
20
40
|
// Identity / hypercore namespace
|
|
21
41
|
export const NAMESPACE = 'cero'
|