@cero-base/cero 0.8.5 → 0.8.6

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/README.md CHANGED
@@ -191,6 +191,52 @@ cero.after(me.profile, ({ row }) => {
191
191
 
192
192
  `ctx` is `{ op, name, row }` (plus `result` in `after`).
193
193
 
194
+ ## Custom operators
195
+
196
+ Your app's business logic lives as **custom operators** — plain functions whose first arg is the handle they act on, composing the built-ins. Because the built-ins resolve through the handle (a real DB on the core, an RPC proxy on the client), custom operators are symmetric over RPC for free.
197
+
198
+ ```js
199
+ // guest.js — pure functions, one per export
200
+ import { put, del } from '@cero-base/cero'
201
+ import { ensureGuestId } from './ids.js'
202
+
203
+ export const create = (room, data) => put(room.guests, { ...data, id: ensureGuestId(data.id) })
204
+ export const remove = (room, id) => del(room.guests, id)
205
+ ```
206
+
207
+ ### `cero.define(map)`
208
+
209
+ Register custom operators by **scope**, once, and cero puts them on every matching handle — the root and each child as it opens, on both the core and the client. A **bare key** binds on the root handle; a key that names a **child-handle type** binds on each handle of that type:
210
+
211
+ ```js
212
+ import * as user from './user.js'
213
+ import * as guest from './guest.js'
214
+ import * as station from './station.js'
215
+
216
+ cero.define({
217
+ user, // bound on the root → me.user.rename(…)
218
+ room: { guest, station } // bound on each room → room.guest.create(…)
219
+ })
220
+
221
+ const room = await cero.open(me.room, { id })
222
+ await room.guest.create({ name: 'Bob' }) // no binding at the call site
223
+ ```
224
+
225
+ cero reads the spec to tell root namespaces from child-handle types, so there is no `root` wrapper. Call `cero.define(...)` once at startup — in the build/server process **and** the client — the same as extensions.
226
+
227
+ ### `cero.bind(handle, map)`
228
+
229
+ The primitive `define` uses. Curry a handle onto a `{ ns: module }` map yourself — for a handle you opened manually, or to bind operators you don't want globally registered:
230
+
231
+ ```js
232
+ import * as guest from './guest.js'
233
+
234
+ cero.bind(room, { guest })
235
+ await room.guest.create({ name: 'Bob' }) // → guest.create(room, …)
236
+ ```
237
+
238
+ Non-functions in the map are skipped; the handle is returned.
239
+
194
240
  ## Extensions
195
241
 
196
242
  An extension bundles **schema** (build-time) with **behavior** (runtime). Register it with `cero.use()` — `build()` folds in its schema and `cero()` runs its `setup` once the handle is ready. cero core stays schema-agnostic; apps opt into the behaviors they want.
@@ -373,7 +419,7 @@ const remote = await connect(c, spec)
373
419
 
374
420
  | Path | What you get |
375
421
  | ---------------------------- | ------------------------------------------------------------------------ |
376
- | `@cero-base/cero` | factory + operators + schema DSL |
422
+ | `@cero-base/cero` | factory + operators + schema DSL + `define`/`bind` |
377
423
  | `@cero-base/cero/client` | `connect(ipc, spec)` — talk to a cero running in another process |
378
424
  | `@cero-base/cero/server` | `serve(ipc, { storage, spec })` — run + expose a cero over an IPC stream |
379
425
  | `@cero-base/cero/build` | `build(specDir, schema)` — generate the on-disk spec |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cero-base/cero",
3
- "version": "0.8.5",
3
+ "version": "0.8.6",
4
4
  "description": "The ideal p2p API — everything is a handle, handles contain refs, refs contain rows.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -78,12 +78,12 @@
78
78
  "test": "ls test/*.test.js | xargs -P1 -n1 brittle-node"
79
79
  },
80
80
  "dependencies": {
81
- "@cero-base/core": "^0.8.5",
81
+ "@cero-base/core": "^0.8.6",
82
82
  "b4a": "^1.8.1",
83
83
  "bare-abort-controller": "^1.1.2",
84
- "bare-crypto": "^1.13.7",
85
- "bare-fs": "^4.7.1",
86
- "bare-path": "^3.0.0",
84
+ "bare-crypto": "^1.14.1",
85
+ "bare-fs": "^4.7.2",
86
+ "bare-path": "^3.0.1",
87
87
  "blind-pairing": "^2.3.1",
88
88
  "compact-encoding": "^3.1.0",
89
89
  "hrpc": "^4.3.0",
@@ -97,7 +97,7 @@
97
97
  "devDependencies": {
98
98
  "@hyperswarm/testnet": "^3.1.4",
99
99
  "brittle": "^4.0.0",
100
- "typescript": "^5.7.0"
100
+ "typescript": "^6.0.3"
101
101
  },
102
102
  "license": "Apache-2.0"
103
103
  }
@@ -15,6 +15,7 @@ import { CeroError } from '@cero-base/core/errors'
15
15
  import { NS, TIMEOUT } from '../lib/constants.js'
16
16
 
17
17
  import { attachRefs, onAbort } from '../lib/utils.js'
18
+ import { bind } from '../lib/operators.js'
18
19
 
19
20
  export { Ref } from '../lib/utils.js'
20
21
 
@@ -399,6 +400,7 @@ export class Handle extends ReadyResource {
399
400
  })
