@cero-base/core 0.0.5 → 0.2.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
@@ -34,6 +34,99 @@ export const schema = t.schema(
34
34
  | `private` (default) | Across paired devices |
35
35
  | `shared` | Between room members |
36
36
 
37
+ ## Built-in collections
38
+
39
+ cero provides built-in collections that always exist on every database — you don't declare them in `t.schema()` to use them:
40
+
41
+ | Name | Scope | Shape |
42
+ | ---------- | --------- | ---------------------------------------------------- |
43
+ | `profile` | `private` | Single-row identity profile (`name`, `avatar`, etc.) |
44
+ | `devices` | `private` | All devices paired to this identity |
45
+ | `rooms` | `private` | Rooms saved to this identity (id, key, name, …) |
46
+ | `invites` | `private` | Pending invites this identity has created |
47
+ | `mirrors` | `private` | Replicated-room hints (autobase mirror keys) |
48
+ | `settings` | `private` | Key/value app settings (replicated across devices) |
49
+ | `members` | `shared` | Members of the current room |
50
+
51
+ They're accessed via the same `Collection` API:
52
+
53
+ ```js
54
+ const profile = (await db.collection('profile').get())[0]
55
+ await db.collection('profile').put({ name: 'Alice' })
56
+
57
+ const devices = await db.collection('devices').get()
58
+ const rooms = await db.collection('rooms').get() // saved rooms
59
+
60
+ const room = await db.rooms.open() // open one
61
+ const members = await room.collection('members').get()
62
+ ```
63
+
64
+ `db.collection('rooms')` returns the identity-replicated saved-room list. Use `db.rooms.create/open/pair/close` for room _lifecycle_ (those aren't Collection ops).
65
+
66
+ ### Local-scope built-ins via `db.local.collection(name)`
67
+
68
+ cero also maintains device-local tables that don't replicate (rooms cache with rich indexes, file metadata, etc.). Reach them via `db.local.collection(name)`:
69
+
70
+ | Name | Key | Purpose |
71
+ | ---------- | ----------------------------- | ----------------------------------------------------------------------------------- |
72
+ | `identity` | `[]` | Single-row identity record (key, keyPair, …) |
73
+ | `rooms` | `['key']` | Local rooms cache with `by-name`/`by-updatedAt`/`by-discoveryKey` indexes |
74
+ | `files` | `['key', 'blob.blockOffset']` | Local file metadata |
75
+ | `settings` | `['key']` | Local key/value settings (distinct from the identity-replicated `settings` builtin) |
76
+ | `counters` | `['name']` | Local counter table |
77
+
78
+ ```js
79
+ const localRooms = await db.local.collection('rooms').get()
80
+ const cachedFiles = await db.local.collection('files').get()
81
+ ```
82
+
83
+ `db.local.collection(name)` also resolves user-declared local collections (those declared with `scope: 'local'` in `t.schema()`), so `db.local.collection('drafts')` and `db.collection('drafts')` are equivalent for a user-local `drafts`.
84
+
85
+ ### Indexes and handlers
86
+
87
+ `indexes` and `handlers` work on any collection — user-defined or built-in:
88
+
89
+ ```js
90
+ import { t } from '@cero-base/core/schema'
91
+
92
+ export const schema = t.schema(
93
+ {
94
+ // User-defined collection
95
+ tasks: {
96
+ fields: { id: t.string, title: t.string, dept: t.string, done: t.bool },
97
+ key: ['id'],
98
+ scope: 'shared',
99
+ indexes: { 'by-dept': ['dept'] },
100
+ handlers: {
101
+ 'archive-task': async (op, ctx) => {
102
+ const path = '@chat/tasks'
103
+ const m = await ctx.view.get(path, { id: op.id })
104
+ if (m) await ctx.view.insert(path, { ...m, done: true })
105
+ }
106
+ }
107
+ },
108
+
109
+ // Built-in extension — same shape, no key/scope/timestamps (those are fixed)
110
+ members: {
111
+ fields: { department: t.string, level: t.uint },
112
+ indexes: { 'by-department': ['department'] },
113
+ handlers: {
114
+ 'promote-member': async (op, ctx) => {
115
+ const path = '@chat/members'
116
+ const m = await ctx.view.get(path, { id: op.id })
117
+ if (m) await ctx.view.insert(path, { ...m, role: 'admin' })
118
+ }
119
+ }
120
+ }
121
+ },
122
+ { namespace: 'chat' }
123
+ )
124
+ ```
125
+
126
+ Custom dispatch payloads are encoded as `input-${type}` (all fields optional), so partial payloads like `{ id }` work without re-supplying every field.
127
+
128
+ Built-ins are **opt-out**, not opt-in: omit them from `t.schema()` and they still work with cero's default fields. List them only when you need to customize. `key`, `scope`, and `timestamps` are fixed for built-ins — passing them throws.
129
+
37
130
  ## Usage
38
131
 
39
132
  ```js
@@ -54,6 +147,9 @@ const room = await db.rooms.open()
54
147
  const messages = room.collection('messages')
55
148
  await messages.put({ text: 'Hello!', memberId: db.id })
56
149
 
150
+ // Custom dispatches declared via the built-in's handlers
151
+ await room.collection('members').dispatch('promote-member', { id: someId })
152
+
57
153
  // Batch writes
58
154
  const batch = db.batch()
59
155
  batch.put('settings', { key: 'a', value: '1' })
@@ -73,15 +169,14 @@ For RPC support, use `@cero-base/rpc`'s build which includes RPC types automatic
73
169
 
74
170
  ## Exports
75
171
 
76
- | Export | Path | Description |
77
- | ------------ | ------------------------- | ----------------------------------------------- |
78
- | `CeroBase` | `@cero-base/core` | Main database class |
79
- | `Room` | `@cero-base/core` | Room wrapper with collections, members, invites |
80
- | `Collection` | `@cero-base/core` | Collection with put/get/del/sub |
81
- | `Batch` | `@cero-base/core` | Buffered multi-collection writes |
82
- | `Schema` | `@cero-base/core` | Schema class |
83
- | `t` | `@cero-base/core/schema` | Type helpers for defining schemas |
84
- | `build` | `@cero-base/core/builder` | Build spec from schema |
172
+ | Export | Path | Description |
173
+ | ------------ | ------------------------- | ----------------------------------------------------------------------------------------------------------------- |
174
+ | `CeroBase` | `@cero-base/core` | Main database class — `.collection(name)`, `.local.collection(name)`, `.invite(opts?)`, `.join(input)`, `.seed()` |
175
+ | `Room` | `@cero-base/core` | Room wrapper with `.collection(name)` + `.invite(opts?)` |
176
+ | `Collection` | `@cero-base/core` | Collection with put/get/del/sub/dispatch |
177
+ | `Batch` | `@cero-base/core` | Buffered multi-collection writes |
178
+ | `t` | `@cero-base/core/schema` | Type helpers (`t.string`, `t.optional`, `t.array`, …) |
179
+ | `build` | `@cero-base/core/builder` | Build spec from schema |
85
180
 
86
181
  ## License
87
182
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cero-base/core",
3
- "version": "0.0.5",
3
+ "version": "0.2.0",
4
4
  "description": "P2P database with collection-based CRUD and RPC",
5
5
  "files": [
6
6
  "src",
@@ -23,7 +23,7 @@
23
23
  "test": "brittle test/*.js"
24
24
  },
25
25
  "dependencies": {
26
- "@lekinox/cero": "^1.0.10",
26
+ "@lekinox/cero": "^1.3.1",
27
27
  "compact-encoding": "^2.19.1",
28
28
  "ready-resource": "^1.0.2",
29
29
  "safety-catch": "^1.0.2"
package/src/lib/base.js CHANGED
@@ -28,9 +28,16 @@ export class CeroBase extends Cero {
28
28
 
29
29
  this.schema = schema
30
30
  this._baseUpdateHandler = null
31
+
32
+ // Expose cero's local-scope tables (rooms cache, files, settings, …) and
33
+ // user-declared `scope: 'local'` collections via the unified Collection API.
34
+ // db.local already has cero's find/insert/delete; we just add .collection.
35
+ if (this.local) {
36
+ this.local.collection = (name) => new Collection(this, name, { local: true })
37
+ }
38
+
31
39
  this.once('ready', () => {
32
40
  this._bindIdentityBase()
33
- this._initFacades()
34
41
  })
35
42
  }
36
43
 
@@ -59,32 +66,11 @@ export class CeroBase extends Cero {
59
66
  return this.pair(input, opts)
60
67
  }
61
68
 
62
- _initFacades() {
63
- const p = this.identity?.profile
64
- if (p) {
65
- this._profile = {
66
- get: () => p.get(),
67
- set: (data) => p.set(data),
68
- sub: () => p.subscribe()
69
- }
70
- }
71
-
72
- const d = this.identity?.devices
73
- if (d) {
74
- this._devices = {
75
- get: (query) => d.list(query),
76
- sub: (id) => d.subscribe(id),
77
- invite: () => this.invites.create().then(({ invite }) => invite)
78
- }
79
- }
80
- }
81
-
82
- get profile() {
83
- return this._profile || null
84
- }
85
-
86
- get devices() {
87
- return this._devices || null
69
+ /** Create a device-pairing invite string for this identity. */
70
+ async invite(opts = {}) {
71
+ if (!this.opened) await this.ready()
72
+ const { invite } = await this.invites.create(opts)
73
+ return invite
88
74
  }
89
75
 
90
76
  collection(name) {
package/src/lib/batch.js CHANGED
@@ -24,7 +24,10 @@ export class Batch {
24
24
  data.createdAt = data.createdAt ?? Date.now()
25
25
  }
26
26
 
27
- this.operations.push({ action: 'add', name, data, scope })
27
+ // If cero doesn't have add-${type}, fall back to set-${type} which is
28
+ // itself an upsert.
29
+ const action = this.schema.hasAdd(name) ? 'add' : 'set'
30
+ this.operations.push({ action, name, data, scope })
28
31
  return data
29
32
  }
30
33
 
@@ -35,7 +38,7 @@ export class Batch {
35
38
  const [existing] = await col.get(query)
36
39
  if (!existing) return false
37
40
 
38
- const keys = this.schema.collections[name]?.key || ['id']
41
+ const keys = this.schema.keysOf(name)
39
42
  const keyData = keys.reduce((acc, k) => ((acc[k] = existing[k]), acc), {})
40
43
 
41
44
  this.operations.push({ action: 'del', name, data: keyData, scope })
@@ -1,6 +1,7 @@
1
1
  import { Readable } from 'streamx'
2
2
  import { randomId } from './crypto.js'
3
3
  import { debounce, isLocal, isPrivate } from './utils.js'
4
+ import { SCOPE_LOCAL } from './constants.js'
4
5
 
5
6
  /** Route a write operation based on scope */
6
7
  function writeOp(owner, scope, path, ns, op, data, isDel) {
@@ -25,28 +26,62 @@ function writeOp(owner, scope, path, ns, op, data, isDel) {
25
26
  * tasks.sub().on('data', cb)
26
27
  */
27
28
  export class Collection {
28
- constructor(owner, name) {
29
+ constructor(owner, name, opts = {}) {
29
30
  this.owner = owner
30
31
  this.schema = owner.schema
32
+ this.name = name
33
+
34
+ if (opts.local) {
35
+ // Forced local resolution — used by db.local.collection(name) to reach
36
+ // cero's local-scope built-ins (rooms cache, files, etc.) and user-
37
+ // declared `scope: 'local'` collections via the same accessor.
38
+ const type = this.schema.localTypeOf(name)
39
+ if (!type) throw new Error(`'${name}' is not a local collection`)
40
+ this.scope = SCOPE_LOCAL
41
+ this.type = type
42
+ this.keys = this.schema.localKeysOf(name)
43
+ this.path = `@local/${name}`
44
+ this.ns = '@local'
45
+ this.hasAdd = true // local merges via direct insert; no dispatch involved
46
+ this.timestamps = false
47
+ this.counter = false
48
+ this.indexes = null
49
+ return
50
+ }
31
51
 
32
52
  const def = this.schema.collections[name]
33
53
  this.scope = this.schema.getScope(name)
34
- this.name = name
35
54
  this.type = this.schema.types.get(name)
36
55
  this.path = this.schema.getPath(name)
37
56
  this.ns = this.path.split('/')[0]
38
- this.keys = def.key || ['id']
57
+ this.keys = this.schema.keysOf(name)
58
+ this.hasAdd = this.schema.hasAdd(name)
39
59
  this.timestamps = this.schema.hasTimestamps(name)
40
60
  this.counter = !isLocal(this.scope) // all non-local collections have counter
41
- this.indexes = def.indexes || null
42
- this.db = isLocal(this.scope) ? null : isPrivate(this.scope) ? owner.identity.db : owner.room.db
61
+ this.indexes = def?.indexes || null
62
+ }
63
+
64
+ get db() {
65
+ if (isLocal(this.scope)) return null
66
+ return isPrivate(this.scope) ? this.owner.identity.db : this.owner.room.db
43
67
  }
44
68
 
45
69
  /** Upsert — insert if new, update if exists */
46
70
  async put(doc) {
47
- const data = doc.id ? doc : { ...doc, id: randomId() }
48
- const keyQuery = this.keys.reduce((acc, k) => ((acc[k] = data[k]), acc), {})
49
- const existing = data.id ? await this._getOne(keyQuery) : null
71
+ const isNew = !doc.id
72
+ const data = isNew ? { ...doc, id: randomId() } : doc
73
+
74
+ // If cero doesn't have add-${type} for this built-in (profile, settings),
75
+ // there's no auto-incrementing index — set-${type} is the only path and
76
+ // is itself an idempotent upsert.
77
+ if (!this.hasAdd) {
78
+ return this._write(`set-${this.type}`, data)
79
+ }
80
+
81
+ // Freshly randomized id can't possibly exist — skip the lookup.
82
+ const existing = isNew
83
+ ? null
84
+ : await this._getOne(this.keys.reduce((acc, k) => ((acc[k] = data[k]), acc), {}))
50
85
 
51
86
  if (existing) {
52
87
  const updated = { ...existing, ...data }
@@ -118,6 +153,15 @@ export class Collection {
118
153
  return true
119
154
  }
120
155
 
156
+ /** Dispatch a custom op (registered via the schema's handlers) on this collection */
157
+ async dispatch(op, data) {
158
+ if (isLocal(this.scope)) {
159
+ throw new Error(`Cannot dispatch on local collection '${this.name}'`)
160
+ }
161
+ const target = isPrivate(this.scope) ? this.owner.identity : this.owner.room
162
+ return target.dispatch(`${this.ns}/${op}`, data)
163
+ }
164
+
121
165
  /** Subscribe — returns Readable stream of query results */
122
166
  sub(query = {}, opts = {}) {
123
167
  const stream = new Readable()
package/src/lib/room.js CHANGED
@@ -64,10 +64,6 @@ export class Room extends ReadyResource {
64
64
  this.room = await this.db.rooms.open(this._arg)
65
65
  }
66
66
 
67
- this.profile = withSub(this.room.profile)
68
- this.members = withSub(this.room.members)
69
- this.invites = withSub(this.room.invites)
70
-
71
67
  this.room.on('update', () => this.emit('update'))
72
68
  }
73
69
 
@@ -96,9 +92,9 @@ export class Room extends ReadyResource {
96
92
  }
97
93
 
98
94
  /** Shorthand — create invite and return the string */
99
- async invite() {
95
+ async invite(opts = {}) {
100
96
  if (!this.opened) await this.ready()
101
- const { invite } = await this.room.invites.create()
97
+ const { invite } = await this.room.invites.create(opts)
102
98
  return invite
103
99
  }
104
100
 
@@ -147,10 +143,3 @@ function detectType(input) {
147
143
 
148
144
  return 'invite'
149
145
  }
150
-
151
- function withSub(obj) {
152
- if (!obj) return obj
153
- const wrapped = { ...obj, sub: obj.subscribe.bind(obj) }
154
- if (obj.list) wrapped.get = obj.list.bind(obj)
155
- return wrapped
156
- }
package/src/lib/schema.js CHANGED
@@ -1,6 +1,86 @@
1
+ import Hyperschema from 'hyperschema'
2
+ import { extendIdentitySchema, extendIdentityDispatch } from '@lekinox/cero/src/identity/schema.js'
3
+ import { extendRoomSchema, extendRoomDispatch } from '@lekinox/cero/src/room/schema.js'
1
4
  import { singular, toFields, scopeNs, isLocal, isPrivate, isShared } from './utils.js'
2
5
  import { SCOPE_LOCAL, SCOPE_PRIVATE, SCOPE_SHARED } from './constants.js'
3
6
 
7
+ // Built-in collections inherited from cero. Names map to:
8
+ // - scope: private = identity-replicated, shared = room-replicated
9
+ // - type: singular type name in cero's schema (e.g. 'member' for 'members')
10
+ // - key: primary key fields (empty array = single-row collection, e.g. profile)
11
+ //
12
+ // Whether each built-in supports add/set/del is DERIVED from cero's dispatch
13
+ // table at schema-build time (see ceroDispatches), not hardcoded here.
14
+ const BUILTINS = {
15
+ members: { scope: SCOPE_SHARED, type: 'member', key: ['id'] },
16
+ profile: { scope: SCOPE_PRIVATE, type: 'profile', key: [] },
17
+ devices: { scope: SCOPE_PRIVATE, type: 'device', key: ['id'] },
18
+ rooms: { scope: SCOPE_PRIVATE, type: 'room', key: ['id'] },
19
+ invites: { scope: SCOPE_PRIVATE, type: 'invite', key: ['id'] },
20
+ mirrors: { scope: SCOPE_PRIVATE, type: 'mirror', key: ['key'] },
21
+ settings: { scope: SCOPE_PRIVATE, type: 'setting', key: ['key'] }
22
+ }
23
+
24
+ // Local-scope built-ins cero registers in @local/* — exposed to apps via
25
+ // db.local.collection(name). Distinct from BUILTINS (which are private/shared).
26
+ const LOCAL_BUILTINS = {
27
+ identity: { type: 'identity', key: [] },
28
+ rooms: { type: 'room', key: ['key'] },
29
+ files: { type: 'file', key: ['key', 'blob.blockOffset'] },
30
+ settings: { type: 'setting', key: ['key'] },
31
+ counters: { type: 'counter', key: ['name'] }
32
+ }
33
+
34
+ // One in-memory Hyperschema per (scope, ns) — built once by running cero's
35
+ // extender and reused for every type lookup. The extender registers all
36
+ // built-in types in the namespace; baseFieldsOf just reads them back.
37
+ const schemaCache = new Map()
38
+ function builtinSchema(scope, nsName) {
39
+ const key = `${scope}:${nsName}`
40
+ let schema = schemaCache.get(key)
41
+ if (schema) return schema
42
+
43
+ schema = new Hyperschema()
44
+ const ns = schema.namespace(nsName)
45
+ if (scope === SCOPE_SHARED) {
46
+ extendRoomSchema(schema, ns)
47
+ } else {
48
+ extendIdentitySchema(schema, ns)
49
+ }
50
+ schemaCache.set(key, schema)
51
+ return schema
52
+ }
53
+
54
+ function baseFieldsOf(scope, nsName, typeName) {
55
+ const type = builtinSchema(scope, nsName).types.get(`@${nsName}/${typeName}`)
56
+ if (!type) throw new Error(`Built-in type '${typeName}' not found in cero schema`)
57
+ return type.toJSON().fields
58
+ }
59
+
60
+ // Cache of dispatch names cero registers per scope. Runs cero's extender
61
+ // against a stub namespace that just captures register() calls.
62
+ const dispatchCache = new Map()
63
+ function ceroDispatches(scope) {
64
+ if (dispatchCache.has(scope)) return dispatchCache.get(scope)
65
+ const captured = new Set()
66
+ const stubNs = {
67
+ name: scope === SCOPE_SHARED ? 'room' : 'identity',
68
+ register: (desc) => captured.add(desc.name)
69
+ }
70
+ if (scope === SCOPE_SHARED) {
71
+ extendRoomDispatch(null, stubNs)
72
+ } else {
73
+ extendIdentityDispatch(null, stubNs)
74
+ }
75
+ dispatchCache.set(scope, captured)
76
+ return captured
77
+ }
78
+
79
+ // Does cero have an `add-${type}` dispatch for this built-in?
80
+ function ceroHasAdd(scope, typeName) {
81
+ return ceroDispatches(scope).has(`add-${typeName}`)
82
+ }
83
+
4
84
  /**
5
85
  * Type helpers for defining schema fields
6
86
  * Usage: { name: t.string, count: t.int, active: t.bool }
@@ -45,9 +125,32 @@ t.schema = defineSchema
45
125
 
46
126
  export function defineSchema(collections, opts = {}) {
47
127
  const entries = Object.entries(collections)
48
- const types = new Map(entries.map(([name]) => [name, singular(name)]))
49
128
 
50
- const getScope = ([, def]) => def.scope || SCOPE_PRIVATE
129
+ // Validate built-in entries: user can't override fixed metadata (key, scope, timestamps).
130
+ for (const [name, def] of entries) {
131
+ if (!BUILTINS[name]) continue
132
+ if (def.key !== undefined) {
133
+ throw new Error(`'${name}' is a built-in; key is fixed and cannot be set`)
134
+ }
135
+ if (def.scope !== undefined && def.scope !== BUILTINS[name].scope) {
136
+ throw new Error(
137
+ `'${name}' is a built-in with scope '${BUILTINS[name].scope}'; cannot override`
138
+ )
139
+ }
140
+ if (def.timestamps !== undefined) {
141
+ throw new Error(`'${name}' is a built-in; timestamps is fixed and cannot be set`)
142
+ }
143
+ }
144
+
145
+ // Type name resolution: built-ins use cero's singular type name, user collections use singular(plural).
146
+ const types = new Map([
147
+ // Pre-populate with built-ins so they resolve even when user didn't declare them.
148
+ ...Object.entries(BUILTINS).map(([name, info]) => [name, info.type]),
149
+ ...entries.map(([name]) => [name, BUILTINS[name]?.type || singular(name)])
150
+ ])
151
+
152
+ const getScope = ([name, def]) =>
153
+ BUILTINS[name] ? BUILTINS[name].scope : def.scope || SCOPE_PRIVATE
51
154
  const localEntries = entries.filter((e) => getScope(e) === SCOPE_LOCAL)
52
155
  const privateEntries = entries.filter((e) => getScope(e) === SCOPE_PRIVATE)
53
156
  const sharedEntries = entries.filter((e) => getScope(e) === SCOPE_SHARED)
@@ -60,10 +163,76 @@ export function defineSchema(collections, opts = {}) {
60
163
  types,
61
164
 
62
165
  getScope(collection) {
166
+ if (BUILTINS[collection]) return BUILTINS[collection].scope
63
167
  const def = collections[collection]
64
168
  return def ? def.scope || SCOPE_PRIVATE : null
65
169
  },
66
170
 
171
+ isBuiltin(collection) {
172
+ return !!BUILTINS[collection]
173
+ },
174
+
175
+ /**
176
+ * Does Collection.put follow the add/set merge path for this collection?
177
+ *
178
+ * - User-defined: always yes. Non-local user collections get auto-generated
179
+ * `add-${type}` dispatches; local collections take the merge path even
180
+ * without dispatches (they write straight to the local store).
181
+ * - Built-ins: derived from cero's actual dispatch table — `false` for
182
+ * collections cero exposes only via `set-${type}` (e.g. profile, settings).
183
+ */
184
+ hasAdd(collection) {
185
+ const builtin = BUILTINS[collection]
186
+ if (builtin) return ceroHasAdd(builtin.scope, builtin.type)
187
+ return !!collections[collection]
188
+ },
189
+
190
+ keysOf(collection) {
191
+ if (BUILTINS[collection]) return BUILTINS[collection].key
192
+ return collections[collection]?.key || ['id']
193
+ },
194
+
195
+ isLocalBuiltin(collection) {
196
+ return !!LOCAL_BUILTINS[collection]
197
+ },
198
+
199
+ /**
200
+ * Resolve a name to its local-scope type. Returns null if the name doesn't
201
+ * map to a local collection (cero built-in or user-declared `scope: 'local'`).
202
+ */
203
+ localTypeOf(collection) {
204
+ if (LOCAL_BUILTINS[collection]) return LOCAL_BUILTINS[collection].type
205
+ const def = collections[collection]
206
+ if (def?.scope === SCOPE_LOCAL) return singular(collection)
207
+ return null
208
+ },
209
+
210
+ localKeysOf(collection) {
211
+ if (LOCAL_BUILTINS[collection]) return LOCAL_BUILTINS[collection].key
212
+ const def = collections[collection]
213
+ if (def?.scope === SCOPE_LOCAL) return def.key || ['id']
214
+ return null
215
+ },
216
+
217
+ /**
218
+ * Resolve a local collection name to its hyperdb spec + path. Returns null
219
+ * if `name` isn't a local collection. Local built-ins don't have input-X
220
+ * types, so the prefix is ignored for them.
221
+ */
222
+ localResolve(collection, prefix) {
223
+ const ns = scopeNs(SCOPE_LOCAL)
224
+ if (LOCAL_BUILTINS[collection]) {
225
+ return { spec: ns, path: `@${ns}/${LOCAL_BUILTINS[collection].type}` }
226
+ }
227
+ const def = collections[collection]
228
+ if (def?.scope === SCOPE_LOCAL) {
229
+ const type = singular(collection)
230
+ const name = prefix ? `${prefix}-${type}` : type
231
+ return { spec: ns, path: `@${ns}/${name}` }
232
+ }
233
+ return null
234
+ },
235
+
67
236
  isLocal(collection) {
68
237
  return isLocal(this.getScope(collection))
69
238
  },
@@ -131,6 +300,51 @@ export function defineSchema(collections, opts = {}) {
131
300
  const local = scope === SCOPE_LOCAL
132
301
  const ns = nsName || scopeNs(scope)
133
302
  for (const [name, def] of items) {
303
+ if (BUILTINS[name]) {
304
+ // Built-in extension: re-register the type with extra fields appended.
305
+ // cero's later registration is idempotent and skips when the type already exists.
306
+ const typeName = BUILTINS[name].type
307
+ const baseFields = baseFieldsOf(scope, ns, typeName)
308
+ const userFields = toFields(def.fields || {}).map((f) => ({ ...f, required: false }))
309
+
310
+ const baseNames = new Set(baseFields.map((f) => f.name))
311
+ for (const uf of userFields) {
312
+ if (baseNames.has(uf.name)) {
313
+ throw new Error(
314
+ `'${uf.name}' is a base field of '${typeName}' and cannot be redeclared`
315
+ )
316
+ }
317
+ }
318
+
319
+ const allFields = [...baseFields, ...userFields]
320
+ register.type({ name: typeName, compact: false, fields: allFields })
321
+ register.type({
322
+ name: `input-${typeName}`,
323
+ compact: false,
324
+ fields: allFields.map((f) => ({ ...f, required: false }))
325
+ })
326
+
327
+ if (def.indexes) {
328
+ for (const [idx, idxFields] of Object.entries(def.indexes)) {
329
+ register.index({
330
+ name: `${name}-${idx}`,
331
+ collection: `@${ns}/${name}`,
332
+ key: idxFields
333
+ })
334
+ }
335
+ }
336
+
337
+ // Register custom dispatches for user-supplied handlers.
338
+ // Use input-${type} (all-optional fields) so callers can pass partial
339
+ // payloads like { id } without re-supplying every base field.
340
+ if (def.handlers) {
341
+ for (const op of Object.keys(def.handlers)) {
342
+ register.dispatch({ name: op, requestType: `@${ns}/input-${typeName}` })
343
+ }
344
+ }
345
+ continue
346
+ }
347
+
134
348
  const type = types.get(name)
135
349
  const fields = schemaFields(def)
136
350
  const typePath = `@${ns}/${type}`
@@ -157,6 +371,29 @@ export function defineSchema(collections, opts = {}) {
157
371
  register.dispatch({ name: `set-${type}`, requestType: typePath })
158
372
  register.dispatch({ name: `del-${type}`, requestType: `@${ns}/del-${type}` })
159
373
  }
374
+
375
+ // Register custom dispatches for user-supplied handlers.
376
+ // Use input-${type} so callers can pass partial payloads.
377
+ if (!local && def.handlers) {
378
+ for (const op of Object.keys(def.handlers)) {
379
+ register.dispatch({ name: op, requestType: `@${ns}/input-${type}` })
380
+ }
381
+ }
382
+ }
383
+
384
+ // Register input-${type} for implicit built-ins (not in user schema)
385
+ // so the RPC bridge can encode partial payloads for Collection writes.
386
+ if (!local) {
387
+ for (const [name, info] of Object.entries(BUILTINS)) {
388
+ if (info.scope !== scope) continue
389
+ if (collections[name]) continue // already handled by built-in branch above
390
+ const baseFields = baseFieldsOf(info.scope, ns, info.type)
391
+ register.type({
392
+ name: `input-${info.type}`,
393
+ compact: false,
394
+ fields: baseFields.map((f) => ({ ...f, required: false }))
395
+ })
396
+ }
160
397
  }
161
398
  },
162
399
 
@@ -165,6 +402,16 @@ export function defineSchema(collections, opts = {}) {
165
402
  const handlers = {}
166
403
  const p = (name) => `@${nsFor(scope)}/${name}`
167
404
  for (const [name, def] of items) {
405
+ if (BUILTINS[name]) {
406
+ // Built-in extension: cero already owns add/set/del — only register custom handlers.
407
+ if (def.handlers) {
408
+ for (const [op, handler] of Object.entries(def.handlers)) {
409
+ handlers[p(op)] = handler
410
+ }
411
+ }
412
+ continue
413
+ }
414
+
168
415
  const type = types.get(name)
169
416
  const path = p(name)
170
417
  const keys = def.key || ['id']