@cero-base/cero 1.16.1 → 1.18.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 +7 -7
- package/src/build/builtins.js +33 -20
- package/src/build/schemas.js +5 -5
- package/src/handle/index.js +61 -89
- package/src/index.js +24 -11
- package/src/lib/operators.js +24 -13
- package/src/local/index.js +2 -1
- package/src/rpc/server.js +1 -1
- package/types/build/builtins.d.ts +40 -40
- package/types/handle/index.d.ts +2 -2
- package/types/lib/operators.d.ts +130 -19
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cero-base/cero",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.18.0",
|
|
4
4
|
"description": "The ideal p2p API — everything is a handle, handles contain refs, refs contain rows.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -95,12 +95,12 @@
|
|
|
95
95
|
"test:node": "ls test/*.test.js | xargs -P1 -n1 brittle-node"
|
|
96
96
|
},
|
|
97
97
|
"dependencies": {
|
|
98
|
-
"@cero-base/core": "^1.
|
|
98
|
+
"@cero-base/core": "^1.18.0",
|
|
99
99
|
"b4a": "^1.8.1",
|
|
100
100
|
"bare-abort-controller": "^1.1.2",
|
|
101
101
|
"bare-crypto": "^1.15.3",
|
|
102
102
|
"bare-fs": "^4.8.1",
|
|
103
|
-
"bare-path": "^3.1.
|
|
103
|
+
"bare-path": "^3.1.2",
|
|
104
104
|
"ble-swarm": "^2.3.0",
|
|
105
105
|
"compact-encoding": "^3.3.2",
|
|
106
106
|
"corestore": "^7.12.2",
|
|
@@ -110,7 +110,7 @@
|
|
|
110
110
|
"hypercore-storage": "^3.2.1",
|
|
111
111
|
"hyperdb": "^6.9.0",
|
|
112
112
|
"hyperdispatch": "^1.6.0",
|
|
113
|
-
"hyperschema": "^1.
|
|
113
|
+
"hyperschema": "^1.23.0",
|
|
114
114
|
"ready-resource": "^1.2.0",
|
|
115
115
|
"safety-catch": "^1.0.3",
|
|
116
116
|
"streamx": "^2.28.1",
|
|
@@ -120,10 +120,10 @@
|
|
|
120
120
|
"devDependencies": {
|
|
121
121
|
"@hyperswarm/testnet": "^3.1.4",
|
|
122
122
|
"bare-events": "^2.9.2",
|
|
123
|
-
"bare-fetch": "^3.2.
|
|
123
|
+
"bare-fetch": "^3.2.1",
|
|
124
124
|
"bare-process": "^4.5.1",
|
|
125
|
-
"bare-url": "^2.5.
|
|
126
|
-
"brittle": "^4.1.
|
|
125
|
+
"bare-url": "^2.5.4",
|
|
126
|
+
"brittle": "^4.1.1",
|
|
127
127
|
"typescript": "^5.9.3"
|
|
128
128
|
},
|
|
129
129
|
"license": "Apache-2.0"
|
package/src/build/builtins.js
CHANGED
|
@@ -24,7 +24,9 @@ export const refs = {
|
|
|
24
24
|
}
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
export
|
|
27
|
+
export function getHyperdbType(prim) {
|
|
28
|
+
return DB_TYPE[prim] || 'string'
|
|
29
|
+
}
|
|
28
30
|
|
|
29
31
|
const at = (ns, n) => `@${ns}/${n}`
|
|
30
32
|
const keyOf = (def) => (def.kind === 'single' ? [] : ['id'])
|
|
@@ -39,9 +41,11 @@ const fields = (map) =>
|
|
|
39
41
|
// Merge app `t.extend` fields into a builtin's base fields — base can't be redeclared.
|
|
40
42
|
const merge = (type, base, extra) => {
|
|
41
43
|
if (!extra) return base
|
|
42
|
-
for (const k in extra)
|
|
43
|
-
if (k in base)
|
|
44
|
+
for (const k in extra) {
|
|
45
|
+
if (k in base) {
|
|
44
46
|
throw CeroError.INVALID(`'${k}' is a base field of '${type}' and cannot be redeclared`)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
45
49
|
return { ...base, ...extra }
|
|
46
50
|
}
|
|
47
51
|
|
|
@@ -53,8 +57,8 @@ const descriptors = (group, extend = {}) =>
|
|
|
53
57
|
}))
|
|
54
58
|
|
|
55
59
|
// meta.refs entries for a scope's builtins.
|
|
56
|
-
export
|
|
57
|
-
Object.fromEntries(
|
|
60
|
+
export function builtinRefs(ns, scope) {
|
|
61
|
+
return Object.fromEntries(
|
|
58
62
|
Object.entries(refs[scope]).map(([name, def]) => [
|
|
59
63
|
name,
|
|
60
64
|
{
|
|
@@ -66,14 +70,19 @@ export const builtinRefs = (ns, scope) =>
|
|
|
66
70
|
}
|
|
67
71
|
])
|
|
68
72
|
)
|
|
73
|
+
}
|
|
69
74
|
|
|
70
75
|
// hyperschema type descriptors. scope is 'main' | 'local' | 'rpc'; `extend`
|
|
71
76
|
// merges app `t.extend` fields into the matching type.
|
|
72
|
-
export
|
|
73
|
-
|
|
77
|
+
export function builtinTypes(scope, extend) {
|
|
78
|
+
return descriptors(schemas[scope], extend)
|
|
79
|
+
}
|
|
80
|
+
export function rpcTypes() {
|
|
81
|
+
return descriptors(schemas.rpc)
|
|
82
|
+
}
|
|
74
83
|
|
|
75
84
|
// hyperdb collection descriptors for a scope.
|
|
76
|
-
export
|
|
85
|
+
export function builtinCollections(ns, scope) {
|
|
77
86
|
const out = Object.entries(refs[scope]).map(([name, def]) => ({
|
|
78
87
|
name,
|
|
79
88
|
schema: at(ns, def.type),
|
|
@@ -87,25 +96,29 @@ export const builtinCollections = (ns, scope) => {
|
|
|
87
96
|
}
|
|
88
97
|
|
|
89
98
|
// hyperdispatch descriptors (main scope only).
|
|
90
|
-
export
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
{
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
]
|
|
99
|
+
export function builtinDispatches(ns) {
|
|
100
|
+
return [
|
|
101
|
+
{ name: 'add-writer', requestType: at(ns, 'writer') },
|
|
102
|
+
{ name: 'del-writer', requestType: at(ns, 'writer') },
|
|
103
|
+
{ name: 'claim-writer', requestType: at(ns, 'claim') },
|
|
104
|
+
...Object.values(refs.main).flatMap(({ type }) => [
|
|
105
|
+
{ name: `add-${type}`, requestType: at(ns, type) },
|
|
106
|
+
{ name: `set-${type}`, requestType: at(ns, type) },
|
|
107
|
+
{ name: `del-${type}`, requestType: at(ns, 'del-by-id') }
|
|
108
|
+
])
|
|
109
|
+
]
|
|
110
|
+
}
|
|
100
111
|
|
|
101
112
|
// Registered AFTER the app's own dispatches (see build/index.js): hyperdispatch
|
|
102
113
|
// numbers routes positionally and persists them, so a spec built before
|
|
103
114
|
// rotation must see rotate-key appended at the end — inserting it into the
|
|
104
115
|
// builtin group would collide with the app's persisted route ids on an
|
|
105
116
|
// incremental rebuild.
|
|
106
|
-
export
|
|
117
|
+
export function rotateDispatch(ns) {
|
|
118
|
+
return { name: 'rotate-key', requestType: at(ns, 'epoch') }
|
|
119
|
+
}
|
|
107
120
|
|
|
108
|
-
export
|
|
121
|
+
export function rpcCommands(ns) {
|
|
109
122
|
const ref = (n) => at(ns, n)
|
|
110
123
|
return [
|
|
111
124
|
{ name: 'init', request: { name: ref('req-empty') }, response: { name: ref('res-identity') } },
|
package/src/build/schemas.js
CHANGED
|
@@ -15,11 +15,11 @@ export const main = {
|
|
|
15
15
|
sig: required(bytes),
|
|
16
16
|
isIndexer: bool,
|
|
17
17
|
ts: int,
|
|
18
|
-
// Who this writer belongs to
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
18
|
+
// Who this writer belongs to. The paired add-member decides the rank in
|
|
19
|
+
// the same transaction, and a refused grant discards this admission with
|
|
20
|
+
// it — so `role` is unread since 77e5ed0 and stays only for older peers.
|
|
21
|
+
// Absent on a device you add for yourself (recovery), where the device
|
|
22
|
+
// binds to the master identity.
|
|
23
23
|
memberId: string,
|
|
24
24
|
role: string
|
|
25
25
|
},
|
package/src/handle/index.js
CHANGED
|
@@ -12,7 +12,7 @@ import { Identity } from '@cero-base/core/identity'
|
|
|
12
12
|
import { Database } from '@cero-base/core/database'
|
|
13
13
|
import { epochEntries, blobEpochKey } from '@cero-base/core/database/encryption'
|
|
14
14
|
import { Pairing } from '@cero-base/core/pairing'
|
|
15
|
-
import { toId, grants, isRank, addWriterPayload } from '@cero-base/core/utils'
|
|
15
|
+
import { toId, grants, can, isRank, addWriterPayload, REMOVE } from '@cero-base/core/utils'
|
|
16
16
|
import { CeroError } from '@cero-base/core/errors'
|
|
17
17
|
import { Blobs } from '@cero-base/core/blobs'
|
|
18
18
|
import { decodeId } from '@cero-base/core/blobs/codec'
|
|
@@ -128,7 +128,7 @@ export class Handle extends ReadyResource {
|
|
|
128
128
|
this._fileServer = null
|
|
129
129
|
this._blobs = null
|
|
130
130
|
this._epochBlobs = null
|
|
131
|
-
// root only
|
|
131
|
+
// root only, serializes suspend/resume and converges rapid bounces
|
|
132
132
|
this._sus = parent
|
|
133
133
|
? null
|
|
134
134
|
: new Suspendify({ suspend: () => this._suspend(), resume: () => this._resume() })
|
|
@@ -165,9 +165,8 @@ export class Handle extends ReadyResource {
|
|
|
165
165
|
}
|
|
166
166
|
})
|
|
167
167
|
await this.pair.ready()
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
// or consumed elsewhere).
|
|
168
|
+
// serve invites persisted by any member, and keep the set in step as rows
|
|
169
|
+
// replicate in or disappear (minted, revoked or consumed elsewhere)
|
|
171
170
|
await this._syncInvites().catch(safetyCatch)
|
|
172
171
|
this._invitesSync = () => this._syncInvites().catch(safetyCatch)
|
|
173
172
|
this.store.on('update', this._invitesSync)
|
|
@@ -353,8 +352,7 @@ export class Handle extends ReadyResource {
|
|
|
353
352
|
blobs
|
|
354
353
|
.ready()
|
|
355
354
|
.then(() => {
|
|
356
|
-
// close prunes _coreKeys first
|
|
357
|
-
// not re-insert the entry (it would outlive the handle on the root)
|
|
355
|
+
// close prunes _coreKeys first, a late ready() must not re-insert the entry
|
|
358
356
|
if (this.closing || this.closed || !blobs.key) return
|
|
359
357
|
this.root._coreKeys.set(b4a.toString(blobs.key, 'hex'), encryptionKey)
|
|
360
358
|
})
|
|
@@ -377,8 +375,7 @@ export class Handle extends ReadyResource {
|
|
|
377
375
|
const { coreKey } = decodeId(id)
|
|
378
376
|
const hex = b4a.toString(coreKey, 'hex')
|
|
379
377
|
if (!this.root._coreKeys.has(hex)) {
|
|
380
|
-
// a file-field value
|
|
381
|
-
// (fire-and-forget: idempotent, resolution happens again per request)
|
|
378
|
+
// no stamp on a file-field value, look it up (fire-and-forget, idempotent)
|
|
382
379
|
if (stamp === undefined) {
|
|
383
380
|
this.store
|
|
384
381
|
.get('files', id)
|
|
@@ -392,16 +389,14 @@ export class Handle extends ReadyResource {
|
|
|
392
389
|
if (!key) return // unknown epoch — this device is not entitled to the core
|
|
393
390
|
this.root._coreKeys.set(hex, key)
|
|
394
391
|
}
|
|
395
|
-
// remember which handle read it, so close prunes the entry
|
|
396
|
-
// on the next read if another handle still serves the same core)
|
|
392
|
+
// remember which handle read it, so close prunes the entry
|
|
397
393
|
if (this !== this.root) (this._blobKeys ??= new Set()).add(hex)
|
|
398
394
|
} catch {
|
|
399
395
|
// ignore invalid ids
|
|
400
396
|
}
|
|
401
397
|
}
|
|
402
398
|
|
|
403
|
-
//
|
|
404
|
-
// have their own keys); rotated-era cores derive from the epoch entropy.
|
|
399
|
+
// base-era cores use the OWNING handle's key, rooms have their own
|
|
405
400
|
_blobCoreKey(stamp) {
|
|
406
401
|
if (!stamp) return this.store.encryptionKey
|
|
407
402
|
const entropy = this.store.keyring.entropy(stamp)
|
|
@@ -479,20 +474,17 @@ export class Handle extends ReadyResource {
|
|
|
479
474
|
* @returns {Promise<string>} Z32-encoded invite string.
|
|
480
475
|
*/
|
|
481
476
|
async invite(opts) {
|
|
482
|
-
if (!this.pair)
|
|
477
|
+
if (!this.pair) {
|
|
483
478
|
throw CeroError.INVALID(
|
|
484
479
|
'invite() is not available on the root handle — open a child handle first'
|
|
485
480
|
)
|
|
486
|
-
|
|
487
|
-
//
|
|
488
|
-
// at apply — as a silent drop that still leaves the joiner admitted.
|
|
481
|
+
}
|
|
482
|
+
// an invite is a capability, cap the rank or apply drops the mismatch silently
|
|
489
483
|
if (opts?.role !== undefined && opts.role !== '') await this._checkGrant(opts.role)
|
|
490
484
|
const str = await this.pair.createInvite(opts)
|
|
491
485
|
const record = this.pair.recordOf(str)
|
|
492
486
|
if (record) {
|
|
493
|
-
//
|
|
494
|
-
// the in-memory record keeps working this session even if the write
|
|
495
|
-
// fails.
|
|
487
|
+
// persist so every member replica serves it across restarts
|
|
496
488
|
await this.store
|
|
497
489
|
.call('add-invite', {
|
|
498
490
|
id: b4a.toString(record.id, 'hex'),
|
|
@@ -519,15 +511,20 @@ export class Handle extends ReadyResource {
|
|
|
519
511
|
* Revoke a previously-minted invite by its string form.
|
|
520
512
|
*
|
|
521
513
|
* @param {string} invite
|
|
522
|
-
* @returns {boolean} `true` if the invite was found and removed.
|
|
514
|
+
* @returns {Promise<boolean>} `true` if the invite was found and removed.
|
|
523
515
|
*/
|
|
524
|
-
revoke(invite) {
|
|
516
|
+
async revoke(invite) {
|
|
525
517
|
if (!this.pair) throw CeroError.INVALID('revoke() is not available on the root handle')
|
|
518
|
+
// apply refuses revoke below REMOVE, and other members would keep serving it
|
|
519
|
+
const { data: me } = await this.store.get('members', this.identity.id)
|
|
520
|
+
if (me && !can(me.role, REMOVE)) {
|
|
521
|
+
throw CeroError.DENIED(null, 'revoking an invite needs the remove permission')
|
|
522
|
+
}
|
|
526
523
|
const record = this.pair.recordOf(invite)
|
|
527
524
|
const revoked = this.pair.revoke(invite)
|
|
528
525
|
if (revoked && record) {
|
|
529
526
|
// drop the persisted row so every other member stops serving it too
|
|
530
|
-
this.store.call('del-invite', { id: b4a.toString(record.id, 'hex') })
|
|
527
|
+
await this.store.call('del-invite', { id: b4a.toString(record.id, 'hex') })
|
|
531
528
|
}
|
|
532
529
|
return revoked
|
|
533
530
|
}
|
|
@@ -552,20 +549,14 @@ export class Handle extends ReadyResource {
|
|
|
552
549
|
if (candidate.invite.role && !grants(candidate.invite.role, role)) {
|
|
553
550
|
throw CeroError.INVALID(`role '${role}' exceeds the invite role '${candidate.invite.role}'`)
|
|
554
551
|
}
|
|
555
|
-
//
|
|
556
|
-
// inside the pairing request's lifetime, and any read here drops it. Rank
|
|
557
|
-
// validation is synchronous and catches the dangerous case (an app role
|
|
558
|
-
// name silently grants nothing); the cap against our own rank is enforced
|
|
559
|
-
// at apply, where add-writer refuses to admit what it cannot grant.
|
|
552
|
+
// synchronous on purpose, the cap against our own rank is enforced at apply
|
|
560
553
|
if (!isRank(role)) {
|
|
561
554
|
throw CeroError.INVALID(`role '${role}' is not a rank (owner, admin, member, reader)`)
|
|
562
555
|
}
|
|
563
556
|
|
|
564
|
-
// confirm must answer within the
|
|
565
|
-
//
|
|
566
|
-
//
|
|
567
|
-
// land; if they fail the joiner holds the key un-admitted, which is
|
|
568
|
-
// recoverable (re-pair) and surfaced via onerror in _wireAccept.
|
|
557
|
+
// confirm must answer within the pairing request's lifetime, any await before
|
|
558
|
+
// it (even ~100ms) drops the response. So the key is revealed before the
|
|
559
|
+
// membership writes land, and a failure there is recoverable by re-pairing.
|
|
569
560
|
// Epoch secrets ride along so a post-rotation joiner reads full history.
|
|
570
561
|
const epochs = this.store.keyring.all()
|
|
571
562
|
await candidate.confirm({
|
|
@@ -598,26 +589,21 @@ export class Handle extends ReadyResource {
|
|
|
598
589
|
sig,
|
|
599
590
|
master: this.identity.publicKey,
|
|
600
591
|
writer: writerKey,
|
|
601
|
-
//
|
|
602
|
-
// admits nobody — the two ops apply independently and cannot roll
|
|
603
|
-
// each other back
|
|
592
|
+
// add-member in the same transaction decides the rank, a refusal discards both
|
|
604
593
|
memberId: member.id,
|
|
605
|
-
role,
|
|
606
594
|
ts: member.updatedAt || Date.now()
|
|
607
595
|
})
|
|
608
596
|
await tx.call('add-member', member)
|
|
609
597
|
})
|
|
610
598
|
}
|
|
611
599
|
|
|
612
|
-
//
|
|
613
|
-
// never above our own. `grants` treats an unknown role as "no" everywhere,
|
|
614
|
-
// so an app role name must fail loudly here rather than silently downstream.
|
|
600
|
+
// `grants` treats an unknown role as "no", so an app role name must fail loudly
|
|
615
601
|
async _checkGrant(role) {
|
|
616
602
|
if (!isRank(role)) {
|
|
617
603
|
throw CeroError.INVALID(`role '${role}' is not a rank (owner, admin, member, reader)`)
|
|
618
604
|
}
|
|
619
605
|
const { data: me } = await this.store.get('members', this.identity.id)
|
|
620
|
-
// no member row yet = genesis
|
|
606
|
+
// no member row yet = genesis, nothing to cap
|
|
621
607
|
if (!me) return
|
|
622
608
|
if (!grants(me.role, role)) {
|
|
623
609
|
throw CeroError.DENIED(null, `role '${role}' exceeds your own role '${me.role}'`)
|
|
@@ -654,13 +640,11 @@ export class Handle extends ReadyResource {
|
|
|
654
640
|
namespace: `${NS}/handle/${type}/${writer.id}`,
|
|
655
641
|
routes,
|
|
656
642
|
keyPair: writer,
|
|
657
|
-
//
|
|
658
|
-
// any member decrypt every room this identity ever created, plus its root db
|
|
643
|
+
// inheriting identity.encryptionKey would let any member decrypt every room
|
|
659
644
|
encryptionKey: Identity.randomBytes(32)
|
|
660
645
|
})
|
|
661
646
|
)
|
|
662
|
-
// a failure after the child opens
|
|
663
|
-
// swarm session and attached bee, never reaped by the parent (not yet a child)
|
|
647
|
+
// a failure after the child opens leaks its Database, swarm session and bee
|
|
664
648
|
let publish = null
|
|
665
649
|
let abort = null
|
|
666
650
|
let inflightId = null
|
|
@@ -669,10 +653,8 @@ export class Handle extends ReadyResource {
|
|
|
669
653
|
child.name = name
|
|
670
654
|
|
|
671
655
|
const id = toId(child.store.key)
|
|
672
|
-
//
|
|
673
|
-
//
|
|
674
|
-
// must share this child — a duplicate Handle over the same core
|
|
675
|
-
// deadlocks in ready().
|
|
656
|
+
// add-handle makes the row visible before this create finishes, and a
|
|
657
|
+
// duplicate Handle over the same core deadlocks in ready()
|
|
676
658
|
const inflight = new Promise((resolve, reject) => {
|
|
677
659
|
publish = resolve
|
|
678
660
|
abort = reject
|
|
@@ -684,21 +666,24 @@ export class Handle extends ReadyResource {
|
|
|
684
666
|
|
|
685
667
|
const ts = Date.now()
|
|
686
668
|
const writerKey = child.store.writerKey
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
writer
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
669
|
+
// same shape as accept(), so a room's first two ops land together
|
|
670
|
+
await child.store.tx(async (tx) => {
|
|
671
|
+
await tx.call('add-writer', {
|
|
672
|
+
master: this.identity.publicKey,
|
|
673
|
+
writer: writerKey,
|
|
674
|
+
sig: this.identity.sign(
|
|
675
|
+
addWriterPayload(child.store.key, writerKey, child.store.writerKey)
|
|
676
|
+
),
|
|
677
|
+
ts
|
|
678
|
+
})
|
|
679
|
+
await tx.call('add-member', {
|
|
680
|
+
id: this.identity.id,
|
|
681
|
+
key: writerKey,
|
|
682
|
+
role: 'owner',
|
|
683
|
+
name: null,
|
|
684
|
+
createdAt: ts,
|
|
685
|
+
updatedAt: ts
|
|
686
|
+
})
|
|
702
687
|
})
|
|
703
688
|
await this.store.call('add-handle', {
|
|
704
689
|
id,
|
|
@@ -736,10 +721,7 @@ export class Handle extends ReadyResource {
|
|
|
736
721
|
* @returns {Promise<Handle>}
|
|
737
722
|
*/
|
|
738
723
|
async _join(invite, type, opts = {}) {
|
|
739
|
-
//
|
|
740
|
-
// join, a double tap): the pairing layer allows one candidate per invite,
|
|
741
|
-
// so the second attempt must attach to the in-flight join, not start
|
|
742
|
-
// another handshake.
|
|
724
|
+
// the pairing layer allows one candidate per invite, attach to the in-flight join
|
|
743
725
|
const target = Pairing.inviteTopic(invite)
|
|
744
726
|
const key = target && `${type}/${b4a.toString(target, 'hex')}`
|
|
745
727
|
if (!key) return this._pair(invite, type, opts, target)
|
|
@@ -764,14 +746,10 @@ export class Handle extends ReadyResource {
|
|
|
764
746
|
async _pair(invite, type, { routes, timeout } = {}, target = null) {
|
|
765
747
|
const deadline = timeout || TIMEOUT
|
|
766
748
|
|
|
767
|
-
//
|
|
768
|
-
//
|
|
769
|
-
//
|
|
770
|
-
//
|
|
771
|
-
// its keypair would only get it re-removed. Holding a fresh invite is
|
|
772
|
-
// exactly the re-admission path, so fall through to a real pairing —
|
|
773
|
-
// which mints a fresh keypair the host admits. Our member row can outlive
|
|
774
|
-
// our writer (one revoked device), so the device row is the real test.
|
|
749
|
+
// idempotent while our stored writer is still admitted, so no waiting on a
|
|
750
|
+
// peer to confirm. A removed writer's core is frozen and reusing its keypair
|
|
751
|
+
// only gets it re-removed, so fall through to a real pairing, which mints a
|
|
752
|
+
// fresh keypair. A member row outlives a revoked device, so test the device.
|
|
775
753
|
if (target) {
|
|
776
754
|
const { data: joined } = await this.store.get('handles')
|
|
777
755
|
const existing = joined.find(
|
|
@@ -786,8 +764,7 @@ export class Handle extends ReadyResource {
|
|
|
786
764
|
}
|
|
787
765
|
}
|
|
788
766
|
|
|
789
|
-
// offline join:
|
|
790
|
-
// BLE UUID for the duration of the join — pairing rides the injected link
|
|
767
|
+
// offline join: rendezvous on the invite-derived BLE UUID for the join
|
|
791
768
|
const stopNearby = this.root.bluetooth ? this.root.bluetooth.announce(invite) : null
|
|
792
769
|
|
|
793
770
|
const child = /** @type {Child} */ (
|
|
@@ -799,8 +776,7 @@ export class Handle extends ReadyResource {
|
|
|
799
776
|
timeout
|
|
800
777
|
}).finally(() => stopNearby?.())
|
|
801
778
|
)
|
|
802
|
-
// whenWritable timing out (host offline) is
|
|
803
|
-
// fully-opened child rather than leak its Database/pairing/swarm session
|
|
779
|
+
// whenWritable timing out (host offline) is normal, don't leak the opened child
|
|
804
780
|
let publish = null
|
|
805
781
|
let abort = null
|
|
806
782
|
let inflightId = null
|
|
@@ -809,8 +785,7 @@ export class Handle extends ReadyResource {
|
|
|
809
785
|
if (!child.store.writable) await child.store.whenWritable({ timeout: deadline })
|
|
810
786
|
|
|
811
787
|
const id = toId(child.store.key)
|
|
812
|
-
// same create/open race as _create
|
|
813
|
-
// this join finishes — a concurrent _load must share this child
|
|
788
|
+
// same create/open race as _create, a concurrent _load must share this child
|
|
814
789
|
const inflight = new Promise((resolve, reject) => {
|
|
815
790
|
publish = resolve
|
|
816
791
|
abort = reject
|
|
@@ -878,8 +853,9 @@ export class Handle extends ReadyResource {
|
|
|
878
853
|
async _reopen(type, id, opts) {
|
|
879
854
|
const { data } = await this.store.get('handles', id)
|
|
880
855
|
if (!data) throw CeroError.UNKNOWN('handle', id)
|
|
881
|
-
if (data.type !== type)
|
|
856
|
+
if (data.type !== type) {
|
|
882
857
|
throw CeroError.INVALID(`handle ${id} is type ${data.type}, not ${type}`)
|
|
858
|
+
}
|
|
883
859
|
let writer = await this._loadKeyPair(id)
|
|
884
860
|
const firstTime = !writer
|
|
885
861
|
if (firstTime) {
|
|
@@ -900,9 +876,7 @@ export class Handle extends ReadyResource {
|
|
|
900
876
|
if (firstTime) {
|
|
901
877
|
await this._saveKeyPair(id, writer)
|
|
902
878
|
}
|
|
903
|
-
//
|
|
904
|
-
// host-approval gate, and one that comes back on the next open is worse
|
|
905
|
-
// than none at all
|
|
879
|
+
// `accept: false` is a host-approval gate, re-arming it silently is worse
|
|
906
880
|
if (opts?.accept !== false) this._wireAccept(child, { role: opts?.role })
|
|
907
881
|
bind(child, type)
|
|
908
882
|
this.children.add(child)
|
|
@@ -987,9 +961,7 @@ export class Handle extends ReadyResource {
|
|
|
987
961
|
|
|
988
962
|
let epochs = null
|
|
989
963
|
if (additional?.byteLength) {
|
|
990
|
-
// the epoch set is load-bearing
|
|
991
|
-
// delivery must fail the join loudly, not produce a silently inert
|
|
992
|
-
// member (hosts predating rotation send no additional payload at all)
|
|
964
|
+
// the epoch set is load-bearing, a malformed delivery must fail the join
|
|
993
965
|
try {
|
|
994
966
|
epochs = c.decode(epochEntries, additional)
|
|
995
967
|
} catch {
|
package/src/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import Hypercore from 'hypercore'
|
|
1
2
|
import HypercoreStorage from 'hypercore-storage'
|
|
2
3
|
import Corestore from 'corestore'
|
|
3
4
|
import safetyCatch from 'safety-catch'
|
|
@@ -121,9 +122,9 @@ export async function cero(dir, spec, opts = {}) {
|
|
|
121
122
|
if (local) {
|
|
122
123
|
const stored = (await local.store.get('environment')).data?.channel ?? null
|
|
123
124
|
const wanted = opts.channel ?? null
|
|
124
|
-
if (stored == null && wanted != null)
|
|
125
|
+
if (stored == null && wanted != null) {
|
|
125
126
|
await local.store.set('environment', { channel: wanted })
|
|
126
|
-
else if (stored != null && stored !== wanted) throw CeroError.CHANNEL_MISMATCH()
|
|
127
|
+
} else if (stored != null && stored !== wanted) throw CeroError.CHANNEL_MISMATCH()
|
|
127
128
|
}
|
|
128
129
|
|
|
129
130
|
network = new Network({
|
|
@@ -159,17 +160,29 @@ export async function cero(dir, spec, opts = {}) {
|
|
|
159
160
|
|
|
160
161
|
// a supplied or stored identity may already have history elsewhere —
|
|
161
162
|
// authoring genesis twice forks the seed-derived writer core (a writable
|
|
162
|
-
// core has one author, ever).
|
|
163
|
-
//
|
|
164
|
-
// undetectable — this catches the reachable-peer case
|
|
163
|
+
// core has one author, ever). Ask the peers directly with a plain session
|
|
164
|
+
// on the genesis core: the base never replicates an empty local core.
|
|
165
|
+
// Offline reuse is undetectable — this catches the reachable-peer case.
|
|
165
166
|
if (!writer && !opts.key && !opts.recovery && !fresh) {
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
167
|
+
const genesis = store.get({
|
|
168
|
+
key: Hypercore.key({
|
|
169
|
+
version: store.manifestVersion,
|
|
170
|
+
signers: [{ publicKey: identity.publicKey }]
|
|
171
|
+
})
|
|
172
|
+
})
|
|
173
|
+
await genesis.ready()
|
|
174
|
+
network.attach(genesis)
|
|
175
|
+
try {
|
|
176
|
+
const until = Date.now() + 1500
|
|
177
|
+
while (Date.now() < until) {
|
|
178
|
+
if (genesis.length > 0 || genesis.peers.some((p) => p.remoteLength > 0)) {
|
|
179
|
+
throw CeroError.CONFLICT('identity already has history — open with { recovery: true }')
|
|
180
|
+
}
|
|
181
|
+
await new Promise((r) => setTimeout(r, 100))
|
|
171
182
|
}
|
|
172
|
-
|
|
183
|
+
} finally {
|
|
184
|
+
network.detach(genesis)
|
|
185
|
+
await genesis.close()
|
|
173
186
|
}
|
|
174
187
|
}
|
|
175
188
|
if (!writer && me.store.length === 0 && (!opts.key || opts.recovery)) {
|
package/src/lib/operators.js
CHANGED
|
@@ -38,8 +38,9 @@ export function resolveFile(handle, id, name) {
|
|
|
38
38
|
* @param {Record<string, any>} row
|
|
39
39
|
* @returns {Promise<SingleResult>}
|
|
40
40
|
*/
|
|
41
|
-
export
|
|
42
|
-
ref.name === 'files' ? putFile(ref, row) : ref.handle.store.put(ref.name, row)
|
|
41
|
+
export function put(ref, row) {
|
|
42
|
+
return ref.name === 'files' ? putFile(ref, row) : ref.handle.store.put(ref.name, row)
|
|
43
|
+
}
|
|
43
44
|
|
|
44
45
|
async function putFile(ref, row) {
|
|
45
46
|
const handle = ref.handle
|
|
@@ -63,7 +64,9 @@ async function putFile(ref, row) {
|
|
|
63
64
|
* @param {{ upsert?: boolean }} [opts]
|
|
64
65
|
* @returns {Promise<SingleResult>}
|
|
65
66
|
*/
|
|
66
|
-
export
|
|
67
|
+
export function set(ref, row, opts) {
|
|
68
|
+
return ref.handle.store.set(ref.name, row, opts)
|
|
69
|
+
}
|
|
67
70
|
|
|
68
71
|
/**
|
|
69
72
|
* Delete a row by id (collection refs), or wipe the row (single refs).
|
|
@@ -72,7 +75,9 @@ export const set = (ref, row, opts) => ref.handle.store.set(ref.name, row, opts)
|
|
|
72
75
|
* @param {string} [id]
|
|
73
76
|
* @returns {Promise<void>}
|
|
74
77
|
*/
|
|
75
|
-
export
|
|
78
|
+
export function del(ref, id) {
|
|
79
|
+
return ref.handle.store.del(ref.name, id)
|
|
80
|
+
}
|
|
76
81
|
|
|
77
82
|
/**
|
|
78
83
|
* Count rows on `ref`, optionally filtered.
|
|
@@ -81,7 +86,9 @@ export const del = (ref, id) => ref.handle.store.del(ref.name, id)
|
|
|
81
86
|
* @param {Record<string, any>} [q]
|
|
82
87
|
* @returns {Promise<{ data: number }>}
|
|
83
88
|
*/
|
|
84
|
-
export
|
|
89
|
+
export function count(ref, q) {
|
|
90
|
+
return ref.handle.store.count(ref.name, q)
|
|
91
|
+
}
|
|
85
92
|
|
|
86
93
|
/**
|
|
87
94
|
* Invoke an `action`-kind ref (a custom mutation declared in the schema).
|
|
@@ -90,7 +97,9 @@ export const count = (ref, q) => ref.handle.store.count(ref.name, q)
|
|
|
90
97
|
* @param {Record<string, any>} [d]
|
|
91
98
|
* @returns {Promise<any>}
|
|
92
99
|
*/
|
|
93
|
-
export
|
|
100
|
+
export function call(ref, d) {
|
|
101
|
+
return ref.handle.store.call(ref.name, d)
|
|
102
|
+
}
|
|
94
103
|
|
|
95
104
|
// Write ops per ref kind, for `before`/`after` subscriptions.
|
|
96
105
|
const WRITES = { single: ['set'], collection: ['put', 'set', 'del'] }
|
|
@@ -105,7 +114,7 @@ const WRITES = { single: ['set'], collection: ['put', 'set', 'del'] }
|
|
|
105
114
|
* @param {{ signal?: AbortSignal }} [opts]
|
|
106
115
|
* @returns {() => void}
|
|
107
116
|
*/
|
|
108
|
-
export
|
|
117
|
+
export function before(ref, fn, opts) {
|
|
109
118
|
const db = ref.handle.store
|
|
110
119
|
const ops = WRITES[ref.kind] || ['set']
|
|
111
120
|
const offs = ops.map((op) =>
|
|
@@ -130,7 +139,7 @@ export const before = (ref, fn, opts) => {
|
|
|
130
139
|
* @param {{ signal?: AbortSignal }} [opts]
|
|
131
140
|
* @returns {() => void}
|
|
132
141
|
*/
|
|
133
|
-
export
|
|
142
|
+
export function after(ref, fn, opts) {
|
|
134
143
|
const db = ref.handle.store
|
|
135
144
|
const ops = WRITES[ref.kind] || ['set']
|
|
136
145
|
const handler = (ctx) => ctx.name === ref.name && fn(ctx)
|
|
@@ -194,7 +203,7 @@ function resolveRow(ref, row) {
|
|
|
194
203
|
* @param {string | Record<string, any>} [q]
|
|
195
204
|
* @returns {Promise<SingleResult | ListResult | GetByIdResult>}
|
|
196
205
|
*/
|
|
197
|
-
export
|
|
206
|
+
export async function get(ref, q) {
|
|
198
207
|
if (ref.kind === 'handle') {
|
|
199
208
|
const { data } = await parentStore(ref).get('handles', q)
|
|
200
209
|
return normalize(data, ref.name)
|
|
@@ -225,7 +234,7 @@ const bindStream = (owner, stream, opts) => {
|
|
|
225
234
|
* @param {{ signal?: AbortSignal }} [opts]
|
|
226
235
|
* @returns {import('streamx').Readable}
|
|
227
236
|
*/
|
|
228
|
-
export
|
|
237
|
+
export function watch(ref, q, opts) {
|
|
229
238
|
const owner = ref.handle
|
|
230
239
|
if (ref.kind !== 'handle') {
|
|
231
240
|
const src = owner.store.watch(ref.name, q)
|
|
@@ -243,7 +252,7 @@ export const watch = (ref, q, opts) => {
|
|
|
243
252
|
* batch, and any batch after a view swap, replays current state as inserts
|
|
244
253
|
* with `reset: true`). File-typed fields resolve on both sides.
|
|
245
254
|
*/
|
|
246
|
-
export
|
|
255
|
+
export function changes(ref, q, opts) {
|
|
247
256
|
const owner = ref.handle
|
|
248
257
|
if (ref.kind === 'handle') throw CeroError.INVALID('changes does not support handle refs')
|
|
249
258
|
const src = owner.store.changes(ref.name, q)
|
|
@@ -338,7 +347,7 @@ function snapshotStream(src, map) {
|
|
|
338
347
|
* @param {string | { invite?: string, id?: string, name?: string, routes?: any, role?: string, accept?: boolean } | undefined} [arg]
|
|
339
348
|
* @returns {Promise<CeroHandle>} The resolved child handle.
|
|
340
349
|
*/
|
|
341
|
-
export
|
|
350
|
+
export function open(ref, arg) {
|
|
342
351
|
if (typeof arg === 'string') return ref.handle._join(arg, ref.name)
|
|
343
352
|
if (arg && typeof arg.invite === 'string') return ref.handle._join(arg.invite, ref.name)
|
|
344
353
|
if (arg && typeof arg.id === 'string') return ref.handle._load(ref.name, arg.id, arg)
|
|
@@ -357,7 +366,9 @@ export const open = (ref, arg) => {
|
|
|
357
366
|
* @param {any} handle
|
|
358
367
|
* @returns {Promise<{ epoch: number }>}
|
|
359
368
|
*/
|
|
360
|
-
export
|
|
369
|
+
export function rotate(handle) {
|
|
370
|
+
return handle.store.rotate()
|
|
371
|
+
}
|
|
361
372
|
|
|
362
373
|
// ─── custom operators ──────────────────────────────────────────────────────
|
|
363
374
|
// App business logic lives as custom operators: pure functions whose first arg
|
package/src/local/index.js
CHANGED
|
@@ -25,8 +25,9 @@ export class Local extends ReadyResource {
|
|
|
25
25
|
*/
|
|
26
26
|
constructor(dir, spec, { root, store, storageKey } = {}) {
|
|
27
27
|
super()
|
|
28
|
-
if (!root && !store && (typeof dir !== 'string' || !dir))
|
|
28
|
+
if (!root && !store && (typeof dir !== 'string' || !dir)) {
|
|
29
29
|
throw CeroError.REQUIRED('dir, root, or store')
|
|
30
|
+
}
|
|
30
31
|
if (!spec?.local?.database) throw CeroError.REQUIRED('spec.local.database')
|
|
31
32
|
if (!spec?.meta?.local) throw CeroError.REQUIRED('spec.meta.local')
|
|
32
33
|
|
package/src/rpc/server.js
CHANGED
|
@@ -1,43 +1,3 @@
|
|
|
1
|
-
export namespace refs {
|
|
2
|
-
namespace main {
|
|
3
|
-
namespace members {
|
|
4
|
-
let type: string;
|
|
5
|
-
}
|
|
6
|
-
namespace devices {
|
|
7
|
-
let type_1: string;
|
|
8
|
-
export { type_1 as type };
|
|
9
|
-
}
|
|
10
|
-
namespace invites {
|
|
11
|
-
let type_2: string;
|
|
12
|
-
export { type_2 as type };
|
|
13
|
-
}
|
|
14
|
-
namespace handles {
|
|
15
|
-
let type_3: string;
|
|
16
|
-
export { type_3 as type };
|
|
17
|
-
}
|
|
18
|
-
namespace files {
|
|
19
|
-
let type_4: string;
|
|
20
|
-
export { type_4 as type };
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
let local: {
|
|
24
|
-
master: {
|
|
25
|
-
type: string;
|
|
26
|
-
kind: string;
|
|
27
|
-
};
|
|
28
|
-
keypair: {
|
|
29
|
-
type: string;
|
|
30
|
-
kind: string;
|
|
31
|
-
};
|
|
32
|
-
'handle-keypairs': {
|
|
33
|
-
type: string;
|
|
34
|
-
};
|
|
35
|
-
environment: {
|
|
36
|
-
type: string;
|
|
37
|
-
kind: string;
|
|
38
|
-
};
|
|
39
|
-
};
|
|
40
|
-
}
|
|
41
1
|
export function getHyperdbType(prim: any): any;
|
|
42
2
|
export function builtinRefs(ns: any, scope: any): {
|
|
43
3
|
[k: string]: {
|
|
@@ -98,3 +58,43 @@ export function rpcCommands(ns: any): ({
|
|
|
98
58
|
stream: boolean;
|
|
99
59
|
};
|
|
100
60
|
})[];
|
|
61
|
+
export namespace refs {
|
|
62
|
+
namespace main {
|
|
63
|
+
namespace members {
|
|
64
|
+
let type: string;
|
|
65
|
+
}
|
|
66
|
+
namespace devices {
|
|
67
|
+
let type_1: string;
|
|
68
|
+
export { type_1 as type };
|
|
69
|
+
}
|
|
70
|
+
namespace invites {
|
|
71
|
+
let type_2: string;
|
|
72
|
+
export { type_2 as type };
|
|
73
|
+
}
|
|
74
|
+
namespace handles {
|
|
75
|
+
let type_3: string;
|
|
76
|
+
export { type_3 as type };
|
|
77
|
+
}
|
|
78
|
+
namespace files {
|
|
79
|
+
let type_4: string;
|
|
80
|
+
export { type_4 as type };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
let local: {
|
|
84
|
+
master: {
|
|
85
|
+
type: string;
|
|
86
|
+
kind: string;
|
|
87
|
+
};
|
|
88
|
+
keypair: {
|
|
89
|
+
type: string;
|
|
90
|
+
kind: string;
|
|
91
|
+
};
|
|
92
|
+
'handle-keypairs': {
|
|
93
|
+
type: string;
|
|
94
|
+
};
|
|
95
|
+
environment: {
|
|
96
|
+
type: string;
|
|
97
|
+
kind: string;
|
|
98
|
+
};
|
|
99
|
+
};
|
|
100
|
+
}
|
package/types/handle/index.d.ts
CHANGED
|
@@ -242,9 +242,9 @@ export class Handle extends ReadyResource {
|
|
|
242
242
|
* Revoke a previously-minted invite by its string form.
|
|
243
243
|
*
|
|
244
244
|
* @param {string} invite
|
|
245
|
-
* @returns {boolean} `true` if the invite was found and removed.
|
|
245
|
+
* @returns {Promise<boolean>} `true` if the invite was found and removed.
|
|
246
246
|
*/
|
|
247
|
-
revoke(invite: string): boolean
|
|
247
|
+
revoke(invite: string): Promise<boolean>;
|
|
248
248
|
/**
|
|
249
249
|
* Accept a paired candidate — adds them as a writer (or read-only member)
|
|
250
250
|
* and confirms the pairing so they receive this handle's keys.
|
package/types/lib/operators.d.ts
CHANGED
|
@@ -22,38 +22,64 @@ export function resolveFile(handle: object, id: string, name?: string): {
|
|
|
22
22
|
name?: string;
|
|
23
23
|
};
|
|
24
24
|
/**
|
|
25
|
-
*
|
|
26
|
-
* `handle.
|
|
27
|
-
*
|
|
28
|
-
* - `null` → the registered root operators, or
|
|
29
|
-
* - a child-handle type → the registered operators for that type.
|
|
30
|
-
* The scope forms are how cero binds handles automatically; pass a map yourself
|
|
31
|
-
* for manual binding.
|
|
25
|
+
* Insert (or overwrite by id) a row on `ref`. The `files` builtin is special:
|
|
26
|
+
* `put(handle.files, { data, type, name? })` uploads the bytes to this handle's
|
|
27
|
+
* blob store, records `{ id, name }`, and resolves the file.
|
|
32
28
|
*
|
|
33
|
-
* @param {
|
|
34
|
-
* @param {Record<string, any>
|
|
35
|
-
* @returns {
|
|
29
|
+
* @param {Ref} ref
|
|
30
|
+
* @param {Record<string, any>} row
|
|
31
|
+
* @returns {Promise<SingleResult>}
|
|
36
32
|
*/
|
|
37
|
-
export function
|
|
33
|
+
export function put(ref: Ref, row: Record<string, any>): Promise<SingleResult>;
|
|
38
34
|
/**
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
35
|
+
* Upsert a row on `ref` — merges with the existing row and preserves
|
|
36
|
+
* `createdAt`. Pass `{ upsert: false }` to update-only: a missing row is left
|
|
37
|
+
* untouched instead of created (atomic — never resurrects a deleted row).
|
|
42
38
|
*
|
|
43
|
-
* @param {
|
|
39
|
+
* @param {Ref} ref
|
|
40
|
+
* @param {Record<string, any>} row
|
|
41
|
+
* @param {{ upsert?: boolean }} [opts]
|
|
42
|
+
* @returns {Promise<SingleResult>}
|
|
44
43
|
*/
|
|
45
|
-
export function define(map: Record<string, any>): void;
|
|
46
|
-
/** Test seam: clear all registered operators. */
|
|
47
|
-
export function _clearDefined(): void;
|
|
48
|
-
export function put(ref: Ref, row: Record<string, any>): Promise<SingleResult>;
|
|
49
44
|
export function set(ref: Ref, row: Record<string, any>, opts?: {
|
|
50
45
|
upsert?: boolean;
|
|
51
46
|
}): Promise<SingleResult>;
|
|
47
|
+
/**
|
|
48
|
+
* Delete a row by id (collection refs), or wipe the row (single refs).
|
|
49
|
+
*
|
|
50
|
+
* @param {Ref} ref
|
|
51
|
+
* @param {string} [id]
|
|
52
|
+
* @returns {Promise<void>}
|
|
53
|
+
*/
|
|
52
54
|
export function del(ref: Ref, id?: string): Promise<void>;
|
|
55
|
+
/**
|
|
56
|
+
* Count rows on `ref`, optionally filtered.
|
|
57
|
+
*
|
|
58
|
+
* @param {Ref} ref
|
|
59
|
+
* @param {Record<string, any>} [q]
|
|
60
|
+
* @returns {Promise<{ data: number }>}
|
|
61
|
+
*/
|
|
53
62
|
export function count(ref: Ref, q?: Record<string, any>): Promise<{
|
|
54
63
|
data: number;
|
|
55
64
|
}>;
|
|
65
|
+
/**
|
|
66
|
+
* Invoke an `action`-kind ref (a custom mutation declared in the schema).
|
|
67
|
+
*
|
|
68
|
+
* @param {Ref} ref
|
|
69
|
+
* @param {Record<string, any>} [d]
|
|
70
|
+
* @returns {Promise<any>}
|
|
71
|
+
*/
|
|
56
72
|
export function call(ref: Ref, d?: Record<string, any>): Promise<any>;
|
|
73
|
+
/**
|
|
74
|
+
* Intercept writes to `ref` before they commit — `fn(ctx)` runs in-path
|
|
75
|
+
* (awaited). Return `false` to cancel the write, or mutate `ctx.row`.
|
|
76
|
+
* Returns an unsubscribe fn; pass `{ signal }` to unsubscribe on abort.
|
|
77
|
+
*
|
|
78
|
+
* @param {Ref} ref
|
|
79
|
+
* @param {(ctx: { op: string, name: string, row: any }) => any} fn
|
|
80
|
+
* @param {{ signal?: AbortSignal }} [opts]
|
|
81
|
+
* @returns {() => void}
|
|
82
|
+
*/
|
|
57
83
|
export function before(ref: Ref, fn: (ctx: {
|
|
58
84
|
op: string;
|
|
59
85
|
name: string;
|
|
@@ -61,6 +87,16 @@ export function before(ref: Ref, fn: (ctx: {
|
|
|
61
87
|
}) => any, opts?: {
|
|
62
88
|
signal?: AbortSignal;
|
|
63
89
|
}): () => void;
|
|
90
|
+
/**
|
|
91
|
+
* Subscribe to writes on `ref` — fires after each committed write,
|
|
92
|
+
* non-blocking (observe only). Returns an unsubscribe fn; pass `{ signal }`
|
|
93
|
+
* to unsubscribe on abort.
|
|
94
|
+
*
|
|
95
|
+
* @param {Ref} ref
|
|
96
|
+
* @param {(ctx: { op: string, name: string, row: any }) => void} fn
|
|
97
|
+
* @param {{ signal?: AbortSignal }} [opts]
|
|
98
|
+
* @returns {() => void}
|
|
99
|
+
*/
|
|
64
100
|
export function after(ref: Ref, fn: (ctx: {
|
|
65
101
|
op: string;
|
|
66
102
|
name: string;
|
|
@@ -68,11 +104,50 @@ export function after(ref: Ref, fn: (ctx: {
|
|
|
68
104
|
}) => void, opts?: {
|
|
69
105
|
signal?: AbortSignal;
|
|
70
106
|
}): () => void;
|
|
107
|
+
/**
|
|
108
|
+
* Read from `ref`. For data refs, dispatches to the underlying store. For
|
|
109
|
+
* `handle`-kind refs, lists existing child handles of that type from the
|
|
110
|
+
* parent's `handles` collection.
|
|
111
|
+
*
|
|
112
|
+
* @param {Ref} ref
|
|
113
|
+
* @param {string | Record<string, any>} [q]
|
|
114
|
+
* @returns {Promise<SingleResult | ListResult | GetByIdResult>}
|
|
115
|
+
*/
|
|
71
116
|
export function get(ref: Ref, q?: string | Record<string, any>): Promise<SingleResult | ListResult | GetByIdResult>;
|
|
117
|
+
/**
|
|
118
|
+
* Live snapshot stream on `ref` — re-emits the latest `get()` result on
|
|
119
|
+
* every underlying mutation. Tied to `ref.handle`'s lifecycle: closing the
|
|
120
|
+
* handle destroys it. Pass `{ signal }` to bind it to a finer scope, or
|
|
121
|
+
* destroy the stream directly to stop watching sooner.
|
|
122
|
+
*
|
|
123
|
+
* @param {Ref} ref
|
|
124
|
+
* @param {Record<string, any>} [q]
|
|
125
|
+
* @param {{ signal?: AbortSignal }} [opts]
|
|
126
|
+
* @returns {import('streamx').Readable}
|
|
127
|
+
*/
|
|
72
128
|
export function watch(ref: Ref, q?: Record<string, any>, opts?: {
|
|
73
129
|
signal?: AbortSignal;
|
|
74
130
|
}): import("streamx").Readable;
|
|
131
|
+
/**
|
|
132
|
+
* Delta subscription: batches of `{ prev, next }` row pairs instead of
|
|
133
|
+
* full snapshots — lossless under backpressure, self-contained (the first
|
|
134
|
+
* batch, and any batch after a view swap, replays current state as inserts
|
|
135
|
+
* with `reset: true`). File-typed fields resolve on both sides.
|
|
136
|
+
*/
|
|
75
137
|
export function changes(ref: any, q: any, opts: any): any;
|
|
138
|
+
/**
|
|
139
|
+
* Open (or create / join / load) a child handle through a `handle`-kind
|
|
140
|
+
* ref. Dispatches on the normalize of `arg`:
|
|
141
|
+
*
|
|
142
|
+
* - `string` → join via an invite string
|
|
143
|
+
* - `{ invite: string }` → join via invite (object form)
|
|
144
|
+
* - `{ id: string }` → load an existing handle by id
|
|
145
|
+
* - `object | undefined` → create a new handle with the given opts
|
|
146
|
+
*
|
|
147
|
+
* @param {Ref} ref
|
|
148
|
+
* @param {string | { invite?: string, id?: string, name?: string, routes?: any, role?: string, accept?: boolean } | undefined} [arg]
|
|
149
|
+
* @returns {Promise<CeroHandle>} The resolved child handle.
|
|
150
|
+
*/
|
|
76
151
|
export function open(ref: Ref, arg?: string | {
|
|
77
152
|
invite?: string;
|
|
78
153
|
id?: string;
|
|
@@ -81,9 +156,45 @@ export function open(ref: Ref, arg?: string | {
|
|
|
81
156
|
role?: string;
|
|
82
157
|
accept?: boolean;
|
|
83
158
|
} | undefined): Promise<CeroHandle>;
|
|
159
|
+
/**
|
|
160
|
+
* Rotate a handle's encryption epoch. A fresh secret is sealed to every
|
|
161
|
+
* current member and announced through the log — members removed before the
|
|
162
|
+
* rotation cannot decrypt anything written after it. Requires the remove
|
|
163
|
+
* permission (admin or owner). Compose with removal:
|
|
164
|
+
*
|
|
165
|
+
* await cero.del(room.members, memberId)
|
|
166
|
+
* await cero.rotate(room)
|
|
167
|
+
*
|
|
168
|
+
* @param {any} handle
|
|
169
|
+
* @returns {Promise<{ epoch: number }>}
|
|
170
|
+
*/
|
|
84
171
|
export function rotate(handle: any): Promise<{
|
|
85
172
|
epoch: number;
|
|
86
173
|
}>;
|
|
174
|
+
/**
|
|
175
|
+
* Put custom operators on `handle`, currying it as their first argument so
|
|
176
|
+
* `handle.ns.fn(args)` calls `fn(handle, args)`. `arg` is either:
|
|
177
|
+
* - a `{ ns: module }` map → bind exactly those, or
|
|
178
|
+
* - `null` → the registered root operators, or
|
|
179
|
+
* - a child-handle type → the registered operators for that type.
|
|
180
|
+
* The scope forms are how cero binds handles automatically; pass a map yourself
|
|
181
|
+
* for manual binding.
|
|
182
|
+
*
|
|
183
|
+
* @param {any} handle
|
|
184
|
+
* @param {Record<string, any> | string | null} arg
|
|
185
|
+
* @returns {any} handle
|
|
186
|
+
*/
|
|
187
|
+
export function bind(handle: any, arg: Record<string, any> | string | null): any;
|
|
188
|
+
/**
|
|
189
|
+
* Register custom operators by scope. A bare key binds on the root handle; a key
|
|
190
|
+
* that names a child-handle type binds on every handle of that type. Call once
|
|
191
|
+
* at startup, before `cero()` / `connect()`, in both processes.
|
|
192
|
+
*
|
|
193
|
+
* @param {Record<string, any>} map
|
|
194
|
+
*/
|
|
195
|
+
export function define(map: Record<string, any>): void;
|
|
196
|
+
/** Test seam: clear all registered operators. */
|
|
197
|
+
export function _clearDefined(): void;
|
|
87
198
|
export type Ref = import("./utils.js").Ref;
|
|
88
199
|
export type CeroHandle = import("../handle/index.js").CeroHandle;
|
|
89
200
|
export type SingleResult = {
|