@cero-base/core 0.0.5 → 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 +200 -61
  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 +59 -194
  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 -98
  47. package/src/lib/batch.js +0 -95
  48. package/src/lib/builder.js +0 -24
  49. package/src/lib/collection.js +0 -208
  50. package/src/lib/crypto.js +0 -6
  51. package/src/lib/index.js +0 -6
  52. package/src/lib/room.js +0 -156
@@ -0,0 +1,572 @@
1
+ import Autobee from 'autobee'
2
+ import HyperDB from 'hyperdb'
3
+ import Hypercore from 'hypercore'
4
+ import ReadyResource from 'ready-resource'
5
+ import safetyCatch from 'safety-catch'
6
+ import b4a from 'b4a'
7
+
8
+ import { NAMESPACE, SINGLE, COLLECTION } from '../lib/constants.js'
9
+ import { genId, subscribe } from '../lib/utils.js'
10
+ import { CeroError } from '../lib/errors.js'
11
+ import { bootstrap } from './bootstrap.js'
12
+ import { makeDispatcher } from './dispatch.js'
13
+
14
+ /**
15
+ * @typedef {object} DatabaseOpts
16
+ * @property {any} store Corestore (or compatible) used to materialize the autobee.
17
+ * @property {import('../identity/index.js').Identity} identity Long-lived member identity used to sign writer changes.
18
+ * @property {import('../network/index.js').Network} [network] Optional swarm; required for multi-writer replication.
19
+ * @property {{ database: any, dispatch: any, meta?: { ns?: string, refs?: Record<string, { kind?: string, verb?: string }> } }} spec Generated hyperdb + hyperdispatch spec.
20
+ * @property {Record<string, Function>} [routes] Custom action handlers keyed by route name.
21
+ * @property {string} [namespace] Corestore namespace; defaults to `cero`.
22
+ * @property {Uint8Array | null} [encryptionKey] Optional encryption key; falls back to identity's key.
23
+ * @property {(nodes: any, view: any, host: any) => Promise<void>} [apply] Override the default apply function.
24
+ * @property {Uint8Array | null} [key] Existing autobee key to reopen.
25
+ * @property {import('../identity/index.js').KeyPair} [keyPair] Device writer keypair; defaults to identity's keypair.
26
+ *
27
+ * @typedef {{ data: any | null }} SingleResult
28
+ * @typedef {{ data: any[], total: number, size: number }} ListResult
29
+ * @typedef {{ gt?: string, gte?: string, lt?: string, lte?: string, reverse?: boolean, limit?: number }} Query
30
+ * @typedef {{ kind: string, verb: string, name: string }} Ref
31
+ * @typedef {(ctx: any) => any | Promise<any>} HookFn
32
+ */
33
+
34
+ /**
35
+ * Multi-writer database built on Autobee + HyperDB. Persists rows via a
36
+ * hyperdispatch-encoded log and exposes a `put/get/...` surface mirroring
37
+ * `Storage`, plus membership ops (claim/addWriter/removeWriter) for the
38
+ * shared writer set.
39
+ */
40
+ export class Database extends ReadyResource {
41
+ /** @param {Partial<DatabaseOpts>} [opts] */
42
+ constructor(opts = {}) {
43
+ super()
44
+
45
+ if (!opts.store) throw CeroError.REQUIRED('store')
46
+ if (!opts.identity) throw CeroError.REQUIRED('identity')
47
+ if (!opts.spec || !opts.spec.database || !opts.spec.dispatch) {
48
+ throw CeroError.INVALID('spec must have database + dispatch')
49
+ }
50
+
51
+ this.store = opts.store
52
+ this.identity = opts.identity
53
+ this.network = opts.network || null
54
+ this.spec = opts.spec
55
+ this.meta = opts.spec.meta || { ns: NAMESPACE, refs: {} }
56
+ this.ns = this.meta.ns || NAMESPACE
57
+ this.refs = this.meta.refs || {}
58
+ this.routes = opts.routes || {}
59
+ this.namespace = opts.namespace || NAMESPACE
60
+ this.encryptionKey = opts.encryptionKey || opts.identity.encryptionKey || null
61
+ this.applyOverride = opts.apply || null
62
+ this.key = opts.key || null
63
+ this.keyPair = opts.keyPair || {
64
+ publicKey: opts.identity.publicKey,
65
+ secretKey: opts.identity.secretKey
66
+ }
67
+
68
+ this.bee = null
69
+ this.dispatcher = null
70
+
71
+ this.beforeHooks = new Map()
72
+ this.afterHooks = new Map()
73
+ this.updaters = new Set()
74
+ this.txQueue = null
75
+ }
76
+
77
+ async _open() {
78
+ await this.store.ready()
79
+ if (this.network) await this.network.ready()
80
+
81
+ await this.openBee()
82
+ }
83
+
84
+ async _close() {
85
+ if (this._discovery) {
86
+ await this._discovery.destroy()
87
+ this._discovery = null
88
+ }
89
+ if (this.bee) {
90
+ await this.bee.close()
91
+ this.bee = null
92
+ }
93
+ }
94
+
95
+ /** Open the underlying autobee, wire dispatcher + apply, attach to network. */
96
+ async openBee() {
97
+ this.dispatcher = makeDispatcher(this.spec, this.ns, this.routes)
98
+
99
+ const bee = new Autobee(this.store.namespace(this.namespace), this.key, {
100
+ keyPair: this.keyPair,
101
+ encryptionKey: this.encryptionKey,
102
+ optimistic: true,
103
+ wakeup: this.network?.wakeup || undefined,
104
+ open: (b) => HyperDB.bee2(b, this.spec.database, { autoUpdate: true }),
105
+ apply: (nodes, view, host) =>
106
+ (this.applyOverride || this.dispatcher.apply)(nodes, view, host),
107
+ update: async (db) => {
108
+ await db.update()
109
+ for (const fn of this.updaters) fn()
110
+ this.emit('update')
111
+ }
112
+ })
113
+
114
+ await bee.ready()
115
+ await bee.update()
116
+
117
+ this.bee = bee
118
+ this.key = bee.key
119
+
120
+ bee.on('writable', () => this.emit('writable'))
121
+
122
+ if (this.network) {
123
+ this.network.attach(bee)
124
+ this._discovery = this.network.join(bee.discoveryKey)
125
+ }
126
+ }
127
+
128
+ get discoveryKey() {
129
+ return this.bee?.discoveryKey || null
130
+ }
131
+
132
+ get writerKey() {
133
+ return this.bee?.local?.key || null
134
+ }
135
+
136
+ get writable() {
137
+ return this.bee?.writable === true
138
+ }
139
+
140
+ get length() {
141
+ return this.bee?.local?.length || 0
142
+ }
143
+
144
+ get view() {
145
+ return this.bee?.view || null
146
+ }
147
+
148
+ /**
149
+ * Register a pre-op hook. Returning `false` from `fn` aborts the op.
150
+ *
151
+ * @param {string} op
152
+ * @param {HookFn} fn
153
+ * @returns {() => void} disposer
154
+ */
155
+ before(op, fn) {
156
+ return this._addHook(this.beforeHooks, op, fn)
157
+ }
158
+
159
+ /**
160
+ * Register a post-op hook, fired after the write succeeds.
161
+ *
162
+ * @param {string} op
163
+ * @param {HookFn} fn
164
+ * @returns {() => void} disposer
165
+ */
166
+ after(op, fn) {
167
+ return this._addHook(this.afterHooks, op, fn)
168
+ }
169
+
170
+ /**
171
+ * Subscribe to local apply notifications. Fires whenever the view updates.
172
+ *
173
+ * @param {() => void} fn
174
+ * @returns {() => void} disposer
175
+ */
176
+ onUpdate(fn) {
177
+ this.updaters.add(fn)
178
+ return () => this.updaters.delete(fn)
179
+ }
180
+
181
+ /**
182
+ * Run all registered `before:op` hooks and emit the corresponding event.
183
+ *
184
+ * @param {string} op
185
+ * @param {any} ctx
186
+ * @returns {Promise<boolean>} `false` if any hook vetoed the op
187
+ */
188
+ async runBefore(op, ctx) {
189
+ const hooks = this.beforeHooks.get(op)
190
+ if (hooks) {
191
+ for (const fn of hooks) {
192
+ const out = await fn(ctx)
193
+ if (out === false) return false
194
+ }
195
+ }
196
+ this.emit(`before:${op}`, ctx)
197
+ return true
198
+ }
199
+
200
+ /**
201
+ * Run all registered `after:op` hooks and emit the corresponding event.
202
+ *
203
+ * @param {string} op
204
+ * @param {any} ctx
205
+ * @returns {Promise<void>}
206
+ */
207
+ async runAfter(op, ctx) {
208
+ const hooks = this.afterHooks.get(op)
209
+ if (hooks) {
210
+ for (const fn of hooks) {
211
+ try {
212
+ await fn(ctx)
213
+ } catch (err) {
214
+ safetyCatch(err)
215
+ }
216
+ }
217
+ }
218
+ this.emit(`after:${op}`, ctx)
219
+ }
220
+
221
+ /**
222
+ * Insert (or overwrite by id) a row, stamping `id`/`createdAt`/`updatedAt`.
223
+ *
224
+ * @param {string} name
225
+ * @param {Record<string, any>} row
226
+ * @returns {Promise<SingleResult | null>}
227
+ */
228
+ async put(name, row) {
229
+ this.guard()
230
+ const ref = this.ref(name)
231
+ const id = row.id || genId()
232
+ const ts = Date.now()
233
+ const stored = { createdAt: ts, updatedAt: ts, ...row, id }
234
+
235
+ const ctx = { op: 'put', name, row: stored }
236
+ if ((await this.runBefore('put', ctx)) === false) return null
237
+
238
+ await this.write([[`add-${ref.verb}`, ctx.row]])
239
+
240
+ const result = { data: ctx.row }
241
+ await this.runAfter('put', { ...ctx, result })
242
+ return result
243
+ }
244
+
245
+ /**
246
+ * Upsert by merging with the existing row, preserving `createdAt`.
247
+ *
248
+ * @param {string} name
249
+ * @param {Record<string, any>} row
250
+ * @returns {Promise<SingleResult | null>}
251
+ */
252
+ async set(name, row) {
253
+ this.guard()
254
+ const ref = this.ref(name)
255
+ const col = this.col(ref)
256
+ const ts = Date.now()
257
+ const existing =
258
+ ref.kind === SINGLE
259
+ ? await this.view.findOne(col, {})
260
+ : row?.id
261
+ ? ((await this.view.get(col, { id: row.id })) ?? null)
262
+ : null
263
+ const stored = {
264
+ ...existing,
265
+ ...row,
266
+ createdAt: existing?.createdAt ?? ts,
267
+ updatedAt: ts
268
+ }
269
+ if (ref.kind === COLLECTION && !stored.id) stored.id = genId()
270
+
271
+ const ctx = { op: 'set', name, row: stored }
272
+ if ((await this.runBefore('set', ctx)) === false) return null
273
+
274
+ await this.write([[`set-${ref.verb}`, ctx.row]])
275
+
276
+ const result = { data: ctx.row }
277
+ await this.runAfter('set', { ...ctx, result })
278
+ return result
279
+ }
280
+
281
+ /**
282
+ * Delete by id (collection) or wipe the whole single-row table.
283
+ *
284
+ * @param {string} name
285
+ * @param {string} [id]
286
+ * @returns {Promise<void | null>}
287
+ */
288
+ async del(name, id) {
289
+ this.guard()
290
+ const ref = this.ref(name)
291
+ const ctx = { op: 'del', name, id }
292
+ if ((await this.runBefore('del', ctx)) === false) return null
293
+
294
+ await this.write([[`del-${ref.verb}`, { id }]])
295
+
296
+ await this.runAfter('del', ctx)
297
+ }
298
+
299
+ /**
300
+ * Dispatch a custom action route by name.
301
+ *
302
+ * @param {string} op
303
+ * @param {Record<string, any>} [data]
304
+ * @returns {Promise<void>}
305
+ */
306
+ async call(op, data = {}) {
307
+ this.guard()
308
+ await this.write([[op, data]])
309
+ }
310
+
311
+ /**
312
+ * Batch every write performed inside `fn` into a single autobee append.
313
+ * Nested calls reuse the outer queue.
314
+ *
315
+ * @template T
316
+ * @param {() => Promise<T> | T} fn
317
+ * @returns {Promise<T>}
318
+ */
319
+ async tx(fn) {
320
+ if (this.txQueue) return fn()
321
+ const queue = []
322
+ this.txQueue = queue
323
+ let result
324
+ try {
325
+ result = await fn()
326
+ } catch (err) {
327
+ this.txQueue = null
328
+ throw err
329
+ }
330
+ this.txQueue = null
331
+ if (queue.length) await this.write(queue)
332
+ return result
333
+ }
334
+
335
+ /**
336
+ * Encode and append dispatch ops. Buffers into the active `tx` queue if one
337
+ * is open.
338
+ *
339
+ * @param {Array<[string, any]>} ops
340
+ * @returns {Promise<void>}
341
+ */
342
+ async write(ops) {
343
+ if (this.txQueue) {
344
+ for (const op of ops) this.txQueue.push(op)
345
+ return
346
+ }
347
+ if (!this.bee.writable) throw CeroError.NOT_WRITABLE('Database')
348
+ const encoded = ops.map(([op, payload]) =>
349
+ this.spec.dispatch.encode(`@${this.ns}/${op}`, payload)
350
+ )
351
+ await this.bee.append(encoded.length === 1 ? encoded[0] : encoded)
352
+ }
353
+
354
+ /**
355
+ * Read a row. With no `query`: list all (collection) or fetch the one
356
+ * record (single). With a string id: fetch that specific row.
357
+ *
358
+ * @param {string} name
359
+ * @param {string | Query} [query]
360
+ * @returns {Promise<SingleResult | ListResult>}
361
+ */
362
+ async get(name, query) {
363
+ this.guard()
364
+ const ref = this.ref(name)
365
+ const col = this.col(ref)
366
+
367
+ if (ref.kind === SINGLE) {
368
+ return { data: await this.view.findOne(col, {}) }
369
+ }
370
+
371
+ if (typeof query === 'string') {
372
+ return { data: await this.view.get(col, { id: query }) }
373
+ }
374
+
375
+ const all = await this.view.find(col, {}).toArray()
376
+ const total = all.length
377
+ const data = paginate(all, query)
378
+ return { data, total, size: data.length }
379
+ }
380
+
381
+ /**
382
+ * Number of rows that match `query` (or total if omitted).
383
+ *
384
+ * @param {string} name
385
+ * @param {Query} [query]
386
+ * @returns {Promise<{ data: number }>}
387
+ */
388
+ async count(name, query) {
389
+ this.guard()
390
+ const ref = this.ref(name)
391
+ const all = await this.view.find(this.col(ref), {}).toArray()
392
+ return { data: paginate(all, query).length }
393
+ }
394
+
395
+ /**
396
+ * Live snapshot stream — re-emits the latest `get()` result on every
397
+ * underlying mutation. Destroy the stream to stop watching.
398
+ *
399
+ * @param {string} name
400
+ * @param {Query} [query]
401
+ * @returns {import('streamx').Readable}
402
+ */
403
+ watch(name, query) {
404
+ this.guard()
405
+ this.ref(name)
406
+ return subscribe({
407
+ get: () => this.get(name, query),
408
+ watch: (fn) => this.onUpdate(fn)
409
+ })
410
+ }
411
+
412
+ /**
413
+ * First-run bootstrap: create the device writer, save it, and swap into it.
414
+ *
415
+ * @param {{ name?: string | null, isMobile?: boolean, recovering?: boolean }} [opts]
416
+ * @returns {Promise<{ id: Uint8Array, writer: import('../identity/index.js').KeyPair }>}
417
+ */
418
+ bootstrap(opts) {
419
+ this.guard()
420
+ return bootstrap(this, opts)
421
+ }
422
+
423
+ /**
424
+ * Claim writership on an existing room by signing our writer key with the
425
+ * member identity and appending optimistically.
426
+ *
427
+ * @param {{ name?: string | null, isMobile?: boolean }} [opts]
428
+ * @returns {Promise<void>}
429
+ */
430
+ async claim({ name = null, isMobile = false } = {}) {
431
+ this.guard()
432
+ if (this.bee.writable) return
433
+
434
+ const writerKey = this.writerKey
435
+ const sig = this.identity.sign(writerKey)
436
+ const encoded = this.spec.dispatch.encode(`@${this.ns}/claim-writer`, {
437
+ identity: this.identity.publicKey,
438
+ writer: writerKey,
439
+ sig,
440
+ name,
441
+ isMobile
442
+ })
443
+ await this.bee.append(encoded, { optimistic: true })
444
+ await this.bee.update()
445
+ if (!this.bee.writable) await this.whenWritable()
446
+ }
447
+
448
+ /**
449
+ * Resolve once the bee becomes writable, or reject after `timeout` ms.
450
+ *
451
+ * @param {{ timeout?: number }} [opts]
452
+ * @returns {Promise<void>}
453
+ */
454
+ async whenWritable({ timeout = 30000 } = {}) {
455
+ this.guard()
456
+ if (this.bee.writable) return
457
+ return new Promise((resolve, reject) => {
458
+ let timer = null
459
+ let pollTimer = null
460
+ let done = false
461
+
462
+ const finish = (err) => {
463
+ if (done) return
464
+ done = true
465
+ if (timer) clearTimeout(timer)
466
+ if (pollTimer) clearInterval(pollTimer)
467
+ if (this.bee) this.bee.off('writable', onWritable)
468
+ this.off('close', onClose)
469
+ if (err) reject(err)
470
+ else resolve()
471
+ }
472
+
473
+ const onWritable = () => finish()
474
+ const onClose = () => finish(CeroError.CLOSED('Database'))
475
+ const poll = async () => {
476
+ if (done || !this.bee) return
477
+ try {
478
+ await this.bee.update()
479
+ if (!done && this.bee.writable) finish()
480
+ } catch {}
481
+ }
482
+
483
+ this.bee.on('writable', onWritable)
484
+ this.on('close', onClose)
485
+ pollTimer = setInterval(poll, 1000)
486
+ if (timeout > 0) {
487
+ timer = setTimeout(() => finish(CeroError.TIMED_OUT('whenWritable')), timeout)
488
+ }
489
+ })
490
+ }
491
+
492
+ /**
493
+ * Add a peer's writer key to the indexer set.
494
+ *
495
+ * @param {Uint8Array} publicKey
496
+ * @returns {Promise<void>}
497
+ */
498
+ async addWriter(publicKey) {
499
+ return this._write('add-writer', 'addWriter', publicKey)
500
+ }
501
+
502
+ /**
503
+ * Remove a peer's writer key from the indexer set.
504
+ *
505
+ * @param {Uint8Array} publicKey
506
+ * @returns {Promise<void>}
507
+ */
508
+ async removeWriter(publicKey) {
509
+ return this._write('del-writer', 'removeWriter', publicKey)
510
+ }
511
+
512
+ guard() {
513
+ if (this.closing || this.closed) throw CeroError.CLOSED('Database')
514
+ if (!this.bee) throw CeroError.NOT_READY('Database', 'db')
515
+ }
516
+
517
+ ref(name) {
518
+ if (typeof name !== 'string' || !name)
519
+ throw CeroError.INVALID('name must be a non-empty string')
520
+ const ref = this.refs[name]
521
+ if (!ref) throw CeroError.UNKNOWN('ref', name)
522
+ return { name, kind: ref.kind || COLLECTION, verb: ref.verb || name }
523
+ }
524
+
525
+ col(ref) {
526
+ return `@${this.ns}/${ref.name}`
527
+ }
528
+
529
+ _addHook(map, op, fn) {
530
+ if (typeof fn !== 'function') throw CeroError.INVALID('hook fn must be a function')
531
+ let arr = map.get(op)
532
+ if (!arr) map.set(op, (arr = []))
533
+ arr.push(fn)
534
+ return () => {
535
+ const i = arr.indexOf(fn)
536
+ if (i >= 0) arr.splice(i, 1)
537
+ }
538
+ }
539
+
540
+ async _write(verb, hook, publicKey) {
541
+ this.guard()
542
+ if (!b4a.isBuffer(publicKey) && !(publicKey instanceof Uint8Array)) {
543
+ throw CeroError.INVALID('publicKey must be a buffer')
544
+ }
545
+ const ctx = { op: hook, publicKey }
546
+ if ((await this.runBefore(hook, ctx)) === false) return
547
+ const writerKey = Hypercore.key({
548
+ version: this.store.manifestVersion,
549
+ signers: [{ publicKey }]
550
+ })
551
+ const sig = this.identity.sign(writerKey)
552
+ await this.write([
553
+ [
554
+ verb,
555
+ { master: this.identity.publicKey, writer: writerKey, sig, name: null, isMobile: false }
556
+ ]
557
+ ])
558
+ await this.runAfter(hook, ctx)
559
+ }
560
+ }
561
+
562
+ function paginate(rows, query) {
563
+ if (!query) return rows
564
+ let out = rows
565
+ if (query.gt !== undefined) out = out.filter((r) => r.id > query.gt)
566
+ if (query.gte !== undefined) out = out.filter((r) => r.id >= query.gte)
567
+ if (query.lt !== undefined) out = out.filter((r) => r.id < query.lt)
568
+ if (query.lte !== undefined) out = out.filter((r) => r.id <= query.lte)
569
+ if (query.reverse) out = [...out].reverse()
570
+ if (query.limit !== undefined) out = out.slice(0, query.limit)
571
+ return out
572
+ }
@@ -0,0 +1,3 @@
1
+ <claude-mem-context>
2
+
3
+ </claude-mem-context>