@cero-base/cero 0.6.0 → 0.8.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/README.md CHANGED
@@ -140,16 +140,18 @@ const { data: n } = await cero.count(room.messages)
140
140
  const { data: n } = await cero.count(room.messages, { gt: 'm-2025' })
141
141
  ```
142
142
 
143
- ### `cero.watch(ref, q?)`
143
+ ### `cero.watch(ref, q?, opts?)`
144
144
 
145
- A Readable stream that re-emits the latest snapshot on every change. Always emits an initial snapshot.
145
+ A Readable stream that re-emits the latest snapshot on every change. Always emits an initial snapshot. The stream is **tied to the ref's handle** — closing the handle destroys it, so a watch never outlives its store and you don't track cleanup. Pass `{ signal }` to bind it to a finer scope instead.
146
146
 
147
147
  ```js
148
148
  const stream = cero.watch(room.messages, { limit: 50, reverse: true })
149
149
  stream.on('data', ({ data, total, size }) => {
150
150
  /* render */
151
151
  })
152
- // stream.destroy() to stop
152
+ // stops on room.close(), on stream.destroy(), or on `signal` abort:
153
+ const ac = new AbortController()
154
+ cero.watch(room.messages, null, { signal: ac.signal }) // ac.abort() ⇒ destroyed
153
155
  ```
154
156
 
155
157
  ### `cero.call(actionRef, data)`
@@ -173,6 +175,112 @@ await cero.open(me.room, { id: existingRoomId }) // load an existing room by id
173
175
 
174
176
  Returns the child handle, ready to operate on (`cero.put(joined.messages, …)` etc.).
175
177
 
178
+ ### `cero.before(ref, fn, opts?)` / `cero.after(ref, fn, opts?)`
179
+
180
+ Hook into writes to a ref. `before` runs **in-path** before the write commits — return `false` to cancel, or mutate `ctx.row`. `after` is a non-blocking **event** that fires once the write has committed. Both return an unsubscribe fn — call it to stop early, ignore it for a subscription that lives as long as the handle, or pass `{ signal }` to unsubscribe when an `AbortSignal` fires (e.g. `{ signal: me.signal }` to stop on close).
181
+
182
+ ```js
183
+ const off = cero.before(room.messages, (ctx) => {
184
+ if (!ctx.row.text?.trim()) return false // reject empty messages
185
+ })
186
+
187
+ cero.after(me.profile, ({ row }) => {
188
+ /* react to your profile changing */
189
+ })
190
+ ```
191
+
192
+ `ctx` is `{ op, name, row }` (plus `result` in `after`).
193
+
194
+ ## Extensions
195
+
196
+ 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.
197
+
198
+ ```js
199
+ import { profileSync } from '@cero-base/cero/extensions'
200
+
201
+ cero.use(profileSync()) // before build() (for the schema) and before cero() (for the behavior)
202
+ await build('./spec', schema)
203
+ ```
204
+
205
+ An extension is two optional parts:
206
+
207
+ ```js
208
+ function myExtension() {
209
+ return {
210
+ schema: { members: cero.t.extend({ status: cero.t.string }) }, // merged into your schema
211
+ setup(me) {
212
+ const onHandle = (room, opts) => {
213
+ /* opts carries the open args (e.g. opts.name on create); me.children is the set of open rooms */
214
+ }
215
+ me.on('handle', onHandle, { signal: me.signal }) // dropped on me.close()
216
+ }
217
+ }
218
+ }
219
+ ```
220
+
221
+ For a behavior-only extension (no schema), pass a function directly — it's shorthand for `{ setup }`:
222
+
223
+ ```js
224
+ cero.use((me) => {
225
+ me.on('handle', (room) => {
226
+ /* … */
227
+ })
228
+ })
229
+ ```
230
+
231
+ Build and the running app are separate processes, so `cero.use()` runs in both — your build script (for the schema) and once at app startup before `cero()` (for the behavior).
232
+
233
+ ### Cleanup
234
+
235
+ You rarely write teardown — a handle's subscriptions follow the same "dies with the node" model as DOM events:
236
+
237
+ - **`cero.watch(ref)` streams** are destroyed automatically when the ref's handle closes.
238
+ - **`me.on(...)`, `before`, `after`** take `{ signal }` to drop themselves when an `AbortSignal` fires.
239
+ - **`me.signal`** is an `AbortSignal` that aborts on `me.close()`. Pass it to tie a subscription to the handle's life, or use your own `AbortController` for a finer scope (drop it before the handle closes):
240
+
241
+ ```js
242
+ setup(me) {
243
+ me.on('handle', onHandle, { signal: me.signal }) // dropped when me closes
244
+ cero.after(me.profile, onProfile, { signal: me.signal })
245
+ }
246
+ ```
247
+
248
+ For anything the close cascade won't reach — a timer, an external connection, a stream you made yourself — hand it to **`me.own(resource)`** (destroyed on close) or return a disposer from `setup`:
249
+
250
+ ```js
251
+ setup(me) {
252
+ const timer = setInterval(() => ping(me), 30_000)
253
+ me.own({ destroy: () => clearInterval(timer) }) // tied to me.close()
254
+ // …or: return () => clearInterval(timer)
255
+ }
256
+ ```
257
+
258
+ ### `profileSync` (bundled)
259
+
260
+ Mirrors your `profile` onto your `member` row in every room, so others see your name and avatar. It **declares its own `profile` single** (`name` + the synced fields) and extends `member` with them — no app schema required. Defaults to syncing `avatar`:
261
+
262
+ ```js
263
+ cero.use(profileSync())
264
+ cero.use(profileSync({ fields: { status: cero.t.string } })) // sync extra fields
265
+ ```
266
+
267
+ Want a richer profile (e.g. a `bio` that doesn't sync)? Declare your own `profile` single — the app schema wins, and `profileSync` still adds the member side.
268
+
269
+ ### `handleSync` (bundled)
270
+
271
+ Mirrors a child handle's `profile` (name + avatar) onto its row in the parent's `handles` list — so a handle/room list renders names and photos without opening each handle. Adds `avatar` (or your `fields`) to the `handle` builtin and reflects the handle's own `profile` (which your app owns) onto the row.
272
+
273
+ ```js
274
+ cero.use(handleSync())
275
+
276
+ // your handle type declares a `profile`; your app sets it:
277
+ const room = await cero.open(me.room)
278
+ await cero.set(room.profile, { name: 'general', avatar: 'pic.png' })
279
+ // → the me.handles row now carries name + avatar — render the room list, no opens
280
+ ```
281
+
282
+ Requires your handle types to declare a `profile` single; handles without one are left untouched.
283
+
176
284
  ## RPC
177
285
 
178
286
  cero runs in one process; your UI runs in another. Connect them with any duplex stream (Bare IPC, Electron `contextBridge`, a worker port, even a TCP socket).
@@ -263,12 +371,13 @@ const remote = await connect(c, spec)
263
371
 
264
372
  ## Exports
265
373
 
266
- | Path | What you get |
267
- | ------------------------ | ------------------------------------------------------------------------ |
268
- | `@cero-base/cero` | factory + operators + schema DSL |
269
- | `@cero-base/cero/client` | `connect(ipc, spec)` — talk to a cero running in another process |
270
- | `@cero-base/cero/server` | `serve(ipc, { storage, spec })` — run + expose a cero over an IPC stream |
271
- | `@cero-base/cero/build` | `build(specDir, schema)` — generate the on-disk spec |
374
+ | Path | What you get |
375
+ | ---------------------------- | ------------------------------------------------------------------------ |
376
+ | `@cero-base/cero` | factory + operators + schema DSL |
377
+ | `@cero-base/cero/client` | `connect(ipc, spec)` — talk to a cero running in another process |
378
+ | `@cero-base/cero/server` | `serve(ipc, { storage, spec })` — run + expose a cero over an IPC stream |
379
+ | `@cero-base/cero/build` | `build(specDir, schema)` — generate the on-disk spec |
380
+ | `@cero-base/cero/extensions` | bundled extensions (`profileSync`, …) |
272
381
 
273
382
  ## Tests
274
383
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cero-base/cero",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
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",
@@ -30,6 +30,10 @@
30
30
  "./client": {
31
31
  "types": "./types/rpc/client.d.ts",
32
32
  "default": "./src/rpc/client.js"
33
+ },
34
+ "./extensions": {
35
+ "types": "./types/extensions/index.d.ts",
36
+ "default": "./src/extensions/index.js"
33
37
  }
34
38
  },
35
39
  "typesVersions": {
@@ -42,6 +46,9 @@
42
46
  ],
43
47
  "client": [
44
48
  "types/rpc/client.d.ts"
49
+ ],
50
+ "extensions": [
51
+ "types/extensions/index.d.ts"
45
52
  ]
46
53
  }
47
54
  },
@@ -67,7 +74,7 @@
67
74
  "test": "ls test/*.test.js | xargs -P2 -n1 brittle-node"
68
75
  },
69
76
  "dependencies": {
70
- "@cero-base/core": "^0.6.0",
77
+ "@cero-base/core": "^0.8.0",
71
78
  "b4a": "^1.8.1",
72
79
  "bare-crypto": "^1.13.7",
73
80
  "bare-fs": "^4.7.1",
@@ -9,6 +9,7 @@ import HRPCBuilder from 'hrpc'
9
9
  import { CeroError } from '@cero-base/core/errors'
10
10
 
11
11
  import { NS } from '../lib/constants.js'
12
+ import { internal } from '../lib/internal.js'
12
13
  import {
13
14
  refs,
14
15
  builtinRefs,
@@ -45,19 +46,23 @@ export async function build(specDir, schema, { ns = NS } = {}) {
45
46
  const raw = /** @type {SchemaDefs & { local?: SchemaDefs }} */ (schema?.defs || schema)
46
47
  if (!raw || typeof raw !== 'object') throw CeroError.REQUIRED('schema')
47
48
 
48
- // Pull out `t.extend(...)` entries (keyed by builtin ref name) and map them
49
- // to the builtin's type name, to merge into builtin types across every scope.
49
+ // Pull out `t.extend(...)` entries (keyed by builtin ref name) builtin type
50
+ // name, merged across scopes. App schema first, then each `cero.use()` extension.
50
51
  const extend = {}
51
52
  const defs = {}
52
- for (const [k, v] of Object.entries(raw)) {
53
- if (v && v.kind === 'extend') {
54
- const type = refs.main[k]?.type
55
- if (!type) throw CeroError.INVALID(`'${k}' is not an extendable builtin`)
56
- extend[type] = v.fields
57
- } else {
58
- defs[k] = v
53
+ const collect = (entries, fromExt = false) => {
54
+ for (const [k, v] of Object.entries(entries)) {
55
+ if (v && v.kind === 'extend') {
56
+ const type = refs.main[k]?.type
57
+ if (!type) throw CeroError.INVALID(`'${k}' is not an extendable builtin`)
58
+ extend[type] = { ...extend[type], ...v.fields }
59
+ } else if (!fromExt || !(k in defs)) {
60
+ defs[k] = v // app schema wins over an extension's default
61
+ }
59
62
  }
60
63
  }
64
+ collect(raw)
65
+ for (const ext of internal.extensions) if (ext.schema) collect(ext.schema, true)
61
66
 
62
67
  const main = compile(splitMain(defs), ns, 'main')
63
68
  const local = compile(defs.local || {}, ns, 'local')
@@ -0,0 +1,31 @@
1
+ import { t } from '../lib/spec.js'
2
+ import { set, watch } from '../lib/operators.js'
3
+
4
+ /**
5
+ * Mirror a child handle's `profile` (name + avatar) onto its row in the parent's
6
+ * `handles` list — so a handle list shows names + avatars without opening each
7
+ * one. Adds the synced `fields` to the `handle` builtin.
8
+ *
9
+ * On create it seeds the handle's `profile.name` from the open `{ name }`, then
10
+ * reflects the (app-owned) `profile` onto the row. Requires your handle types to
11
+ * declare a `profile` single — handles without one are left untouched.
12
+ *
13
+ * @param {{ fields?: Record<string, any> }} [opts]
14
+ */
15
+ export function handleSync({ fields = { avatar: t.string } } = {}) {
16
+ return {
17
+ schema: { handles: t.extend(fields) },
18
+ setup(me) {
19
+ const onHandle = (child, opts) => {
20
+ if (!child.profile) return
21
+ if (opts.name) set(child.profile, { name: opts.name }).catch(me._onerror)
22
+ watch(child.profile).on('data', ({ data }) => {
23
+ if (!data?.name) return
24
+ child.name = data.name
25
+ set(me.handles, { id: child.id, ...data }, { upsert: false }).catch(me._onerror)
26
+ })
27
+ }
28
+ me.on('handle', onHandle, { signal: me.signal })
29
+ }
30
+ }
31
+ }
@@ -0,0 +1,2 @@
1
+ export * from './profile-sync.js'
2
+ export * from './handle-sync.js'
@@ -0,0 +1,36 @@
1
+ import { t } from '../lib/spec.js'
2
+ import { get, set, after } from '../lib/operators.js'
3
+
4
+ /**
5
+ * Mirror your `profile` onto your `member` row in every handle you're in.
6
+ * Declares a `profile` single (`name`, `avatar`, plus any extra `fields`) and
7
+ * mirrors them onto the `member` builtin, then publishes when you open/join a
8
+ * handle and whenever your profile changes. An app may declare its own richer
9
+ * `profile` instead — the app schema wins.
10
+ *
11
+ * @param {{ fields?: Record<string, any> }} [opts]
12
+ */
13
+ export function profileSync({ fields = { avatar: t.string } } = {}) {
14
+ return {
15
+ schema: {
16
+ profile: t.single({ name: t.string, ...fields }),
17
+ members: t.extend(fields)
18
+ },
19
+ setup(me) {
20
+ const publish = (child, profile) =>
21
+ set(child.members, { id: me.identity.id, ...profile }, { upsert: false }).catch(me._onerror)
22
+
23
+ const onHandle = async (child) => {
24
+ const { data: profile } = await get(me.profile)
25
+ if (profile) publish(child, profile)
26
+ }
27
+
28
+ const onProfile = (ctx) => {
29
+ if (ctx.row) me.children.forEach((child) => publish(child, ctx.row))
30
+ }
31
+
32
+ me.on('handle', onHandle, { signal: me.signal })
33
+ after(me.profile, onProfile, { signal: me.signal })
34
+ }
35
+ }
36
+ }
@@ -13,7 +13,7 @@ import { CeroError } from '@cero-base/core/errors'
13
13
 
14
14
  import { NS, TIMEOUT } from '../lib/constants.js'
15
15
 
16
- import { attachRefs } from '../lib/utils.js'
16
+ import { attachRefs, onAbort } from '../lib/utils.js'
17
17
 
18
18
  export { Ref } from '../lib/utils.js'
19
19
 
@@ -110,6 +110,7 @@ export class Handle extends ReadyResource {
110
110
  this._opts = opts.opts || {}
111
111
  this._onerror = this._opts.onerror || safetyCatch
112
112
  this.children = parent ? null : new Set()
113
+ this._owned = new Set()
113
114
 
114
115
  this.store = new Database({
115
116
  store: store,
@@ -137,16 +138,11 @@ export class Handle extends ReadyResource {
137
138
  await this.pair.ready()
138
139
  }
139
140
  attachRefs(this, this.store.refs)
140
- if (!this.children) return
141
-
142
- this._offUpdate = this.store.onUpdate(() => {
143
- if (this.closing || this.closed) return
144
- for (const c of this.children) this._syncMember(c).catch(this._onerror)
145
- })
146
141
  }
147
142
 
148
143
  async _close() {
149
- this._offUpdate?.()
144
+ for (const r of [...this._owned]) r.destroy?.()
145
+ this._owned.clear()
150
146
  if (this.children) {
151
147
  for (const c of [...this.children]) await c.close()
152
148
  this.children.clear()
@@ -168,6 +164,56 @@ export class Handle extends ReadyResource {
168
164
  if (this._storage) await this._storage.close()
169
165
  }
170
166
 
167
+ /**
168
+ * Tie a destroyable resource (a `watch` stream, a timer, any `{ destroy }`)
169
+ * to this handle's lifecycle — it's destroyed automatically on close, so
170
+ * callers don't track cleanup. De-registers itself if destroyed earlier.
171
+ *
172
+ * @template {{ destroy?: Function, once?: Function }} T
173
+ * @param {T} resource
174
+ * @returns {T}
175
+ */
176
+ own(resource) {
177
+ if (this.closing || this.closed) {
178
+ resource.destroy?.()
179
+ return resource
180
+ }
181
+ this._owned.add(resource)
182
+ resource.once?.('close', () => this._owned.delete(resource))
183
+ return resource
184
+ }
185
+
186
+ /**
187
+ * An `AbortSignal` that fires when this handle closes. Pass it as
188
+ * `{ signal }` to `on`/`after`/`before`/`watch` to drop a subscription on
189
+ * close — or use your own `AbortController` for a finer scope.
190
+ *
191
+ * @returns {AbortSignal}
192
+ */
193
+ get signal() {
194
+ if (!this._ac) {
195
+ this._ac = new AbortController()
196
+ if (this.closed) this._ac.abort()
197
+ else this.once('close', () => this._ac.abort())
198
+ }
199
+ return this._ac.signal
200
+ }
201
+
202
+ /**
203
+ * `EventEmitter.on` plus an optional `{ signal }` that removes the listener
204
+ * when the signal aborts — e.g. `me.on('handle', fn, { signal: me.signal })`.
205
+ *
206
+ * @param {string} event
207
+ * @param {(...args: any[]) => void} fn
208
+ * @param {{ signal?: AbortSignal }} [opts]
209
+ * @returns {this}
210
+ */
211
+ on(event, fn, opts) {
212
+ super.on(event, fn)
213
+ onAbort(opts?.signal, () => super.off(event, fn))
214
+ return this
215
+ }
216
+
171
217
  /** Canonical id — identity id for the root handle, store key for children. */
172
218
  get id() {
173
219
  if (!this.parent) return this.identity.id
@@ -340,7 +386,6 @@ export class Handle extends ReadyResource {
340
386
  createdAt: ts,
341
387
  updatedAt: ts
342
388
  })
343
- if (name && child.profile) await child.store.set('profile', { name })
344
389
  await this.store.call('add-handle', {
345
390
  id,
346
391
  type,
@@ -353,7 +398,7 @@ export class Handle extends ReadyResource {
353
398
 
354
399
  if (accept !== false) this._wireAccept(child, { role })
355
400
  this.children.add(child)
356
- await this._syncMember(child)
401
+ this.emit('handle', child, { name, role })
357
402
  return child
358
403
  }
359
404
 
@@ -396,20 +441,19 @@ export class Handle extends ReadyResource {
396
441
  const id = toId(child.store.key)
397
442
  await this._saveKeyPair(id, child.store.keyPair)
398
443
 
399
- const name = child.profile ? await waitForProfileName(child, deadline) : null
400
444
  const ts = Date.now()
401
445
  await this.store.call('add-handle', {
402
446
  id,
403
447
  type,
404
448
  key: child.store.key,
405
449
  encryptionKey: child.store.encryptionKey,
406
- name,
450
+ name: null,
407
451
  createdAt: ts,
408
452
  updatedAt: ts
409
453
  })
410
454
  this._wireAccept(child)
411
455
  this.children.add(child)
412
- await this._syncMember(child)
456
+ this.emit('handle', child, {})
413
457
  return child
414
458
  }
415
459
 
@@ -452,6 +496,7 @@ export class Handle extends ReadyResource {
452
496
  }
453
497
  this._wireAccept(child)
454
498
  this.children.add(child)
499
+ this.emit('handle', child, {})
455
500
  return child
456
501
  }
457
502
 
@@ -534,20 +579,6 @@ export class Handle extends ReadyResource {
534
579
  })
535
580
  }
536
581
 
537
- async _syncMember(child) {
538
- if (!child.store.writable) return
539
- const { data: profile } = await this.store.get('profile')
540
- if (!profile) return
541
- const { data: existing } = await child.store.get('members', this.identity.id)
542
- if (!existing) return
543
- await child.store.call('set-member', {
544
- ...existing,
545
- ...profile,
546
- id: existing.id,
547
- updatedAt: Date.now()
548
- })
549
- }
550
-
551
582
  /**
552
583
  * @param {Handle} child
553
584
  * @param {{ role?: string }} [opts]
@@ -597,13 +628,3 @@ function pickHandle(spec, type) {
597
628
  function randomNs() {
598
629
  return z32.encode(Identity.randomBytes(8))
599
630
  }
600
-
601
- async function waitForProfileName(child, timeout) {
602
- const deadline = Date.now() + timeout
603
- while (Date.now() < deadline) {
604
- const { data } = await child.store.get('profile')
605
- if (data?.name) return data.name
606
- await new Promise((r) => setTimeout(r, 100))
607
- }
608
- return null
609
- }
package/src/index.js CHANGED
@@ -8,13 +8,14 @@ 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 } from './lib/operators.js'
11
+ import { put, set, get, del, count, watch, call, open, before, after } from './lib/operators.js'
12
12
  import { peek } from './lib/peek.js'
13
13
  import { t, schema } from './lib/spec.js'
14
14
  import { FLUSH } from './lib/constants.js'
15
+ import { internal } from './lib/internal.js'
15
16
 
16
17
  export { Handle, Ref, Local }
17
- export { put, set, get, del, count, watch, call, open } from './lib/operators.js'
18
+ export { put, set, get, del, count, watch, call, open, before, after } from './lib/operators.js'
18
19
  export { peek } from './lib/peek.js'
19
20
  export { t, schema } from './lib/spec.js'
20
21
 
@@ -112,6 +113,11 @@ export async function cero(dir, spec, opts = {}) {
112
113
  }
113
114
  if (opts.recovery) await me.recover({ timeout: opts.recoveryTimeout })
114
115
 
116
+ for (const ext of internal.extensions) {
117
+ const off = await ext.setup?.(me)
118
+ if (typeof off === 'function') me.once('close', off)
119
+ }
120
+
115
121
  return me
116
122
  }
117
123
 
@@ -150,7 +156,26 @@ export async function restore(me, phrase) {
150
156
  })
151
157
  }
152
158
 
153
- Object.assign(cero, { put, set, get, del, count, watch, call, open, peek, restore, t, schema })
159
+ Object.assign(cero, {
160
+ t,
161
+ put,
162
+ set,
163
+ get,
164
+ del,
165
+ count,
166
+ watch,
167
+ call,
168
+ open,
169
+ before,
170
+ after,
171
+ peek,
172
+ restore,
173
+ schema
174
+ })
175
+ cero._internal = internal
176
+ // A bare function is shorthand for a behavior-only extension: `{ setup: fn }`.
177
+ cero.use = (...exts) =>
178
+ internal.extensions.push(...exts.map((e) => (typeof e === 'function' ? { setup: e } : e)))
154
179
 
155
180
  async function resolveIdentity(opts, local) {
156
181
  if (opts.identity) return opts.identity
@@ -0,0 +1,3 @@
1
+ // Process-wide registry for extensions registered via `cero.use()`.
2
+ // `build()` folds in each extension's schema; `cero()` runs each setup.
3
+ export const internal = { extensions: [] }
@@ -1,5 +1,7 @@
1
1
  import { Readable } from 'streamx'
2
2
 
3
+ import { onAbort } from './utils.js'
4
+
3
5
  /**
4
6
  * @typedef {import('./utils.js').Ref} Ref
5
7
  * @typedef {{ data: any }} SingleResult
@@ -18,13 +20,15 @@ export const put = (ref, row) => ref.handle.store.put(ref.name, row)
18
20
 
19
21
  /**
20
22
  * Upsert a row on `ref` — merges with the existing row and preserves
21
- * `createdAt`.
23
+ * `createdAt`. Pass `{ upsert: false }` to update-only: a missing row is left
24
+ * untouched instead of created (atomic — never resurrects a deleted row).
22
25
  *
23
26
  * @param {Ref} ref
24
27
  * @param {Record<string, any>} row
28
+ * @param {{ upsert?: boolean }} [opts]
25
29
  * @returns {Promise<SingleResult>}
26
30
  */
27
- export const set = (ref, row) => ref.handle.store.set(ref.name, row)
31
+ export const set = (ref, row, opts) => ref.handle.store.set(ref.name, row, opts)
28
32
 
29
33
  /**
30
34
  * Delete a row by id (collection refs), or wipe the row (single refs).
@@ -53,6 +57,50 @@ export const count = (ref, q) => ref.handle.store.count(ref.name, q)
53
57
  */
54
58
  export const call = (ref, d) => ref.handle.store.call(ref.name, d)
55
59
 
60
+ // Write ops per ref kind, for `before`/`after` subscriptions.
61
+ const WRITES = { single: ['set'], collection: ['put', 'set', 'del'] }
62
+
63
+ /**
64
+ * Intercept writes to `ref` before they commit — `fn(ctx)` runs in-path
65
+ * (awaited). Return `false` to cancel the write, or mutate `ctx.row`.
66
+ * Returns an unsubscribe fn; pass `{ signal }` to unsubscribe on abort.
67
+ *
68
+ * @param {Ref} ref
69
+ * @param {(ctx: { op: string, name: string, row: any }) => any} fn
70
+ * @param {{ signal?: AbortSignal }} [opts]
71
+ * @returns {() => void}
72
+ */
73
+ export const before = (ref, fn, opts) => {
74
+ const db = ref.handle.store
75
+ const ops = WRITES[ref.kind] || ['set']
76
+ const offs = ops.map((op) =>
77
+ db.before(op, (ctx) => (ctx.name === ref.name ? fn(ctx) : undefined))
78
+ )
79
+ const off = () => offs.forEach((unsub) => unsub())
80
+ onAbort(opts?.signal, off)
81
+ return off
82
+ }
83
+
84
+ /**
85
+ * Subscribe to writes on `ref` — fires after each committed write,
86
+ * non-blocking (observe only). Returns an unsubscribe fn; pass `{ signal }`
87
+ * to unsubscribe on abort.
88
+ *
89
+ * @param {Ref} ref
90
+ * @param {(ctx: { op: string, name: string, row: any }) => void} fn
91
+ * @param {{ signal?: AbortSignal }} [opts]
92
+ * @returns {() => void}
93
+ */
94
+ export const after = (ref, fn, opts) => {
95
+ const db = ref.handle.store
96
+ const ops = WRITES[ref.kind] || ['set']
97
+ const handler = (ctx) => ctx.name === ref.name && fn(ctx)
98
+ for (const op of ops) db.on(`after:${op}`, handler)
99
+ const off = () => ops.forEach((op) => db.off(`after:${op}`, handler))
100
+ onAbort(opts?.signal, off)
101
+ return off
102
+ }
103
+
56
104
  // get/watch on a handle-kind ref list its rows from the `handles` collection
57
105
  // filtered by type (handle types are stored there with their { id, key,
58
106
  // encryptionKey, name }). Data-kind refs go straight to the store.
@@ -79,16 +127,28 @@ export const get = async (ref, q) => {
79
127
  return normalize(all, ref.name)
80
128
  }
81
129
 
130
+ // Tie a fresh watch stream to its handle's lifecycle (destroyed on close) and
131
+ // to an optional `{ signal }` (destroyed on abort). Local refs have no
132
+ // close-cascade, so they keep managing their own streams.
133
+ const bindStream = (owner, stream, opts) => {
134
+ onAbort(opts?.signal, () => stream.destroy())
135
+ return owner.own ? owner.own(stream) : stream
136
+ }
137
+
82
138
  /**
83
139
  * Live snapshot stream on `ref` — re-emits the latest `get()` result on
84
- * every underlying mutation. Destroy the stream to stop watching.
140
+ * every underlying mutation. Tied to `ref.handle`'s lifecycle: closing the
141
+ * handle destroys it. Pass `{ signal }` to bind it to a finer scope, or
142
+ * destroy the stream directly to stop watching sooner.
85
143
  *
86
144
  * @param {Ref} ref
87
145
  * @param {Record<string, any>} [q]
146
+ * @param {{ signal?: AbortSignal }} [opts]
88
147
  * @returns {import('streamx').Readable}
89
148
  */
90
- export const watch = (ref, q) => {
91
- if (ref.kind !== 'handle') return ref.handle.store.watch(ref.name, q)
149
+ export const watch = (ref, q, opts) => {
150
+ const owner = ref.handle
151
+ if (ref.kind !== 'handle') return bindStream(owner, owner.store.watch(ref.name, q), opts)
92
152
  const source = parentStore(ref).watch('handles', q)
93
153
  const out = new Readable({
94
154
  destroy(cb) {
@@ -99,7 +159,7 @@ export const watch = (ref, q) => {
99
159
  source.on('data', (snap) => out.push(normalize(snap?.data, ref.name)))
100
160
  source.on('end', () => out.push(null))
101
161
  source.on('error', (err) => out.destroy(err))
102
- return out
162
+ return bindStream(owner, out, opts)
103
163
  }
104
164
 
105
165
  // Universal signal instantiator. Dispatch on the second arg:
package/src/lib/utils.js CHANGED
@@ -39,3 +39,16 @@ export function attachRefs(target, refs) {
39
39
  target[name] = new Ref(target, name, info.kind, info.schema)
40
40
  }
41
41
  }
42
+
43
+ /**
44
+ * Run `cb` when `signal` aborts — or immediately if it already has. No-op
45
+ * without a signal. The listener removes itself on fire.
46
+ *
47
+ * @param {AbortSignal} [signal]
48
+ * @param {() => void} cb
49
+ */
50
+ export function onAbort(signal, cb) {
51
+ if (!signal) return
52
+ if (signal.aborted) return cb()
53
+ signal.addEventListener('abort', cb, { once: true })
54
+ }
@@ -1,153 +1,153 @@
1
1
  export const main: {
2
2
  'del-by-id': {
3
- id: import("@cero-base/core/schema").Prim;
3
+ id: import("@cero-base/core").Prim;
4
4
  };
5
5
  writer: {
6
- master: import("@cero-base/core/schema").Prim;
7
- writer: import("@cero-base/core/schema").Prim;
8
- sig: import("@cero-base/core/schema").Prim;
9
- isIndexer: import("@cero-base/core/schema").Prim;
6
+ master: import("@cero-base/core").Prim;
7
+ writer: import("@cero-base/core").Prim;
8
+ sig: import("@cero-base/core").Prim;
9
+ isIndexer: import("@cero-base/core").Prim;
10
10
  };
11
11
  counter: {
12
- name: import("@cero-base/core/schema").Prim;
13
- value: import("@cero-base/core/schema").Prim;
12
+ name: import("@cero-base/core").Prim;
13
+ value: import("@cero-base/core").Prim;
14
14
  };
15
15
  member: {
16
- id: import("@cero-base/core/schema").Prim;
17
- key: import("@cero-base/core/schema").Prim;
18
- role: import("@cero-base/core/schema").Prim;
19
- name: import("@cero-base/core/schema").Prim;
20
- createdAt: import("@cero-base/core/schema").Prim;
21
- updatedAt: import("@cero-base/core/schema").Prim;
22
- sig: import("@cero-base/core/schema").Prim;
23
- index: import("@cero-base/core/schema").Prim;
16
+ id: import("@cero-base/core").Prim;
17
+ key: import("@cero-base/core").Prim;
18
+ role: import("@cero-base/core").Prim;
19
+ name: import("@cero-base/core").Prim;
20
+ createdAt: import("@cero-base/core").Prim;
21
+ updatedAt: import("@cero-base/core").Prim;
22
+ sig: import("@cero-base/core").Prim;
23
+ index: import("@cero-base/core").Prim;
24
24
  };
25
25
  device: {
26
- id: import("@cero-base/core/schema").Prim;
27
- memberId: import("@cero-base/core/schema").Prim;
28
- name: import("@cero-base/core/schema").Prim;
29
- isMobile: import("@cero-base/core/schema").Prim;
30
- createdAt: import("@cero-base/core/schema").Prim;
31
- updatedAt: import("@cero-base/core/schema").Prim;
32
- index: import("@cero-base/core/schema").Prim;
26
+ id: import("@cero-base/core").Prim;
27
+ memberId: import("@cero-base/core").Prim;
28
+ name: import("@cero-base/core").Prim;
29
+ isMobile: import("@cero-base/core").Prim;
30
+ createdAt: import("@cero-base/core").Prim;
31
+ updatedAt: import("@cero-base/core").Prim;
32
+ index: import("@cero-base/core").Prim;
33
33
  };
34
34
  invite: {
35
- id: import("@cero-base/core/schema").Prim;
36
- invite: import("@cero-base/core/schema").Prim;
37
- publicKey: import("@cero-base/core/schema").Prim;
38
- data: import("@cero-base/core/schema").Prim;
39
- sig: import("@cero-base/core/schema").Prim;
40
- role: import("@cero-base/core/schema").Prim;
41
- expires: import("@cero-base/core/schema").Prim;
42
- createdAt: import("@cero-base/core/schema").Prim;
43
- index: import("@cero-base/core/schema").Prim;
35
+ id: import("@cero-base/core").Prim;
36
+ invite: import("@cero-base/core").Prim;
37
+ publicKey: import("@cero-base/core").Prim;
38
+ data: import("@cero-base/core").Prim;
39
+ sig: import("@cero-base/core").Prim;
40
+ role: import("@cero-base/core").Prim;
41
+ expires: import("@cero-base/core").Prim;
42
+ createdAt: import("@cero-base/core").Prim;
43
+ index: import("@cero-base/core").Prim;
44
44
  };
45
45
  handle: {
46
- id: import("@cero-base/core/schema").Prim;
47
- type: import("@cero-base/core/schema").Prim;
48
- key: import("@cero-base/core/schema").Prim;
49
- encryptionKey: import("@cero-base/core/schema").Prim;
50
- name: import("@cero-base/core/schema").Prim;
51
- createdAt: import("@cero-base/core/schema").Prim;
52
- updatedAt: import("@cero-base/core/schema").Prim;
53
- index: import("@cero-base/core/schema").Prim;
46
+ id: import("@cero-base/core").Prim;
47
+ type: import("@cero-base/core").Prim;
48
+ key: import("@cero-base/core").Prim;
49
+ encryptionKey: import("@cero-base/core").Prim;
50
+ name: import("@cero-base/core").Prim;
51
+ createdAt: import("@cero-base/core").Prim;
52
+ updatedAt: import("@cero-base/core").Prim;
53
+ index: import("@cero-base/core").Prim;
54
54
  };
55
55
  claim: {
56
- identity: import("@cero-base/core/schema").Prim;
57
- writer: import("@cero-base/core/schema").Prim;
58
- sig: import("@cero-base/core/schema").Prim;
56
+ identity: import("@cero-base/core").Prim;
57
+ writer: import("@cero-base/core").Prim;
58
+ sig: import("@cero-base/core").Prim;
59
59
  };
60
60
  };
61
61
  export const local: {
62
62
  master: {
63
- seed: import("@cero-base/core/schema").Prim;
63
+ seed: import("@cero-base/core").Prim;
64
64
  };
65
65
  keypair: {
66
- publicKey: import("@cero-base/core/schema").Prim;
67
- secretKey: import("@cero-base/core/schema").Prim;
66
+ publicKey: import("@cero-base/core").Prim;
67
+ secretKey: import("@cero-base/core").Prim;
68
68
  };
69
69
  'handle-keypair': {
70
- id: import("@cero-base/core/schema").Prim;
71
- publicKey: import("@cero-base/core/schema").Prim;
72
- secretKey: import("@cero-base/core/schema").Prim;
73
- encryptionKey: import("@cero-base/core/schema").Prim;
70
+ id: import("@cero-base/core").Prim;
71
+ publicKey: import("@cero-base/core").Prim;
72
+ secretKey: import("@cero-base/core").Prim;
73
+ encryptionKey: import("@cero-base/core").Prim;
74
74
  };
75
75
  };
76
76
  export const rpc: {
77
77
  'req-empty': {
78
- ok: import("@cero-base/core/schema").Prim;
78
+ ok: import("@cero-base/core").Prim;
79
79
  };
80
80
  'req-restore': {
81
- phrase: import("@cero-base/core/schema").Prim;
81
+ phrase: import("@cero-base/core").Prim;
82
82
  };
83
83
  'req-row': {
84
- handle: import("@cero-base/core/schema").Prim;
85
- ref: import("@cero-base/core/schema").Prim;
86
- data: import("@cero-base/core/schema").Prim;
87
- local: import("@cero-base/core/schema").Prim;
84
+ handle: import("@cero-base/core").Prim;
85
+ ref: import("@cero-base/core").Prim;
86
+ data: import("@cero-base/core").Prim;
87
+ local: import("@cero-base/core").Prim;
88
88
  };
89
89
  'req-id': {
90
- handle: import("@cero-base/core/schema").Prim;
91
- ref: import("@cero-base/core/schema").Prim;
92
- id: import("@cero-base/core/schema").Prim;
93
- local: import("@cero-base/core/schema").Prim;
90
+ handle: import("@cero-base/core").Prim;
91
+ ref: import("@cero-base/core").Prim;
92
+ id: import("@cero-base/core").Prim;
93
+ local: import("@cero-base/core").Prim;
94
94
  };
95
95
  'req-query': {
96
- handle: import("@cero-base/core/schema").Prim;
97
- ref: import("@cero-base/core/schema").Prim;
98
- query: import("@cero-base/core/schema").Prim;
99
- local: import("@cero-base/core/schema").Prim;
96
+ handle: import("@cero-base/core").Prim;
97
+ ref: import("@cero-base/core").Prim;
98
+ query: import("@cero-base/core").Prim;
99
+ local: import("@cero-base/core").Prim;
100
100
  };
101
101
  'req-call': {
102
- handle: import("@cero-base/core/schema").Prim;
103
- op: import("@cero-base/core/schema").Prim;
104
- data: import("@cero-base/core/schema").Prim;
102
+ handle: import("@cero-base/core").Prim;
103
+ op: import("@cero-base/core").Prim;
104
+ data: import("@cero-base/core").Prim;
105
105
  };
106
106
  'req-invite': {
107
- handle: import("@cero-base/core/schema").Prim;
108
- role: import("@cero-base/core/schema").Prim;
107
+ handle: import("@cero-base/core").Prim;
108
+ role: import("@cero-base/core").Prim;
109
109
  };
110
110
  'req-revoke': {
111
- handle: import("@cero-base/core/schema").Prim;
112
- invite: import("@cero-base/core/schema").Prim;
111
+ handle: import("@cero-base/core").Prim;
112
+ invite: import("@cero-base/core").Prim;
113
113
  };
114
114
  'req-join': {
115
- parent: import("@cero-base/core/schema").Prim;
116
- ref: import("@cero-base/core/schema").Prim;
117
- invite: import("@cero-base/core/schema").Prim;
115
+ parent: import("@cero-base/core").Prim;
116
+ ref: import("@cero-base/core").Prim;
117
+ invite: import("@cero-base/core").Prim;
118
118
  };
119
119
  'req-open': {
120
- parent: import("@cero-base/core/schema").Prim;
121
- row: import("@cero-base/core/schema").Prim;
120
+ parent: import("@cero-base/core").Prim;
121
+ row: import("@cero-base/core").Prim;
122
122
  };
123
123
  'req-handle': {
124
- handle: import("@cero-base/core/schema").Prim;
124
+ handle: import("@cero-base/core").Prim;
125
125
  };
126
126
  'res-data': {
127
- data: import("@cero-base/core/schema").Prim;
127
+ data: import("@cero-base/core").Prim;
128
128
  };
129
129
  'res-rows': {
130
- data: import("@cero-base/core/schema").Prim;
131
- total: import("@cero-base/core/schema").Prim;
132
- size: import("@cero-base/core/schema").Prim;
130
+ data: import("@cero-base/core").Prim;
131
+ total: import("@cero-base/core").Prim;
132
+ size: import("@cero-base/core").Prim;
133
133
  };
134
134
  'res-count': {
135
- count: import("@cero-base/core/schema").Prim;
135
+ count: import("@cero-base/core").Prim;
136
136
  };
137
137
  'res-invite': {
138
- invite: import("@cero-base/core/schema").Prim;
138
+ invite: import("@cero-base/core").Prim;
139
139
  };
140
140
  'res-handle': {
141
- id: import("@cero-base/core/schema").Prim;
142
- type: import("@cero-base/core/schema").Prim;
143
- name: import("@cero-base/core/schema").Prim;
141
+ id: import("@cero-base/core").Prim;
142
+ type: import("@cero-base/core").Prim;
143
+ name: import("@cero-base/core").Prim;
144
144
  };
145
145
  'res-identity': {
146
- id: import("@cero-base/core/schema").Prim;
147
- deviceId: import("@cero-base/core/schema").Prim;
148
- phrase: import("@cero-base/core/schema").Prim;
146
+ id: import("@cero-base/core").Prim;
147
+ deviceId: import("@cero-base/core").Prim;
148
+ phrase: import("@cero-base/core").Prim;
149
149
  };
150
150
  'res-ok': {
151
- ok: import("@cero-base/core/schema").Prim;
151
+ ok: import("@cero-base/core").Prim;
152
152
  };
153
153
  };
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Mirror a child handle's `profile` (name + avatar) onto its row in the parent's
3
+ * `handles` list — so a handle list shows names + avatars without opening each
4
+ * one. Adds the synced `fields` to the `handle` builtin.
5
+ *
6
+ * On create it seeds the handle's `profile.name` from the open `{ name }`, then
7
+ * reflects the (app-owned) `profile` onto the row. Requires your handle types to
8
+ * declare a `profile` single — handles without one are left untouched.
9
+ *
10
+ * @param {{ fields?: Record<string, any> }} [opts]
11
+ */
12
+ export function handleSync({ fields }?: {
13
+ fields?: Record<string, any>;
14
+ }): {
15
+ schema: {
16
+ handles: {
17
+ kind: "extend";
18
+ fields: Record<string, import("@cero-base/core").Prim>;
19
+ };
20
+ };
21
+ setup(me: any): void;
22
+ };
@@ -0,0 +1,2 @@
1
+ export * from "./profile-sync.js";
2
+ export * from "./handle-sync.js";
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Mirror your `profile` onto your `member` row in every handle you're in.
3
+ * Declares a `profile` single (`name`, `avatar`, plus any extra `fields`) and
4
+ * mirrors them onto the `member` builtin, then publishes when you open/join a
5
+ * handle and whenever your profile changes. An app may declare its own richer
6
+ * `profile` instead — the app schema wins.
7
+ *
8
+ * @param {{ fields?: Record<string, any> }} [opts]
9
+ */
10
+ export function profileSync({ fields }?: {
11
+ fields?: Record<string, any>;
12
+ }): {
13
+ schema: {
14
+ profile: import("@cero-base/core").TypeDef;
15
+ members: {
16
+ kind: "extend";
17
+ fields: Record<string, import("@cero-base/core").Prim>;
18
+ };
19
+ };
20
+ setup(me: any): void;
21
+ };
@@ -86,10 +86,44 @@ export class Handle extends ReadyResource {
86
86
  _opts: any;
87
87
  _onerror: any;
88
88
  children: Set<any>;
89
+ _owned: Set<any>;
89
90
  store: Database;
90
91
  pair: Pairing;
91
92
  _wantsPair: boolean;
92
- _offUpdate: () => void;
93
+ /**
94
+ * Tie a destroyable resource (a `watch` stream, a timer, any `{ destroy }`)
95
+ * to this handle's lifecycle — it's destroyed automatically on close, so
96
+ * callers don't track cleanup. De-registers itself if destroyed earlier.
97
+ *
98
+ * @template {{ destroy?: Function, once?: Function }} T
99
+ * @param {T} resource
100
+ * @returns {T}
101
+ */
102
+ own<T extends {
103
+ destroy?: Function;
104
+ once?: Function;
105
+ }>(resource: T): T;
106
+ /**
107
+ * An `AbortSignal` that fires when this handle closes. Pass it as
108
+ * `{ signal }` to `on`/`after`/`before`/`watch` to drop a subscription on
109
+ * close — or use your own `AbortController` for a finer scope.
110
+ *
111
+ * @returns {AbortSignal}
112
+ */
113
+ get signal(): AbortSignal;
114
+ _ac: AbortController;
115
+ /**
116
+ * `EventEmitter.on` plus an optional `{ signal }` that removes the listener
117
+ * when the signal aborts — e.g. `me.on('handle', fn, { signal: me.signal })`.
118
+ *
119
+ * @param {string} event
120
+ * @param {(...args: any[]) => void} fn
121
+ * @param {{ signal?: AbortSignal }} [opts]
122
+ * @returns {this}
123
+ */
124
+ on(event: string, fn: (...args: any[]) => void, opts?: {
125
+ signal?: AbortSignal;
126
+ }): this;
93
127
  /** Canonical id — identity id for the root handle, store key for children. */
94
128
  get id(): any;
95
129
  /** This device's id + name. `null` on child handles. */
@@ -203,7 +237,6 @@ export class Handle extends ReadyResource {
203
237
  * @returns {Promise<void>}
204
238
  */
205
239
  resume(): Promise<void>;
206
- _syncMember(child: any): Promise<void>;
207
240
  /**
208
241
  * @param {Handle} child
209
242
  * @param {{ role?: string }} [opts]
package/types/index.d.ts CHANGED
@@ -25,6 +25,10 @@
25
25
  * @returns {Promise<Handle>}
26
26
  */
27
27
  export function cero(dir: string, spec: any, opts?: CeroOpts): Promise<Handle>;
28
+ export namespace cero {
29
+ export { internal as _internal };
30
+ export function use(...exts: any[]): number;
31
+ }
28
32
  /**
29
33
  * Restore a cero instance from a mnemonic phrase. Closes the running
30
34
  * instance, wipes the on-disk `main/` tree and re-opens with `recovery: true`
@@ -94,9 +98,10 @@ export type CeroOpts = {
94
98
  recoveryTimeout?: number;
95
99
  };
96
100
  import { Handle } from './handle/index.js';
101
+ import { internal } from './lib/internal.js';
97
102
  import { Ref } from './handle/index.js';
98
103
  import { Local } from './local/index.js';
99
104
  import { Identity } from '@cero-base/core/identity';
100
105
  export { Handle, Ref, Local };
101
- export { put, set, get, del, count, watch, call, open } from "./lib/operators.js";
106
+ export { put, set, get, del, count, watch, call, open, before, after } from "./lib/operators.js";
102
107
  export { t, schema } from "./lib/spec.js";
@@ -0,0 +1,3 @@
1
+ export namespace internal {
2
+ let extensions: any[];
3
+ }
@@ -1,12 +1,30 @@
1
1
  export function put(ref: Ref, row: Record<string, any>): Promise<SingleResult>;
2
- export function set(ref: Ref, row: Record<string, any>): Promise<SingleResult>;
2
+ export function set(ref: Ref, row: Record<string, any>, opts?: {
3
+ upsert?: boolean;
4
+ }): Promise<SingleResult>;
3
5
  export function del(ref: Ref, id?: string): Promise<void>;
4
6
  export function count(ref: Ref, q?: Record<string, any>): Promise<{
5
7
  data: number;
6
8
  }>;
7
9
  export function call(ref: Ref, d?: Record<string, any>): Promise<any>;
10
+ export function before(ref: Ref, fn: (ctx: {
11
+ op: string;
12
+ name: string;
13
+ row: any;
14
+ }) => any, opts?: {
15
+ signal?: AbortSignal;
16
+ }): () => void;
17
+ export function after(ref: Ref, fn: (ctx: {
18
+ op: string;
19
+ name: string;
20
+ row: any;
21
+ }) => void, opts?: {
22
+ signal?: AbortSignal;
23
+ }): () => void;
8
24
  export function get(ref: Ref, q?: string | Record<string, any>): Promise<SingleResult | ListResult | GetByIdResult>;
9
- export function watch(ref: Ref, q?: Record<string, any>): any;
25
+ export function watch(ref: Ref, q?: Record<string, any>, opts?: {
26
+ signal?: AbortSignal;
27
+ }): any;
10
28
  export function open(ref: Ref, arg?: string | {
11
29
  invite?: string;
12
30
  id?: string;
@@ -7,6 +7,14 @@
7
7
  * @param {Record<string, RefInfo>} refs
8
8
  */
9
9
  export function attachRefs(target: any, refs: Record<string, RefInfo>): void;
10
+ /**
11
+ * Run `cb` when `signal` aborts — or immediately if it already has. No-op
12
+ * without a signal. The listener removes itself on fire.
13
+ *
14
+ * @param {AbortSignal} [signal]
15
+ * @param {() => void} cb
16
+ */
17
+ export function onAbort(signal?: AbortSignal, cb: () => void): void;
10
18
  /**
11
19
  * @typedef {'collection' | 'single' | 'action' | 'handle'} RefKind
12
20
  * @typedef {{ kind?: string, schema?: string }} RefInfo
@@ -111,7 +111,7 @@ declare class LocalRefs {
111
111
  /** @param {Client} client */
112
112
  constructor(client: Client);
113
113
  parent: Client;
114
- spec: import("@cero-base/core/rpc").Spec;
114
+ spec: import("@cero-base/core").Spec;
115
115
  store: this;
116
116
  _local: boolean;
117
117
  /** Underlying RPC channel borrowed from the parent. */
File without changes