400
401
 
401
402
  if (accept !== false) this._wireAccept(child, { role })
403
+ bind(child, type)
402
404
  this.children.add(child)
403
405
  this.emit('handle', child, { name, role })
404
406
  return child
@@ -454,6 +456,7 @@ export class Handle extends ReadyResource {
454
456
  updatedAt: ts
455
457
  })
456
458
  this._wireAccept(child)
459
+ bind(child, type)
457
460
  this.children.add(child)
458
461
  this.emit('handle', child, {})
459
462
  return child
@@ -517,6 +520,7 @@ export class Handle extends ReadyResource {
517
520
  await this._saveKeyPair(id, writer)
518
521
  }
519
522
  this._wireAccept(child)
523
+ bind(child, type)
520
524
  this.children.add(child)
521
525
  this.emit('handle', child, {})
522
526
  return child
package/src/index.js CHANGED
@@ -8,15 +8,41 @@ import { CeroError } from '@cero-base/core/errors'
8
8
 
9
9
  import { Handle, Ref } from './handle/index.js'
10
10
  import { Local } from './local/index.js'
11
- import { put, set, get, del, count, watch, call, open, before, after } from './lib/operators.js'
12
- import { peek } from './lib/peek.js'
11
+ import {
12
+ put,
13
+ set,
14
+ get,
15
+ del,
16
+ count,
17
+ watch,
18
+ call,
19
+ open,
20
+ before,
21
+ after,
22
+ bind,
23
+ define,
24
+ peek
25
+ } from './lib/operators.js'
13
26
  import { t, schema } from './lib/spec.js'
14
27
  import { FLUSH } from './lib/constants.js'
15
28
  import { internal } from './lib/internal.js'
16
29
 
17
30
  export { Handle, Ref, Local }
18
- export { put, set, get, del, count, watch, call, open, before, after } from './lib/operators.js'
19
- export { peek } from './lib/peek.js'
31
+ export {
32
+ put,
33
+ set,
34
+ get,
35
+ del,
36
+ count,
37
+ watch,
38
+ call,
39
+ open,
40
+ before,
41
+ after,
42
+ bind,
43
+ define,
44
+ peek
45
+ } from './lib/operators.js'
20
46
  export { t, schema } from './lib/spec.js'
21
47
 
