@cero-base/cero 1.19.0 → 2.1.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 (41) hide show
  1. package/README.md +38 -643
  2. package/package.json +9 -6
  3. package/src/build/index.js +63 -61
  4. package/src/build/internal.js +123 -0
  5. package/src/build/schemas.js +6 -11
  6. package/src/extensions/handle-sync.js +3 -11
  7. package/src/extensions/index.js +86 -0
  8. package/src/extensions/profile-sync.js +15 -16
  9. package/src/handle/index.js +290 -284
  10. package/src/index.js +24 -81
  11. package/src/lib/bluetooth.js +25 -56
  12. package/src/lib/constants.js +0 -15
  13. package/src/lib/operators.js +35 -134
  14. package/src/lib/peek.js +4 -8
  15. package/src/lib/refs.js +10 -8
  16. package/src/lib/spec.js +2 -3
  17. package/src/local/index.js +2 -3
  18. package/src/rpc/client.js +54 -77
  19. package/src/rpc/index.js +3 -3
  20. package/src/rpc/server.js +40 -43
  21. package/types/build/index.d.ts +11 -9
  22. package/types/build/{builtins.d.ts → internal.d.ts} +20 -38
  23. package/types/build/schemas.d.ts +4 -3
  24. package/types/extensions/handle-sync.d.ts +2 -8
  25. package/types/extensions/index.d.ts +102 -0
  26. package/types/extensions/profile-sync.d.ts +0 -5
  27. package/types/handle/index.d.ts +90 -108
  28. package/types/index.d.ts +21 -17
  29. package/types/lib/bluetooth.d.ts +8 -32
  30. package/types/lib/constants.d.ts +0 -11
  31. package/types/lib/operators.d.ts +30 -83
  32. package/types/lib/peek.d.ts +2 -3
  33. package/types/lib/refs.d.ts +5 -5
  34. package/types/lib/spec.d.ts +2 -3
  35. package/types/local/index.d.ts +2 -3
  36. package/types/rpc/client.d.ts +29 -26
  37. package/types/rpc/index.d.ts +3 -3
  38. package/types/rpc/server.d.ts +7 -10
  39. package/src/build/builtins.js +0 -174
  40. package/src/lib/internal.js +0 -9
  41. package/types/lib/internal.d.ts +0 -24
@@ -7,6 +7,7 @@ import { onAbort } from '@cero-base/core/utils'
7
7
 
8
8
  /**
9
9
  * @typedef {import('./refs.js').Ref} Ref
10
+ * @typedef {import('@cero-base/core/database').HookContext} HookContext
10
11
  * @typedef {import('../handle/index.js').CeroHandle} CeroHandle
11
12
  * @typedef {{ data: any }} SingleResult
12
13
  * @typedef {{ data: any[], total: number, size: number }} ListResult
@@ -30,9 +31,7 @@ export function resolveFile(handle, id, name) {
30
31
  }
31
32
 
32
33
  /**
33
- * Insert (or overwrite by id) a row on `ref`. The `files` builtin is special:
34
- * `put(handle.files, { data, type, name? })` uploads the bytes to this handle's
35
- * blob store, records `{ id, name }`, and resolves the file.
34
+ * Insert (or overwrite by id) a row on `ref`.
36
35
  *
37
36
  * @param {Ref} ref
38
37
  * @param {Record<string, any>} row
@@ -55,9 +54,7 @@ async function putFile(ref, row) {
55
54
  }
56
55
 
57
56
  /**
58
- * Upsert a row on `ref` — merges with the existing row and preserves
59
- * `createdAt`. Pass `{ upsert: false }` to update-only: a missing row is left
60
- * untouched instead of created (atomic — never resurrects a deleted row).
57
+ * Upsert a row on `ref` — merges with the existing row and preserves `createdAt`.
61
58
  *
62
59
  * @param {Ref} ref
63
60
  * @param {Record<string, any>} row
@@ -79,17 +76,6 @@ export function del(ref, id) {
79
76
  return ref.handle.store.del(ref.name, id)
80
77
  }
81
78
 
82
- /**
83
- * Count rows on `ref`, optionally filtered.
84
- *
85
- * @param {Ref} ref
86
- * @param {Record<string, any>} [q]
87
- * @returns {Promise<{ data: number }>}
88
- */
89
- export function count(ref, q) {
90
- return ref.handle.store.count(ref.name, q)
91
- }
92
-
93
79
  /**
94
80
  * Invoke an `action`-kind ref (a custom mutation declared in the schema).
95
81
  *
@@ -101,20 +87,25 @@ export function call(ref, d) {
101
87
  return ref.handle.store.call(ref.name, d)
102
88
  }
103
89
 
104
- // Write ops per ref kind, for `before`/`after` subscriptions.
105
90
  const WRITES = { single: ['set'], collection: ['put', 'set', 'del'] }
106
91
 
107
92
  /**
108
- * Intercept writes to `ref` before they commit `fn(ctx)` runs in-path
109
- * (awaited). Return `false` to cancel the write, or mutate `ctx.row`.
110
- * Returns an unsubscribe fn; pass `{ signal }` to unsubscribe on abort.
93
+ * Rule that runs before a write to `ref` lands at apply, on every peer, inside the op's
94
+ * transaction. Return `false` to refuse it: the writer's own call rejects with `REFUSED`.
95
+ * `ctx` is `{ op, name, row, existing, id, memberId, role, get, put, set, del }`; mutate
96
+ * `ctx.row` to rewrite what is stored. `op` is the op as it applies, so an upsert on a
97
+ * collection is a `put`. The four operators on `ctx` read and write the room as it stands at
98
+ * this op, inside the transaction. Must be deterministic — read only `ctx`, never a clock or
99
+ * local state — and registered before any op applies, in the process that owns the data. The
100
+ * imported operators throw inside a hook; use the ones on `ctx`. Not available over RPC.
111
101
  *
112
102
  * @param {Ref} ref
113
- * @param {(ctx: { op: string, name: string, row: any }) => any} fn
103
+ * @param {(ctx: HookContext) => unknown} fn
114
104
  * @param {{ signal?: AbortSignal }} [opts]
115
105
  * @returns {() => void}
116
106
  */
