@cero-base/core 0.2.0 → 0.4.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.
Files changed (52) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +180 -136
  3. package/package.json +85 -26
  4. package/src/blobs/index.js +297 -0
  5. package/src/database/CLAUDE.md +3 -0
  6. package/src/database/bootstrap.js +76 -0
  7. package/src/database/dispatch.js +156 -0
  8. package/src/database/index.js +572 -0
  9. package/src/identity/CLAUDE.md +3 -0
  10. package/src/identity/index.js +232 -0
  11. package/src/index.js +20 -1
  12. package/src/lib/CLAUDE.md +3 -0
  13. package/src/lib/constants.js +24 -4
  14. package/src/lib/errors.js +150 -0
  15. package/src/lib/schema.js +58 -440
  16. package/src/lib/spec/index.js +353 -0
  17. package/src/lib/spec/schema.json +284 -0
  18. package/src/lib/utils.js +54 -49
  19. package/src/network/discovery.js +80 -0
  20. package/src/network/index.js +231 -0
  21. package/src/pairing/index.js +482 -0
  22. package/src/pairing/invite.js +199 -0
  23. package/src/rpc/client.js +45 -0
  24. package/src/rpc/index.js +141 -0
  25. package/src/rpc/server.js +45 -0
  26. package/src/storage/index.js +261 -0
  27. package/types/blobs/index.d.ts +169 -0
  28. package/types/database/bootstrap.d.ts +17 -0
  29. package/types/database/dispatch.d.ts +8 -0
  30. package/types/database/index.d.ts +329 -0
  31. package/types/identity/index.d.ts +160 -0
  32. package/types/index.d.ts +11 -0
  33. package/types/lib/constants.d.ts +13 -0
  34. package/types/lib/errors.d.ts +110 -0
  35. package/types/lib/schema.d.ts +53 -0
  36. package/types/lib/spec/index.d.ts +95 -0
  37. package/types/lib/utils.d.ts +39 -0
  38. package/types/network/discovery.d.ts +44 -0
  39. package/types/network/index.d.ts +115 -0
  40. package/types/pairing/index.d.ts +194 -0
  41. package/types/pairing/invite.d.ts +157 -0
  42. package/types/rpc/client.d.ts +18 -0
  43. package/types/rpc/index.d.ts +67 -0
  44. package/types/rpc/server.d.ts +18 -0
  45. package/types/storage/index.d.ts +163 -0
  46. package/src/lib/base.js +0 -84
  47. package/src/lib/batch.js +0 -98
  48. package/src/lib/builder.js +0 -24
  49. package/src/lib/collection.js +0 -252
  50. package/src/lib/crypto.js +0 -6
  51. package/src/lib/index.js +0 -6
  52. package/src/lib/room.js +0 -145
package/src/lib/utils.js CHANGED
@@ -1,60 +1,65 @@
1
- import { SCOPE_LOCAL, SCOPE_PRIVATE, SCOPE_SHARED } from './constants.js'
2
-
3
- /** Map user-facing scope to cero namespace */
4
- const SCOPE_NS = {
5
- [SCOPE_LOCAL]: 'local',
6
- [SCOPE_PRIVATE]: 'identity',
7
- [SCOPE_SHARED]: 'room'
8
- }
1
+ import crypto from 'crypto'
2
+ import { Readable } from 'streamx'
3
+ import z32 from 'z32'
4
+ import { encode, decode, isValid } from 'hypercore-id-encoding'
9
5
 
10
6
  /**
11
- * Convert plural collection name to singular type name
12
- * tasks -> task, categories -> category
7
+ * Generate a short opaque id (z32-encoded 16 random bytes).
8
+ *
9
+ * @returns {string}
13
10
  */