22
48
  /**
@@ -123,6 +149,7 @@ export async function cero(dir, spec, opts = {}) {
123
149
  throw err
124
150
  }
125
151
 
152
+ bind(me, null)
126
153
  return me
127
154
  }
128
155
 
@@ -175,7 +202,9 @@ Object.assign(cero, {
175
202
  after,
176
203
  peek,
177
204
  restore,
178
- schema
205
+ schema,
206
+ bind,
207
+ define
179
208
  })
180
209
  cero._internal = internal
181
210
  // A bare function is shorthand for a behavior-only extension: `{ setup: fn }`.
@@ -1,6 +1,11 @@
1
1
  import { Readable } from 'streamx'
2
+ import HypercoreStorage from 'hypercore-storage'
3
+ import Corestore from 'corestore'
4
+
5
+ import { CeroError } from '@cero-base/core/errors'
2
6
 
3
7
  import { onAbort } from './utils.js'
8
+ import { Local } from '../local/index.js'
4
9
 
5
10
  /**
6
11
  * @typedef {import('./utils.js').Ref} Ref
@@ -186,3 +191,96 @@ export const open = (ref, arg) => {
186
191
  if (arg && typeof arg.id === 'string') return ref.handle._load(ref.name, arg.id)
187
192
  return ref.handle._create(ref.name, arg)
188
193
  }
194
+
195
+ /**
196
+ * Quickly check whether the on-disk directory at `dir` already holds an
197
+ * initialised cero identity (i.e. a stored master seed). Opens the local store
198
+ * read-only and closes everything before returning.
199
+ *
200
+ * @param {string} dir Cero data directory.
201
+ * @param {any} spec Built spec — same value passed to `cero(dir, spec)`.
202
+ * @returns {Promise<boolean>} `true` if a master seed exists on disk.
203
+ */
204
+ export async function peek(dir, spec) {
205
+ if (typeof dir !== 'string' || !dir) throw CeroError.INVALID('dir must be a non-empty string')
206
+ if (!spec) throw CeroError.REQUIRED('spec')
207
+
208
+ const root = new HypercoreStorage(`${dir}/main`)
209
+ await root.ready()
210
+ const store = new Corestore(root, { manifestVersion: 2 })
211
+ await store.ready()
212
+
213
+ const local = new Local(null, spec, { store })
214
+ await local.ready()
215
+ try {
216
+ const { data } = await local.store.get('master')
217
+ return !!data?.seed
218
+ } finally {
219
+ await local.close()
220
+ await store.close()
221
+ await root.close()
222
+ }
223
+ }
224
+
225
+ // ─── custom operators ──────────────────────────────────────────────────────
226
+ // App business logic lives as custom operators: pure functions whose first arg
227
+ // is the handle they act on, composed from the operators above. `define`
228
+ // registers them by scope; `bind` puts them on a handle — either an explicit
229
+ // `{ ns: module }` map, or (given a scope) the registered operators for that
230
+ // scope, which is how cero auto-binds the root and each child as it opens.
231
+
232
+ const registry = {}
233
+
234
+ // Curry `handle` as arg 0 of every function in `fns`, under `handle[ns]`.
235
+ function attach(handle, ns, fns) {
236
+ const bound = {}
237
+ for (const key of Object.keys(fns)) {
238
+ if (typeof fns[key] === 'function') bound[key] = (...args) => fns[key](handle, ...args)
239
+ }
240
+ handle[ns] = bound
241
+ }
242
+
243
+ /**
244
+ * Put custom operators on `handle`, currying it as their first argument so
245
+ * `handle.ns.fn(args)` calls `fn(handle, args)`. `arg` is either:
246
+ * - a `{ ns: module }` map → bind exactly those, or
247
+ * - `null` → the registered root operators, or
248
+ * - a child-handle type → the registered operators for that type.
249
+ * The scope forms are how cero binds handles automatically; pass a map yourself
250
+ * for manual binding.
251
+ *
252
+ * @param {any} handle
253
+ * @param {Record<string, any> | string | null} arg
254
+ * @returns {any} handle
255
+ */
256
+ export function bind(handle, arg) {
257
+ if (arg !== null && typeof arg !== 'string') {
258
+ for (const ns of Object.keys(arg)) attach(handle, ns, arg[ns])
259
+ return handle
260
+ }
261
+ const handles = handle.spec?.meta?.handles || {}
262
+ for (const ns of Object.keys(registry)) {
263
+ if (arg === null) {
264
+ if (!(ns in handles)) attach(handle, ns, registry[ns]) // bare root namespace
265
+ } else if (ns === arg) {
266
+ for (const k of Object.keys(registry[ns])) attach(handle, k, registry[ns][k]) // child group
267
+ }
268
+ }
269
+ return handle
270
+ }
271
+
272
+ /**
273
+ * Register custom operators by scope. A bare key binds on the root handle; a key
274
+ * that names a child-handle type binds on every handle of that type. Call once
275
+ * at startup, before `cero()` / `connect()`, in both processes.
276
+ *
277
+ * @param {Record<string, any>} map
278
+ */
279
+ export function define(map) {
280
+ Object.assign(registry, map)
281
+ }
282
+
283
+ /** Test seam: clear all registered operators. */
284
+ export function _clearDefined() {
285
+ for (const k of Object.keys(registry)) delete registry[k]
286
+ }
package/src/rpc/client.js CHANGED
@@ -2,6 +2,7 @@ import { Readable } from 'streamx'
2
2
  import { RPCClient, bindCodec } from '@cero-base/core/rpc'
3
3
 
4
4
  import { attachRefs } from '../lib/utils.js'
5
+ import { bind } from '../lib/operators.js'
5
6
 
6
7
  export { put, set, get, del, count, watch, call, open } from '../lib/operators.js'
7
8
 
@@ -298,6 +299,7 @@ export class Client extends RPCClient {
298
299
  this.deviceId = deviceId || null
299
300
  this.identity = { id, toPhrase: () => phrase || null }
300
301
  attachRefs(this, /** @type {Spec} */ (this.spec).meta.refs)
302
+ bind(this, null)
301
303
  if (/** @type {Spec} */ (this.spec).meta.local?.refs) this.local = new LocalRefs(this)
302
304
  }
303
305
 
@@ -364,6 +366,7 @@ class Handle {
364
366
  if (!this.spec.codec) bindCodec(this.spec)
365
367
  this.store = this
366
368
  attachRefs(this, this.spec.meta.refs)
369
+ bind(this, this.type)
367
370
  }
368
371
 
369
372
  /** Underlying RPC channel borrowed from the parent. */