117
107
  export function before(ref, fn, opts) {
108
+ if (ref.type) return ref.handle._hookType(before, ref, fn, opts)
118
109
  const db = ref.handle.store
119
110
  const ops = WRITES[ref.kind] || ['set']
120
111
  const offs = ops.map((op) =>
@@ -130,33 +121,31 @@ export function before(ref, fn, opts) {
130
121
  }
131
122
 
132
123
  /**
133
- * Subscribe to writes on `ref` — fires after each committed write,
134
- * non-blocking (observe only). Returns an unsubscribe fn; pass `{ signal }`
135
- * to unsubscribe on abort.
124
+ * Rule that runs after a write to `ref` lands at apply, on every peer, inside the op's
125
+ * transaction. Write derived rows through `ctx.put` / `ctx.set` / `ctx.del`; a throw refuses
126
+ * the whole op. Same `ctx` and the same determinism and registration rules as `before`. Use
127
+ * `changes(ref)` instead to observe writes locally.
136
128
  *
137
129
  * @param {Ref} ref
138
- * @param {(ctx: { op: string, name: string, row: any }) => void} fn
130
+ * @param {(ctx: HookContext) => unknown} fn
139
131
  * @param {{ signal?: AbortSignal }} [opts]
140
132
  * @returns {() => void}
141
133
  */
142
134
  export function after(ref, fn, opts) {
135
+ if (ref.type) return ref.handle._hookType(after, ref, fn, opts)
143
136
  const db = ref.handle.store
144
137
  const ops = WRITES[ref.kind] || ['set']
145
- const handler = (ctx) => ctx.name === ref.name && fn(ctx)
146
- for (const op of ops) db.on(`after:${op}`, handler)
138
+ const offs = ops.map((op) => db.after(op, (ctx) => (ctx.name === ref.name ? fn(ctx) : undefined)))
147
139
  let stopAbort
148
140
  const off = () => {
149
- ops.forEach((op) => db.off(`after:${op}`, handler))
141
+ offs.forEach((unsub) => unsub())
150
142
  stopAbort?.()
151
143
  }
152
144
  stopAbort = onAbort(opts?.signal, off)
153
145
  return off
154
146
  }
155
147
 
156
- // get/watch on a handle-kind ref list its rows from the `handles` collection
157
- // filtered by type (handle types are stored there with their { id, key,
158
- // encryptionKey, name }). Data-kind refs go straight to the store.
159
- // For handle-kind refs on the facade, the parent store lives on `root`.
148
+ // handle refs read their rows from the parent's `handles` collection, filtered by type
160
149
  const parentStore = (ref) => (ref.handle.root ? ref.handle.root.store : ref.handle.store)
161
150
 
162
151
  const normalize = (rows, name) => {
@@ -195,9 +184,7 @@ function resolveRow(ref, row) {
195
184
  }
196
185
 
197
186
  /**
198
- * Read from `ref`. For data refs, dispatches to the underlying store. For
199
- * `handle`-kind refs, lists existing child handles of that type from the
200
- * parent's `handles` collection.
187
+ * Read from `ref`. For data refs, dispatches to the underlying store.
201
188
  *
202
189
  * @param {Ref} ref
203
190
  * @param {string | Record<string, any>} [q]
@@ -212,22 +199,17 @@ export async function get(ref, q) {
212
199
  return resolveResult(ref, res)
213
200
  }
214
201
 
215
- // Tie a fresh watch stream to its handle's lifecycle (destroyed on close) and
216
- // to an optional `{ signal }` (destroyed on abort). Local refs have no
217
- // close-cascade, so they keep managing their own streams.
202
+ // a watch stream dies with its handle, or with the signal
218
203
  const bindStream = (owner, stream, opts) => {
219
204
  const stopAbort = onAbort(opts?.signal, () => stream.destroy())
220
- // drop the abort listener once the stream ends, so it doesn't linger on a
221
- // long-lived signal after the stream is gone
205
+ // drop the abort listener once the stream ends
222
206
  if (stopAbort) stream.once('close', stopAbort)
223
207
  return owner.own ? owner.own(stream) : stream
224
208
  }
225
209
 
226
210
  /**
227
- * Live snapshot stream on `ref` — re-emits the latest `get()` result on
228
- * every underlying mutation. Tied to `ref.handle`'s lifecycle: closing the
229
- * handle destroys it. Pass `{ signal }` to bind it to a finer scope, or
230
- * destroy the stream directly to stop watching sooner.
211
+ * Live snapshot stream on `ref` — re-emits the latest `get()` result on every underlying
212
+ * mutation.
231
213
  *
232
214
  * @param {Ref} ref
233
215
  * @param {Record<string, any>} [q]
@@ -247,10 +229,9 @@ export function watch(ref, q, opts) {
247
229
  }
248
230
 
249
231
  /**
250
- * Delta subscription: batches of `{ prev, next }` row pairs instead of
251
- * full snapshots — lossless under backpressure, self-contained (the first
252
- * batch, and any batch after a view swap, replays current state as inserts
253
- * with `reset: true`). File-typed fields resolve on both sides.
232
+ * Delta subscription: batches of `{ prev, next }` row pairs instead of full snapshots —
233
+ * lossless under backpressure, self-contained (the first batch, and any batch after a view
234
+ * swap, replays current.
254
235
  */
255
236
  export function changes(ref, q, opts) {
256
237
  const owner = ref.handle
@@ -297,9 +278,7 @@ export function changes(ref, q, opts) {
297
278
  return bindStream(owner, out, opts)
298
279
  }
299
280
 
300
- // Snapshots are idempotent — under a slow consumer hold only the NEWEST one
301
- // instead of queueing every intermediate (a busy room + un-drained reader
302
- // used to buffer full result sets without bound).
281
+ // snapshots are idempotent: a slow consumer gets only the newest
303
282
  function snapshotStream(src, map) {
304
283
  let pending
305
284
  let wanted = false
@@ -329,19 +308,8 @@ function snapshotStream(src, map) {
329
308
  return out
330
309
  }
331
310
 
332
- // Universal signal instantiator. Dispatch on the second arg:
333
- // string → join via invite
334
- // { invite: string } → join via invite (object form)
335
- // { id: string } → load an existing handle by id
336
- // object | undefined → create (opts)
337
311
  /**
338
- * Open (or create / join / load) a child handle through a `handle`-kind
339
- * ref. Dispatches on the normalize of `arg`:
340
- *
341
- * - `string` → join via an invite string
342
- * - `{ invite: string }` → join via invite (object form)
343
- * - `{ id: string }` → load an existing handle by id
344
- * - `object | undefined` → create a new handle with the given opts
312
+ * Open (or create / join / load) a child handle through a `handle`-kind ref.
345
313
  *
346
314
  * @param {Ref} ref
347
315
  * @param {string | { invite?: string, id?: string, name?: string, routes?: any, role?: string, accept?: boolean } | undefined} [arg]
@@ -355,13 +323,9 @@ export function open(ref, arg) {
355
323
  }
356
324
 
357
325
  /**
358
- * Rotate a handle's encryption epoch. A fresh secret is sealed to every
359
- * current member and announced through the log — members removed before the
360
- * rotation cannot decrypt anything written after it. Requires the remove
361
- * permission (admin or owner). Compose with removal:
362
- *
363
- * await cero.del(room.members, memberId)
364
- * await cero.rotate(room)
326
+ * Rotate a handle's encryption epoch. A fresh secret is sealed to every current member and
327
+ * announced through the log — members removed before the rotation cannot decrypt anything
328
+ * written after it.
365
329
  *
366
330
  * @param {any} handle
367
331
  * @returns {Promise<{ epoch: number }>}
@@ -369,66 +333,3 @@ export function open(ref, arg) {
369
333
  export function rotate(handle) {
370
334
  return handle.store.rotate()
371
335
  }
372
-
373
- // ─── custom operators ──────────────────────────────────────────────────────
374
- // App business logic lives as custom operators: pure functions whose first arg
375
- // is the handle they act on, composed from the operators above. `define`
376
- // registers them by scope; `bind` puts them on a handle — either an explicit
377
- // `{ ns: module }` map, or (given a scope) the registered operators for that
378
- // scope, which is how cero auto-binds the root and each child as it opens.
379
-
380
- const registry = {}
381
-
382
- // Curry `handle` as arg 0 of every function in `fns`, under `handle[ns]`.
383
- function attach(handle, ns, fns) {
384
- const bound = {}
385
- for (const key of Object.keys(fns)) {
386
- if (typeof fns[key] === 'function') bound[key] = (...args) => fns[key](handle, ...args)
387
- }
388
- handle[ns] = bound
389
- }
390
-
391
- /**
392
- * Put custom operators on `handle`, currying it as their first argument so
393
- * `handle.ns.fn(args)` calls `fn(handle, args)`. `arg` is either:
394
- * - a `{ ns: module }` map → bind exactly those, or
395
- * - `null` → the registered root operators, or
396
- * - a child-handle type → the registered operators for that type.
397
- * The scope forms are how cero binds handles automatically; pass a map yourself
398
- * for manual binding.
399
- *
400
- * @param {any} handle
401
- * @param {Record<string, any> | string | null} arg
402
- * @returns {any} handle
403
- */
404
- export function bind(handle, arg) {
405
- if (arg !== null && typeof arg !== 'string') {
406
- for (const ns of Object.keys(arg)) attach(handle, ns, arg[ns])
407
- return handle
408
- }
409
- const handles = handle.spec?.meta?.handles || {}
410
- for (const ns of Object.keys(registry)) {
411
- if (arg === null) {
412
- if (!(ns in handles)) attach(handle, ns, registry[ns]) // bare root namespace
413
- } else if (ns === arg) {
414
- for (const k of Object.keys(registry[ns])) attach(handle, k, registry[ns][k]) // child group
415
- }
416
- }
417
- return handle
418
- }
419
-
420
- /**
421
- * Register custom operators by scope. A bare key binds on the root handle; a key
422
- * that names a child-handle type binds on every handle of that type. Call once
423
- * at startup, before `cero()` / `connect()`, in both processes.
424
- *
425
- * @param {Record<string, any>} map
426
- */
427
- export function define(map) {
428
- Object.assign(registry, map)
429
- }
430
-
431
- /** Test seam: clear all registered operators. */
432
- export function _clearDefined() {
433
- for (const k of Object.keys(registry)) delete registry[k]
434
- }
package/src/lib/peek.js CHANGED
@@ -6,14 +6,11 @@ import { CeroError } from '@cero-base/core/errors'
6
6
 
7
7
  import { Local } from '../local/index.js'
8
8
 
9
- // Lives outside operators.js so the storage deps stay off the RPC client's
10
- // module graph — bundlers follow even dynamic imports, and a browser build
11
- // must never reach hypercore/sodium.
9
+ // outside operators.js so storage deps stay off the client's module graph
12
10
 
13
11
  /**
14
- * Quickly check whether the on-disk directory at `dir` already holds an
15
- * initialised cero identity (i.e. a stored master seed). Opens the local store
16
- * read-only and closes everything before returning.
12
+ * Quickly check whether the on-disk directory at `dir` already holds an initialised cero
13
+ * identity (i.e. a stored master seed).
17
14
  *
18
15
  * @param {string} dir Cero data directory.
19
16
  * @param {any} spec Built spec — same value passed to `cero(dir, spec)`.
@@ -23,8 +20,7 @@ export async function peek(dir, spec) {
23
20
  if (typeof dir !== 'string' || !dir) throw CeroError.INVALID('dir must be a non-empty string')
24
21
  if (!spec) throw CeroError.REQUIRED('spec')
25
22
 
26
- // Construct first (cheap), ready inside the try a corrupt dir that throws in
27
- // any ready() must still close root + store + local, not leak the storage lock.
23
+ // construct first, ready inside the try: a corrupt dir must still close everything
28
24
  const root = new HypercoreStorage(`${dir}/main`)
29
25
  const store = new Corestore(root, { manifestVersion: 2 })
30
26
  const local = new Local(null, spec, { store })
package/src/lib/refs.js CHANGED
@@ -9,9 +9,7 @@ import { CeroError } from '@cero-base/core/errors'
9
9
  */
10
10
 
11
11
  /**
12
- * Typed pointer to a single ref (table or handle slot) on a `Handle` or
13
- * `Local`. Operators (`put`/`get`/`open`/...) take a `Ref` as their first
14
- * argument and dispatch through the owning handle's store.
12
+ * Typed pointer to a single ref (table or handle slot) on a `Handle` or `Local`.
15
13
  */
16
14
  export class Ref {
17
15
  /**
@@ -20,11 +18,12 @@ export class Ref {
20
18
  * @param {string} kind Ref kind: `'collection'`, `'single'`, `'action'`, or `'handle'`.
21
19
  * @param {string | null} [schema] Fully-qualified schema id, if any.
22
20
  */
23
- constructor(handle, name, kind, schema = null) {
21
+ constructor(handle, name, kind, schema = null, type = null) {
24
22
  this.handle = handle
25
23
  this.name = name
26
24
  this.kind = kind
27
25
  this.schema = schema
26
+ this.type = type
28
27
  }
29
28
 
30
29
  /**
@@ -33,17 +32,20 @@ export class Ref {
33
32
  *
34
33
  * @param {any} target
35
34
  * @param {Record<string, RefInfo>} refs
35
+ * @param {Record<string, any>} [handles] The handle types, so `target.room.notes` names every room's notes.
36
36
  */
37
- static attach(target, refs) {
37
+ static attach(target, refs, handles) {
38
38
  for (const [name, info] of Object.entries(refs || {})) {
39
- // fail loud rather than silently overwrite a method or property (close,
40
- // on, store) when a schema declares a ref named like a reserved member
39
+ // a ref named like a reserved member must fail loud, not overwrite it
41
40
  if (name in target) {
42
41
  throw CeroError.INVALID(
43
42
  `schema ref '${name}' collides with a reserved ${target.constructor?.name || 'handle'} member — rename it`
44
43
  )
45
44
  }
46
- target[name] = new Ref(target, name, info.kind, info.schema)
45
+ const ref = (target[name] = new Ref(target, name, info.kind, info.schema))
46
+ for (const [sub, i] of Object.entries(handles?.[name]?.meta?.refs || {})) {
47
+ ref[sub] = new Ref(target, sub, i.kind, i.schema, name)
48
+ }
47
49
  }
48
50
  }
49
51
  }
package/src/lib/spec.js CHANGED
@@ -1,6 +1,5 @@
1
1
  /**
2
- * Re-exports of the schema DSL (`t`) and `schema()` wrapper from
3
- * `@cero-base/core/schema`, so cero apps can describe their tables without
4
- * pulling in the core package directly.
2
+ * Re-exports of the schema DSL (`t`) and `schema()` wrapper from `@cero-base/core/schema`,
3
+ * so cero apps can describe their tables without pulling in the core package directly.
5
4
  */
6
5
  export { t, schema } from '@cero-base/core/schema'
@@ -13,9 +13,8 @@ import { Ref } from '../lib/refs.js'
13
13
  */
14
14
 
15
15
  /**
16
- * Per-device, single-writer storage for cero — holds the master seed,
17
- * device keypair and any per-handle keypairs. Wraps a hyperbee-backed
18
- * `Storage` and exposes each local ref as a property of the instance.
16
+ * Per-device, single-writer storage for cero — holds the master seed, device keypair and
17
+ * any per-handle keypairs.
19
18
  */
20
19
  export class Local extends ReadyResource {
21
20
  /**
package/src/rpc/client.js CHANGED
@@ -5,23 +5,11 @@ import z32 from 'z32'
5
5
  import { decodeId } from '@cero-base/core/blobs/codec'
6
6
 
7
7
  import { Ref } from '../lib/refs.js'
8
- import {
9
- put,
10
- set,
11
- get,
12
- del,
13
- count,
14
- watch,
15
- changes,
16
- call,
17
- open,
18
- rotate,
19
- bind,
20
- define
21
- } from '../lib/operators.js'
8
+ import { put, set, get, del, watch, changes, call, open, rotate } from '../lib/operators.js'
22
9
  import { t, schema } from '../lib/spec.js'
10
+ import { operatorsOf, bind } from '../extensions/index.js'
23
11
 
24
- export { put, set, get, del, count, watch, changes, call, open, rotate, bind, define, t, schema }
12
+ export { put, set, get, del, watch, changes, call, open, rotate, t, schema }
25
13
 
26
14
  /**
27
15
  * @typedef {import('@cero-base/core/rpc').RPCClient} BaseRPCClient
@@ -30,7 +18,7 @@ export { put, set, get, del, count, watch, changes, call, open, rotate, bind, de
30
18
  * @property {'single'|'collection'|'action'|'handle'} [kind]
31
19
  * @property {string} [schema]
32
20
  * @property {string} [type]
33
- * @property {boolean} [builtin]
21
+ * @property {boolean} [internal]
34
22
  *
35
23
  * @typedef {import('@cero-base/core/rpc').Spec & { meta: { ns?: string, refs: Record<string, RefInfo>, local?: { refs: Record<string, RefInfo> }, handles?: Record<string, Spec> }, handles: Record<string, Spec> }} Spec Built cero spec (schema + rpc + per-handle child specs).
36
24
  *
@@ -48,7 +36,6 @@ export { put, set, get, del, count, watch, changes, call, open, rotate, bind, de
48
36
  * @property {string|null} name
49
37
  */
50
38
 
51
- // Compact-encoding blobId struct matching hypercore-blob-server's wire format.
52
39
  const blobIdEnc = {
53
40
  preencode(state, b) {
54
41
  c.uint.preencode(state, b.blockOffset)
@@ -72,9 +59,7 @@ const blobIdEnc = {
72
59
  }
73
60
  }
74
61
 
75
- // Mixin applied to Client, Handle and LocalRefs so they expose the same
76
- // row-ops surface as a local cero handle but routed over the wire. `_local`
77
- // selects the per-device store + JSON codec; main refs use the schema codec.
62
+ // the same row-ops surface as a local handle, routed over the wire
78
63
  const operators = {
79
64
  _local: false,
80
65
 
@@ -113,9 +98,8 @@ const operators = {
113
98
  },
114
99
 
115
100
  /**
116
- * Augment a decoded row (or array of rows) to resolve file-typed fields to
117
- * `{ id, type, size, url }` objects. The `files` builtin's own `id` is the
118
- * file id; other refs declare file fields in `meta.refs[name].files`.
101
+ * Augment a decoded row (or array of rows) to resolve file-typed fields to `{ id, type,
102
+ * size, url }` objects.
119
103
  *
120
104
  * @param {string} name
121
105
  * @param {any} data
@@ -130,7 +114,7 @@ const operators = {
130
114
 
131
115
  _resolveRow(name, info, row) {
132
116
  if (!row || typeof row !== 'object') return row
133
- if (info?.builtin && info?.verb === 'file') {
117
+ if (info?.internal && info?.verb === 'file') {
134
118
  if (!row.id) return row
135
119
  try {
136
120
  const { type, blobId } = decodeId(row.id)
@@ -256,24 +240,6 @@ const operators = {
256
240
  await this.rpc.del({ handle: this.id, ref: name, id, local: this._local })
257
241
  },
258
242
 
259
- /**
260
- * Count matching rows.
261
- *
262
- * @param {string} name
263
- * @param {Record<string, any>} [query]
264
- * @returns {Promise<{ data: number }>}
265
- */
266
- async count(name, query) {
267
- const codec = this._codec()
268
- const res = await this.rpc.count({
269
- handle: this.id,
270
- ref: name,
271
- query: codec.encodeQuery(query),
272
- local: this._local
273
- })
274
- return { data: res.count }
275
- },
276
-
277
243
  /**
278
244
  * Live snapshot stream. Re-emits the latest `get()` shape on every
279
245
  * underlying mutation. Destroy the stream to stop watching.
@@ -318,15 +284,14 @@ const operators = {
318
284
  wire.on('end', end)
319
285
  wire.on('close', end)
320
286
  // the channel tears the stream down on client close — that is an end,
321
- // not a failure (bare-rpc ≥1.3.2 errors every in-flight op on teardown)
287
+ // not a failure
322
288
  wire.on('error', (err) => (err.code === 'CHANNEL_CLOSED' ? end() : out.destroy(err)))
323
289
  return out
324
290
  },
325
291
 
326
292
  /**
327
- * Delta subscription over the wire — same contract as the local operator:
328
- * batches of `{ prev, next }` with file fields resolved, `reset` marks
329
- * a full replay. Lossless: server-side the cursor folds under backpressure.
293
+ * Delta subscription over the wire — same contract as the local operator: batches of `{
294
+ * prev, next }` with file fields resolved, `reset` marks a full replay.
330
295
  */
331
296
  changes(name, query) {
332
297
  const refInfo = this._refInfo(name)
@@ -358,8 +323,7 @@ const operators = {
358
323
  }
359
324
  pump().catch((err) => {
360
325
  if (out.destroyed) return
361
- // the server tears the stream down on handle close, and the channel on
362
- // client close — both are ends, not failures (same contract as watch)
326
+ // both are ends, not failures
363
327
  if (err.code === 'PREMATURE_CLOSE' || err.code === 'CHANNEL_CLOSED') out.push(null)
364
328
  else out.destroy(err)
365
329
  })
@@ -415,6 +379,16 @@ const operators = {
415
379
  async rotate() {
416
380
  const { epoch } = await this.rpc.rotate({ handle: this.id })
417
381
  return { epoch }
382
+ },
383
+
384
+ /**
385
+ * `true` ranks this handle as just updated on the server, `false` takes it off the swarm.
386
+ *
387
+ * @param {boolean} active
388
+ * @returns {Promise<void>}
389
+ */
390
+ async setActive(active) {
391
+ await this.rpc.setActive({ handle: this.id, active })
418
392
  }
419
393
  }
420
394
 
@@ -435,10 +409,7 @@ export async function restore(me, phrase) {
435
409
  }
436
410
 
437
411
  /**
438
- * Per-device `local`-namespace surface on a Client. Exposes each app-defined
439
- * local ref (e.g. `client.local.settings`) and routes ops over RPC with
440
- * `local: true`, so they hit the server's per-device store and never
441
- * replicate. Built-in local refs (identity master/keypair) are not exposed.
412
+ * Per-device `local`-namespace surface on a Client.
442
413
  */
443
414
  class LocalRefs {
444
415
  /** @param {Client} client */
@@ -449,7 +420,7 @@ class LocalRefs {
449
420
  this.store = this
450
421
  this._local = true
451
422
  const refs = client.spec.meta.local?.refs || {}
452
- const exposed = Object.fromEntries(Object.entries(refs).filter(([, info]) => !info.builtin))
423
+ const exposed = Object.fromEntries(Object.entries(refs).filter(([, info]) => !info.internal))
453
424
  Ref.attach(this, exposed)
454
425
  }
455
426
 
@@ -465,19 +436,21 @@ class LocalRefs {
465
436
  }
466
437
 
467
438
  /**
468
- * IPC-side RPC client for cero. Wraps an `hrpc` channel and exposes the
469
- * same handle/ref/row API as a local cero instance, transparently
470
- * routing every operation across the wire.
439
+ * IPC-side RPC client for cero. Wraps an `hrpc` channel and exposes the same
440
+ * handle/ref/row API as a local cero instance, transparently routing every operation
441
+ * across the wire.
471
442
  */
472
443
  export class Client extends RPCClient {
473
444
  /**
474
445
  * @param {any} ipc Framed IPC stream (must be writable).
475
446
  * @param {Spec} spec Compiled cero spec (schema + rpc + handles).
447
+ * @param {{ operators?: Record<string, any> }} [opts] The operators to bind, instead of the ones the spec carries.
476
448
  */
477
- constructor(ipc, spec) {
449
+ constructor(ipc, spec, opts = {}) {
478
450
  super(ipc, spec)
479
451
  if (spec.local?.schema && !spec.local.codec) bindCodec(spec.local)
480
452
  Object.assign(this, operators)
453
+ this.operators = operatorsOf(spec, opts.operators)
481
454
  this.id = null
482
455
  this.deviceId = null
483
456
  this.store = this
@@ -493,10 +466,20 @@ export class Client extends RPCClient {
493
466
  this._fileToken = fileToken || ''
494
467
  this.identity = { id, toPhrase: async () => (await this.rpc.seed({})).phrase || null }
495
468
  Ref.attach(this, /** @type {Spec} */ (this.spec).meta.refs)
496
- bind(this, null)
469
+ bind(this, null, this.operators)
497
470
  if (/** @type {Spec} */ (this.spec).meta.local?.refs) this.local = new LocalRefs(this)
498
471
  }
499
472
 
473
+ /** Pause networking and storage on the server. Idempotent. */
474
+ async suspend() {
475
+ await this.rpc.suspend({})
476
+ }
477
+
478
+ /** Resume a suspended server. Idempotent. */
479
+ async resume() {
480
+ await this.rpc.resume({})
481
+ }
482
+
500
483
  /**
501
484
  * Create a new child handle of the given type.
502
485
  *
@@ -505,7 +488,7 @@ export class Client extends RPCClient {
505
488
  * @returns {Promise<Handle>}
506
489
  */
507
490
  async _create(type, opts = {}) {
508
- // `routes` are functions and can't cross the wire; role/accept now do.
491
+ // routes are functions and cannot cross the wire
509
492
  const wire = { ...opts, noAccept: opts.accept === false || undefined }
510
493
  const stub = await this.rpc.addHandle({
511
494
  ref: type,
@@ -541,9 +524,9 @@ export class Client extends RPCClient {
541
524
  }
542
525
 
543
526
  /**
544
- * Client-side proxy for a remote handle. Exposes the same row-ops surface
545
- * as `Client` but scoped to a single child handle id, and routes every
546
- * call through the parent's RPC channel.
527
+ * Client-side proxy for a remote handle. Exposes the same row-ops surface as `Client` but
528
+ * scoped to a single child handle id, and routes every call through the parent's RPC
529
+ * channel.
547
530
  */
548
531
  class Handle {
549
532
  /**
@@ -562,7 +545,7 @@ class Handle {
562
545
  if (!this.spec.codec) bindCodec(this.spec)
563
546
  this.store = this
564
547
  Ref.attach(this, this.spec.meta.refs)
565
- bind(this, this.type)
548
+ bind(this, this.type, parent.operators)
566
549
  }
567
550
 
568
551
  /** Underlying RPC channel borrowed from the parent. */
@@ -586,29 +569,26 @@ class Handle {
586
569
  *
587
570
  * @param {any} ipc
588
571
  * @param {object} spec
572
+ * @param {{ operators?: Record<string, any> }} [opts]
589
573
  * @returns {Promise<Client>}
590
574
  */
591
- export async function connect(ipc, spec) {
592
- const client = new Client(ipc, spec)
575
+ export async function connect(ipc, spec, opts) {
576
+ const client = new Client(ipc, spec, opts)
593
577
  await client.ready()
594
578
  return client
595
579
  }
596
580
 
597
581
  /**
598
- * Symmetric client entry. Mirrors the main `cero`, but `cero(ipc, spec)` connects
599
- * to a server (via `connect`) instead of opening a local store. The same operator
600
- * surface is attached, so `import { cero } from '@cero-base/cero/client'` works
601
- * just like `import { cero } from '@cero-base/cero'`.
602
- *
603
- * Note: `before`/`after`/`peek` are intentionally not exposed here — they hook the
604
- * local write path / probe a local store, which a client proxy has no notion of.
582
+ * Symmetric client entry. Mirrors the main `cero`, but `cero(ipc, spec)` connects to a
583
+ * server (via `connect`) instead of opening a local store.
605
584
  *
606
585
  * @param {any} ipc Framed IPC duplex stream.
607
586
  * @param {any} spec Built cero spec.
587
+ * @param {{ operators?: Record<string, any> }} [opts]
608
588
  * @returns {Promise<Client>}
609
589
  */
610
- export function cero(ipc, spec) {
611
- return connect(ipc, spec)
590
+ export function cero(ipc, spec, opts) {
591
+ return connect(ipc, spec, opts)
612
592
  }
613
593
  cero.connect = connect
614
594
  cero.restore = restore
@@ -617,12 +597,9 @@ cero.put = put
617
597
  cero.set = set
618
598
  cero.get = get
619
599
  cero.del = del
620
- cero.count = count
621
600
  cero.watch = watch
622
601
  cero.changes = changes
623
602
  cero.call = call
624
603
  cero.open = open
625
604
  cero.rotate = rotate
626
- cero.bind = bind
627
- cero.define = define
628
605
  cero.schema = schema