14
- export function singular(name) {
15
- if (name.endsWith('ies')) return name.slice(0, -3) + 'y'
16
- if (name.endsWith('s')) return name.slice(0, -1)
17
- return name
18
- }
19
-
20
- /** Get cero namespace for a scope */
21
- export function scopeNs(scope) {
22
- return SCOPE_NS[scope]
23
- }
24
-
25
- /** Get dispatch/collection path: @ns/name */
26
- export function scopePath(scope, name) {
27
- return `@${SCOPE_NS[scope]}/${name}`
28
- }
29
-
30
- export function isLocal(scope) {
31
- return scope === SCOPE_LOCAL
11
+ export function genId() {
12
+ return z32.encode(crypto.randomBytes(16))
32
13
  }
33
14
 
34
- export function isPrivate(scope) {
35
- return scope === SCOPE_PRIVATE
36
- }
15
+ /**
16
+ * z32-encode a 32-byte key into a canonical string id.
17
+ *
18
+ * @type {(key: Uint8Array) => string}
19
+ */
20
+ export const toId = encode
37
21
 
38
- export function isShared(scope) {
39
- return scope === SCOPE_SHARED
40
- }
22
+ /**
23
+ * Decode a canonical string id back into a 32-byte key.
24
+ *
25
+ * @type {(id: string) => Uint8Array}
26
+ */
27
+ export const toKey = decode
41
28
 
42
- export function debounce(fn, ms = 0) {
43
- let timer
44
- return (...args) => {
45
- clearTimeout(timer)
46
- timer = setTimeout(() => fn(...args), ms)
47
- }
48
- }
29
+ /**
30
+ * Test whether a string is a well-formed canonical id.
31
+ *
32
+ * @type {(id: string) => boolean}
33
+ */
34
+ export const isKeyId = isValid
49
35
 
50
36
  /**
51
- * Convert field definitions to hyperschema field array
37
+ * Stream-of-snapshots primitive. Couples a `get` (returns the latest value)
38
+ * with a `watch` (re-fires on change) and emits the newest snapshot every
39
+ * time `watch` ticks.
40
+ *
41
+ * @template T
42
+ * @param {object} args
43
+ * @param {() => Promise<T> | T} args.get
44
+ * @param {(fn: () => void) => (() => void) | void} args.watch Returns an unsubscribe.
45
+ * @returns {import('streamx').Readable<T>}
52
46
  */
53
- export function toFields(fields) {
54
- return Object.entries(fields).map(([name, def]) => ({
55
- name,
56
- type: def.type,
57
- required: def.required ?? true,
58
- array: def.array ?? false
59
- }))
47
+ export function subscribe({ get, watch }) {
48
+ let stop = null
49
+ const stream = new Readable({
50
+ destroy(cb) {
51
+ Promise.resolve(stop?.()).then(() => cb(null), cb)
52
+ }
53
+ })
54
+ const push = async () => {
55
+ if (stream.destroyed) return
56
+ try {
57
+ stream.push(await get())
58
+ } catch (e) {
59
+ stream.destroy(e)
60
+ }
61
+ }
62
+ stop = watch(push)
63
+ push()
64
+ return stream
60
65
  }
@@ -0,0 +1,80 @@
1
+ import safetyCatch from 'safety-catch'
2
+
3
+ import { ACTIVE, PASSIVE } from '../lib/constants.js'
4
+ import { CeroError } from '../lib/errors.js'
5
+
6
+ /**
7
+ * Handle for a single topic membership on a {@link Network}. Wraps a hyperswarm
8
+ * PeerDiscovery session and lets it switch between active/passive announce/lookup.
9
+ */
10
+ export class Discovery {
11
+ /**
12
+ * @param {import('./index.js').Network} network Owning network.
13
+ * @param {any} session Hyperswarm PeerDiscovery session from `swarm.join()`.
14
+ * @param {'active' | 'passive'} mode
15
+ */
16
+ constructor(network, session, mode) {
17
+ this.network = network
18
+ this.session = session
19
+ this._mode = mode
20
+ this._destroyed = false
21
+ }
22
+
23
+ /** @returns {'active' | 'passive'} */
24
+ get mode() {
25
+ return this._mode
26
+ }
27
+
28
+ /** @returns {boolean} */
29
+ get destroyed() {
30
+ return this._destroyed
31
+ }
32
+
33
+ /**
34
+ * Switch to active (client+server) announce/lookup.
35
+ *
36
+ * @returns {Promise<void>}
37
+ */
38
+ async activate() {
39
+ if (this._destroyed) throw CeroError.DESTROYED('Discovery')
40
+ this._mode = ACTIVE
41
+ await this.session.refresh({ client: true, server: true })
42
+ }
43
+
44
+ /**
45
+ * Switch to passive announce.
46
+ *
47
+ * @returns {Promise<void>}
48
+ */
49
+ async deactivate() {
50
+ if (this._destroyed) throw CeroError.DESTROYED('Discovery')
51
+ this._mode = PASSIVE
52
+ await this.session.refresh({ client: false, server: true })
53
+ }
54
+
55
+ /**
56
+ * Wait for the current announce/lookup to settle.
57
+ *
58
+ * @returns {Promise<void>}
59
+ */
60
+ async flush() {
61
+ if (this._destroyed) return
62
+ await this.session.flushed()
63
+ }
64
+
65
+ /**
66
+ * Leave the topic and tear down the session. Idempotent.
67
+ *
68
+ * @returns {Promise<void>}
69
+ */
70
+ async destroy() {
71
+ if (this._destroyed) return
72
+ this._destroyed = true
73
+ this.network._discoveries.delete(this)
74
+ try {
75
+ await this.session.destroy()
76
+ } catch (err) {
77
+ safetyCatch(err)
78
+ }
79
+ }
80
+ }
@@ -0,0 +1,231 @@
1
+ import Hyperswarm from 'hyperswarm'
2
+ import ProtomuxWakeup from 'protomux-wakeup'
3
+ import ReadyResource from 'ready-resource'
4
+ import safetyCatch from 'safety-catch'
5
+ import b4a from 'b4a'
6
+
7
+ import { ACTIVE, PASSIVE } from '../lib/constants.js'
8
+ import { CeroError } from '../lib/errors.js'
9
+ import { Discovery } from './discovery.js'
10
+
11
+ /**
12
+ * @typedef {object} NetworkOpts
13
+ * @property {import('../identity/index.js').Identity} [identity] Long-lived keypair used as the swarm identity.
14
+ * @property {Array<{ host: string, port: number }>} [bootstrap] Custom DHT bootstrap nodes.
15
+ * @property {(remotePublicKey: Uint8Array, payload: any) => boolean} [firewall] Incoming-connection filter.
16
+ * @property {Uint8Array[]} [relayThrough] Relay public keys to tunnel through.
17
+ *
18
+ * @typedef {{ replicate: (stream: any) => any }} Replicable
19
+ */
20
+
21
+ /**
22
+ * Hyperswarm peer-discovery + replication multiplexer. Wraps a swarm and a
23
+ * shared wakeup channel, replicating any attached resource onto every peer.
24
+ */
25
+ export class Network extends ReadyResource {
26
+ /** @param {NetworkOpts} [opts] */
27
+ constructor({ identity, bootstrap, firewall, relayThrough } = {}) {
28
+ super()
29
+ this.identity = identity || null
30
+ this.bootstrap = bootstrap || null
31
+ this.firewall = firewall || null
32
+ this.relayThrough = relayThrough || null
33
+
34
+ this.swarm = null
35
+ this.wakeup = new ProtomuxWakeup()
36
+
37
+ this._replicateables = new Set()
38
+ this._discoveries = new Set()
39
+ }
40
+
41
+ /** @returns {Map<string, any>} Known peers keyed by public-key string. */
42
+ get peers() {
43
+ return this.swarm ? this.swarm.peers : new Map()
44
+ }
45
+
46
+ /** @returns {Set<any>} Live connection streams. */
47
+ get connections() {
48
+ return this.swarm ? this.swarm.connections : new Set()
49
+ }
50
+
51
+ /** @returns {boolean} */
52
+ get suspended() {
53
+ return this.swarm?.suspended === true
54
+ }
55
+
56
+ async _open() {
57
+ const opts = {}
58
+ if (this.identity)
59
+ opts.keyPair = { publicKey: this.identity.publicKey, secretKey: this.identity.secretKey }
60
+ if (this.bootstrap) opts.bootstrap = this.bootstrap
61
+ if (this.firewall) opts.firewall = this.firewall
62
+ if (this.relayThrough) opts.relayThrough = this.relayThrough
63
+
64
+ this.swarm = new Hyperswarm(opts)
65
+
66
+ this.swarm.on('connection', (stream, info) => {
67
+ if (this.closing || this.closed) {
68
+ stream.destroy()
69
+ return
70
+ }
71
+ this.wakeup.addStream(stream)
72
+ for (const r of this._replicateables) replicateInto(r, stream)
73
+ this.emit('connection', stream, info)
74
+ })
75
+ this.swarm.on('peer-add', (peer) => this.emit('peer-add', peer))
76
+ this.swarm.on('peer-remove', (peer) => this.emit('peer-remove', peer))
77
+ }
78
+
79
+ /**
80
+ * Wait for pending DHT announces and lookups to settle, bounded by timeout.
81
+ *
82
+ * @param {{ timeout?: number }} [opts]
83
+ * @returns {Promise<void>}
84
+ */
85
+ async flush({ timeout = 500 } = {}) {
86
+ if (!this.swarm) return
87
+ await Promise.race([this.swarm.flush(), new Promise((r) => setTimeout(r, timeout))])
88
+ }
89
+
90
+ /**
91
+ * Pause the swarm — keeps state, drops sockets. Idempotent.
92
+ *
93
+ * @returns {Promise<void>}
94
+ */
95
+ async suspend() {
96
+ if (!this.swarm || this.closing || this.closed) return
97
+ if (this.swarm.suspended) return
98
+ try {
99
+ await this.swarm.suspend()
100
+ } catch (err) {
101
+ safetyCatch(err)
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Resume a suspended swarm. Idempotent.
107
+ *
108
+ * @returns {Promise<void>}
109
+ */
110
+ async resume() {
111
+ if (!this.swarm || this.closing || this.closed) return
112
+ if (!this.swarm.suspended) return
113
+ try {
114
+ await this.swarm.resume()
115
+ } catch (err) {
116
+ safetyCatch(err)
117
+ }
118
+ }
119
+
120
+ async _close() {
121
+ for (const d of [...this._discoveries]) {
122
+ try {
123
+ await d.destroy()
124
+ } catch (err) {
125
+ safetyCatch(err)
126
+ }
127
+ }
128
+
129
+ if (this.swarm) {
130
+ try {
131
+ await this.flush()
132
+ } catch (err) {
133
+ safetyCatch(err)
134
+ }
135
+ try {
136
+ await this.swarm.destroy()
137
+ } catch (err) {
138
+ safetyCatch(err)
139
+ }
140
+ this.swarm = null
141
+ }
142
+
143
+ if (this.wakeup) {
144
+ try {
145
+ await this.wakeup.destroy()
146
+ } catch (err) {
147
+ safetyCatch(err)
148
+ }
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Announce/lookup on a 32-byte topic and return a Discovery handle.
154
+ *
155
+ * @param {Uint8Array} topic
156
+ * @param {{ mode?: 'active' | 'passive' }} [opts] `active` = client+server, `passive` = server-only
157
+ * @returns {Discovery}
158
+ */
159
+ join(topic, { mode = ACTIVE } = {}) {
160
+ if (mode !== ACTIVE && mode !== PASSIVE) {
161
+ throw CeroError.INVALID('mode must be "active" or "passive"')
162
+ }
163
+ if (this.closing || this.closed) throw CeroError.CLOSED('Network')
164
+ if (!this.swarm) throw CeroError.NOT_READY('Network', 'network')
165
+ if (!isTopic(topic)) throw CeroError.INVALID('topic must be a 32-byte buffer')
166
+
167
+ const session =
168
+ mode === ACTIVE
169
+ ? this.swarm.join(topic, { client: true, server: true })
170
+ : this.swarm.join(topic, { client: false, server: true })
171
+
172
+ const discovery = new Discovery(this, session, mode)
173
+ this._discoveries.add(discovery)
174
+ return discovery
175
+ }
176
+
177
+ /**
178
+ * Register a replicable resource (hypercore, autobee, hyperdb).
179
+ * It is replicated on every current and future swarm connection.
180
+ *
181
+ * @param {Replicable} core
182
+ * @returns {void}
183
+ */
184
+ attach(core) {
185
+ if (!core) throw CeroError.REQUIRED('core')
186
+ this._replicateables.add(core)
187
+ if (this.swarm) {
188
+ for (const stream of this.swarm.connections) replicateInto(core, stream)
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Unregister a previously attached resource. New connections will no
194
+ * longer replicate it (existing replication streams continue).
195
+ *
196
+ * @param {Replicable} core
197
+ * @returns {void}
198
+ */
199
+ detach(core) {
200
+ if (!core) throw CeroError.REQUIRED('core')
201
+ this._replicateables.delete(core)
202
+ }
203
+
204
+ /**
205
+ * Replicate a one-off resource onto every current swarm connection
206
+ * without registering it as a long-lived attachment.
207
+ *
208
+ * @param {Replicable} target
209
+ * @returns {void}
210
+ */
211
+ replicate(target) {
212
+ if (!target || typeof target.replicate !== 'function') {
213
+ throw CeroError.INVALID('target must be an object with a replicate(stream) method')
214
+ }
215
+ if (this.swarm) {
216
+ for (const stream of this.swarm.connections) replicateInto(target, stream)
217
+ }
218
+ }
219
+ }
220
+
221
+ function replicateInto(core, stream) {
222
+ try {
223
+ core.replicate(stream)
224
+ } catch (err) {
225
+ safetyCatch(err)
226
+ }
227
+ }
228
+
229
+ function isTopic(x) {
230
+ return (b4a.isBuffer(x) || x instanceof Uint8Array) && x.length === 32
231
+ }