package/types/index.d.ts CHANGED
@@ -39,7 +39,6 @@ export namespace cero {
39
39
  * @returns {Promise<Handle>} Freshly restored root handle.
40
40
  */
41
41
  export function restore(me: Handle, phrase: string): Promise<Handle>;
42
- export { peek } from "./lib/peek.js";
43
42
  export type CeroOpts = {
44
43
  /**
45
44
  * Pre-resolved identity. If absent, derived from `seed`/`phrase` or generated.
@@ -103,5 +102,5 @@ import { Ref } from './handle/index.js';
103
102
  import { Local } from './local/index.js';
104
103
  import { Identity } from '@cero-base/core/identity';
105
104
  export { Handle, Ref, Local };
106
- export { put, set, get, del, count, watch, call, open, before, after } from "./lib/operators.js";
105
+ export { put, set, get, del, count, watch, call, open, before, after, bind, define, peek } from "./lib/operators.js";
107
106
  export { t, schema } from "./lib/spec.js";
@@ -1,3 +1,37 @@
1
+ /**
2
+ * Quickly check whether the on-disk directory at `dir` already holds an
3
+ * initialised cero identity (i.e. a stored master seed). Opens the local store
4
+ * read-only and closes everything before returning.
5
+ *
6
+ * @param {string} dir Cero data directory.
7
+ * @param {any} spec Built spec — same value passed to `cero(dir, spec)`.
8
+ * @returns {Promise<boolean>} `true` if a master seed exists on disk.
9
+ */
10
+ export function peek(dir: string, spec: any): Promise<boolean>;
11
+ /**
12
+ * Put custom operators on `handle`, currying it as their first argument so
13
+ * `handle.ns.fn(args)` calls `fn(handle, args)`. `arg` is either:
14
+ * - a `{ ns: module }` map → bind exactly those, or
15
+ * - `null` → the registered root operators, or
16
+ * - a child-handle type → the registered operators for that type.
17
+ * The scope forms are how cero binds handles automatically; pass a map yourself
18
+ * for manual binding.
19
+ *
20
+ * @param {any} handle
21
+ * @param {Record<string, any> | string | null} arg
22
+ * @returns {any} handle
23
+ */
24
+ export function bind(handle: any, arg: Record<string, any> | string | null): any;
25
+ /**
26
+ * Register custom operators by scope. A bare key binds on the root handle; a key
27
+ * that names a child-handle type binds on every handle of that type. Call once
28
+ * at startup, before `cero()` / `connect()`, in both processes.
29
+ *
30
+ * @param {Record<string, any>} map
31
+ */
32
+ export function define(map: Record<string, any>): void;
33
+ /** Test seam: clear all registered operators. */
34
+ export function _clearDefined(): void;
1
35
  export function put(ref: Ref, row: Record<string, any>): Promise<SingleResult>;
2
36
  export function set(ref: Ref, row: Record<string, any>, opts?: {
3
37
  upsert?: boolean;
package/src/lib/peek.js DELETED
@@ -1,36 +0,0 @@
1
- import HypercoreStorage from 'hypercore-storage'
2
- import Corestore from 'corestore'
3
-
4
- import { CeroError } from '@cero-base/core/errors'
5
-
6
- import { Local } from '../local/index.js'
7
-
8
- /**
9
- * Quickly check whether the on-disk directory at `dir` already holds an
10
- * initialised cero identity (i.e. a stored master seed). Opens the local
11
- * store read-only and closes everything before returning.
12
- *
13
- * @param {string} dir Cero data directory.
14
- * @param {any} spec Built spec — same value passed to `cero(dir, spec)`.
15
- * @returns {Promise<boolean>} `true` if a master seed exists on disk.
16
- */
17
- export async function peek(dir, spec) {
18
- if (typeof dir !== 'string' || !dir) throw CeroError.INVALID('dir must be a non-empty string')
19
- if (!spec) throw CeroError.REQUIRED('spec')
20
-
21
- const root = new HypercoreStorage(`${dir}/main`)
22
- await root.ready()
23
- const store = new Corestore(root, { manifestVersion: 2 })
24
- await store.ready()
25
-
26
- const local = new Local(null, spec, { store })
27
- await local.ready()
28
- try {
29
- const { data } = await local.store.get('master')
30
- return !!data?.seed
31
- } finally {
32
- await local.close()
33
- await store.close()
34
- await root.close()
35
- }
36
- }
@@ -1,10 +0,0 @@
1
- /**
2
- * Quickly check whether the on-disk directory at `dir` already holds an
3
- * initialised cero identity (i.e. a stored master seed). Opens the local
4
- * store read-only and closes everything before returning.
5
- *
6
- * @param {string} dir Cero data directory.
7
- * @param {any} spec Built spec — same value passed to `cero(dir, spec)`.
8
- * @returns {Promise<boolean>} `true` if a master seed exists on disk.
9
- */
10
- export function peek(dir: string, spec: any): Promise<boolean>;