@cero-base/cero 0.5.2 → 0.7.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
@@ -173,6 +173,97 @@ await cero.open(me.room, { id: existingRoomId }) // load an existing room by id
173
173
 
174
174
  Returns the child handle, ready to operate on (`cero.put(joined.messages, …)` etc.).
175
175
 
176
+ ### `cero.before(ref, fn)` / `cero.after(ref, fn)`
177
+
178
+ 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, or ignore it for a subscription that should live as long as the handle (it's dropped when the handle closes).
179
+
180
+ ```js
181
+ const off = cero.before(room.messages, (ctx) => {
182
+ if (!ctx.row.text?.trim()) return false // reject empty messages
183
+ })
184
+
185
+ cero.after(me.profile, ({ row }) => {
186
+ /* react to your profile changing */
187
+ })
188
+ ```
189
+
190
+ `ctx` is `{ op, name, row }` (plus `result` in `after`).
191
+
192
+ ## Extensions
193
+
194
+ 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.
195
+
196
+ ```js
197
+ import { profileSync } from '@cero-base/cero/extensions'
198
+
199
+ cero.use(profileSync()) // before build() (for the schema) and before cero() (for the behavior)
200
+ await build('./spec', schema)
201
+ ```
202
+
203
+ An extension is two optional parts:
204
+
205
+ ```js
206
+ function myExtension() {
207
+ return {
208
+ schema: { members: cero.t.extend({ status: cero.t.string }) }, // merged into your schema
209
+ setup(me) {
210
+ me.on('handle', (room, opts) => {
211
+ /* opts carries the open args (e.g. opts.name on create); me.children is the set of open rooms */
212
+ })
213
+ }
214
+ }
215
+ }
216
+ ```
217
+
218
+ For a behavior-only extension (no schema), pass a function directly — it's shorthand for `{ setup }`:
219
+
220
+ ```js
221
+ cero.use((me) => {
222
+ me.on('handle', (room) => {
223
+ /* … */
224
+ })
225
+ })
226
+ ```
227
+
228
+ 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).
229
+
230
+ ### Cleanup
231
+
232
+ `setup(me)` may return a disposer; cero calls it on `me.close()`. You rarely need it — `before`/`after` and `me.on(...)` registered on `me` or its rooms are torn down automatically when the handle closes. Return a disposer **only** for things the handle's close won't clean up, like a timer or an external connection:
233
+
234
+ ```js
235
+ setup(me) {
236
+ const timer = setInterval(() => ping(me), 30_000)
237
+ return () => clearInterval(timer)
238
+ }
239
+ ```
240
+
241
+ ### `profileSync` (bundled)
242
+
243
+ 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`:
244
+
245
+ ```js
246
+ cero.use(profileSync())
247
+ cero.use(profileSync({ fields: { status: cero.t.string } })) // sync extra fields
248
+ ```
249
+
250
+ 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.
251
+
252
+ ### `handleSync` (bundled)
253
+
254
+ 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.
255
+
256
+ ```js
257
+ cero.use(handleSync())
258
+
259
+ // your handle type declares a `profile`; your app sets it:
260
+ const room = await cero.open(me.room)
261
+ await cero.set(room.profile, { name: 'general', avatar: 'pic.png' })
262
+ // → the me.handles row now carries name + avatar — render the room list, no opens
263
+ ```
264
+
265
+ Requires your handle types to declare a `profile` single; handles without one are left untouched.
266
+
176
267
  ## RPC
177
268
 
178
269
  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 +354,13 @@ const remote = await connect(c, spec)
263
354
 
264
355
  ## Exports
265
356
 
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 |
357
+ | Path | What you get |
358
+ | ---------------------------- | ------------------------------------------------------------------------ |
359
+ | `@cero-base/cero` | factory + operators + schema DSL |
360
+ | `@cero-base/cero/client` | `connect(ipc, spec)` — talk to a cero running in another process |
361
+ | `@cero-base/cero/server` | `serve(ipc, { storage, spec })` — run + expose a cero over an IPC stream |
362
+ | `@cero-base/cero/build` | `build(specDir, schema)` — generate the on-disk spec |
363
+ | `@cero-base/cero/extensions` | bundled extensions (`profileSync`, …) |
272
364
 
273
365
  ## Tests
274
366
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cero-base/cero",
3
- "version": "0.5.2",
3
+ "version": "0.7.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",
@@ -20,8 +20,8 @@
20
20
  "default": "./src/index.js"
21
21
  },
22
22
  "./build": {
23
- "types": "./types/builder.d.ts",
24
- "default": "./src/builder.js"
23
+ "types": "./types/build/index.d.ts",
24
+ "default": "./src/build/index.js"
25
25
  },
26
26
  "./server": {
27
27
  "types": "./types/rpc/server.d.ts",
@@ -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.5.2",
77
+ "@cero-base/core": "^0.7.0",
71
78
  "b4a": "^1.8.1",
72
79
  "bare-crypto": "^1.13.7",
73
80
  "bare-fs": "^4.7.1",
@@ -0,0 +1,137 @@
1
+ // Build wiring for the builtin schemas (schemas.js): turns the grouped field
2
+ // maps into the type / collection / dispatch / command descriptors the builder
3
+ // registers. Every cero schema includes these; app refs are added on top.
4
+ import { CeroError } from '@cero-base/core/errors'
5
+ import { COUNTERS, DB_TYPE } from '../lib/constants.js'
6
+ import * as schemas from './schemas.js'
7
+
8
+ // Builtin collections by scope — ref name → { type, kind? }. kind defaults to
9
+ // 'collection' (id-keyed); 'single' has no key. Dispatches are derived for
10
+ // main; counters is internal (a collection with no add/set/del).
11
+ export const refs = {
12
+ main: {
13
+ members: { type: 'member' },
14
+ devices: { type: 'device' },
15
+ invites: { type: 'invite' },
16
+ handles: { type: 'handle' }
17
+ },
18
+ local: {
19
+ master: { type: 'master', kind: 'single' },
20
+ keypair: { type: 'keypair', kind: 'single' },
21
+ 'handle-keypairs': { type: 'handle-keypair' }
22
+ }
23
+ }
24
+
25
+ export const hyperdbType = (prim) => DB_TYPE[prim] || 'string'
26
+
27
+ const at = (ns, n) => `@${ns}/${n}`
28
+ const keyOf = (def) => (def.kind === 'single' ? [] : ['id'])
29
+
30
+ const fields = (map) =>
31
+ Object.entries(map).map(([name, m]) => ({
32
+ name,
33
+ type: hyperdbType(m.prim),
34
+ required: m.required === true
35
+ }))
36
+
37
+ // Merge app `t.extend` fields into a builtin's base fields — base can't be redeclared.
38
+ const merge = (type, base, extra) => {
39
+ if (!extra) return base
40
+ for (const k in extra)
41
+ if (k in base)
42
+ throw CeroError.INVALID(`'${k}' is a base field of '${type}' and cannot be redeclared`)
43
+ return { ...base, ...extra }
44
+ }
45
+
46
+ const descriptors = (group, extend = {}) =>
47
+ Object.entries(group).map(([name, base]) => ({
48
+ name,
49
+ compact: false,
50
+ fields: fields(merge(name, base, extend[name]))
51
+ }))
52
+
53
+ // meta.refs entries for a scope's builtins.
54
+ export const builtinRefs = (ns, scope) =>
55
+ Object.fromEntries(
56
+ Object.entries(refs[scope]).map(([name, def]) => [
57
+ name,
58
+ {
59
+ kind: def.kind || 'collection',
60
+ path: [name],
61
+ builtin: true,
62
+ ...(scope === 'main' && { verb: def.type }),
63
+ schema: at(ns, def.type)
64
+ }
65
+ ])
66
+ )
67
+
68
+ // hyperschema type descriptors. scope is 'main' | 'local' | 'rpc'; `extend`
69
+ // merges app `t.extend` fields into the matching type.
70
+ export const builtinTypes = (scope, extend) => descriptors(schemas[scope], extend)
71
+ export const rpcTypes = () => descriptors(schemas.rpc)
72
+
73
+ // hyperdb collection descriptors for a scope.
74
+ export const builtinCollections = (ns, scope) => {
75
+ const out = Object.entries(refs[scope]).map(([name, def]) => ({
76
+ name,
77
+ schema: at(ns, def.type),
78
+ key: keyOf(def)
79
+ }))
80
+ if (scope === 'main') out.push({ name: COUNTERS, schema: at(ns, 'counter'), key: ['name'] })
81
+ return out
82
+ }
83
+
84
+ // hyperdispatch descriptors (main scope only).
85
+ export const builtinDispatches = (ns) => [
86
+ { name: 'add-writer', requestType: at(ns, 'writer') },
87
+ { name: 'del-writer', requestType: at(ns, 'writer') },
88
+ { name: 'claim-writer', requestType: at(ns, 'claim') },
89
+ ...Object.values(refs.main).flatMap(({ type }) => [
90
+ { name: `add-${type}`, requestType: at(ns, type) },
91
+ { name: `set-${type}`, requestType: at(ns, type) },
92
+ { name: `del-${type}`, requestType: at(ns, 'del-by-id') }
93
+ ])
94
+ ]
95
+
96
+ export const rpcCommands = (ns) => {
97
+ const ref = (n) => at(ns, n)
98
+ return [
99
+ { name: 'init', request: { name: ref('req-empty') }, response: { name: ref('res-identity') } },
100
+ {
101
+ name: 'restore',
102
+ request: { name: ref('req-restore') },
103
+ response: { name: ref('res-identity') }
104
+ },
105
+ { name: 'add-row', request: { name: ref('req-row') }, response: { name: ref('res-data') } },
106
+ {
107
+ name: 'add-handle',
108
+ request: { name: ref('req-row') },
109
+ response: { name: ref('res-handle') }
110
+ },
111
+ { name: 'set', request: { name: ref('req-row') }, response: { name: ref('res-data') } },
112
+ { name: 'get', request: { name: ref('req-query') }, response: { name: ref('res-rows') } },
113
+ { name: 'get-one', request: { name: ref('req-id') }, response: { name: ref('res-data') } },
114
+ { name: 'del', request: { name: ref('req-id') }, response: { name: ref('res-ok') } },
115
+ { name: 'count', request: { name: ref('req-query') }, response: { name: ref('res-count') } },
116
+ {
117
+ name: 'watch',
118
+ request: { name: ref('req-query') },
119
+ response: { name: ref('res-rows'), stream: true }
120
+ },
121
+ { name: 'call', request: { name: ref('req-call') }, response: { name: ref('res-data') } },
122
+ { name: 'invite', request: { name: ref('req-invite') }, response: { name: ref('res-invite') } },
123
+ { name: 'revoke', request: { name: ref('req-revoke') }, response: { name: ref('res-ok') } },
124
+ { name: 'join', request: { name: ref('req-join') }, response: { name: ref('res-handle') } },
125
+ {
126
+ name: 'open-handle',
127
+ request: { name: ref('req-open') },
128
+ response: { name: ref('res-handle') }
129
+ },
130
+ {
131
+ name: 'close-handle',
132
+ request: { name: ref('req-handle') },
133
+ response: { name: ref('res-ok') }
134
+ },
135
+ { name: 'leave', request: { name: ref('req-handle') }, response: { name: ref('res-ok') } }
136
+ ]
137
+ }
@@ -8,20 +8,18 @@ import HRPCBuilder from 'hrpc'
8
8
 
9
9
  import { CeroError } from '@cero-base/core/errors'
10
10
 
11
+ import { NS } from '../lib/constants.js'
12
+ import { internal } from '../lib/internal.js'
11
13
  import {
12
- BUILTINS,
13
- LOCAL_BUILTINS,
14
- hyperdbType,
14
+ refs,
15
+ builtinRefs,
15
16
  builtinTypes,
16
17
  builtinCollections,
17
18
  builtinDispatches,
18
- localBuiltinTypes,
19
- localBuiltinCollections,
20
19
  rpcTypes,
21
- rpcCommands
22
- } from './lib/builtins.js'
23
-
24
- const NS = 'cero'
20
+ rpcCommands,
21
+ hyperdbType
22
+ } from './builtins.js'
25
23
 
26
24
  /**
27
25
  * @typedef {import('@cero-base/core/schema').Schema} Schema
@@ -45,22 +43,38 @@ const NS = 'cero'
45
43
  * @returns {Promise<void>}
46
44
  */
47
45
  export async function build(specDir, schema, { ns = NS } = {}) {
48
- const defs = /** @type {SchemaDefs & { local?: SchemaDefs }} */ (schema?.defs || schema)
49
- if (!defs || typeof defs !== 'object') throw CeroError.REQUIRED('schema')
46
+ const raw = /** @type {SchemaDefs & { local?: SchemaDefs }} */ (schema?.defs || schema)
47
+ if (!raw || typeof raw !== 'object') throw CeroError.REQUIRED('schema')
48
+
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.
51
+ const extend = {}
52
+ const defs = {}
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
+ }
62
+ }
63
+ }
64
+ collect(raw)
65
+ for (const ext of internal.extensions) if (ext.schema) collect(ext.schema, true)
50
66
 
51
- const main = compile(splitMain(defs), ns, BUILTINS)
52
- const local = defs.local
53
- ? compile(defs.local, ns, LOCAL_BUILTINS)
54
- : compile({}, ns, LOCAL_BUILTINS)
67
+ const main = compile(splitMain(defs), ns, 'main')
68
+ const local = compile(defs.local || {}, ns, 'local')
55
69
  const handles = {}
56
70
  for (const [name, child] of Object.entries(splitHandles(defs))) {
57
- handles[name] = compile(child, ns)
71
+ handles[name] = compile(child, ns, 'main')
58
72
  }
59
73
 
60
- emitMain(join(specDir, 'main'), ns, main, { rpc: true })
74
+ emitMain(join(specDir, 'main'), ns, main, { rpc: true, extend })
61
75
  emitLocal(join(specDir, 'local'), ns, local)
62
76
  for (const [name, handle] of Object.entries(handles)) {
63
- emitMain(join(specDir, 'handles', name), ns, handle, { rpc: false })
77
+ emitMain(join(specDir, 'handles', name), ns, handle, { rpc: false, extend })
64
78
  }
65
79
 
66
80
  const meta = {
@@ -105,18 +119,10 @@ function isPlainHandle(v) {
105
119
  return v && typeof v === 'object' && !v.kind && !v.prim
106
120
  }
107
121
 
108
- function compile(root, ns, builtins = BUILTINS) {
122
+ function compile(root, ns, scope = 'main') {
109
123
  const ctx = { types: [], collections: [], dispatches: [], meta: { ns, refs: {} }, ns }
110
124
 
111
- for (const b of builtins) {
112
- ctx.meta.refs[b.name] = {
113
- kind: b.kind || 'collection',
114
- path: [b.name],
115
- builtin: true,
116
- verb: b.verb,
117
- schema: `@${ns}/${b.type}`
118
- }
119
- }
125
+ Object.assign(ctx.meta.refs, builtinRefs(ns, scope))
120
126
 
121
127
  for (const [name, node] of Object.entries(root)) {
122
128
  if (node.kind === 'handle') {
@@ -174,11 +180,11 @@ function fieldsFor(fields) {
174
180
  return Object.entries(fields).map(([name, marker]) => ({
175
181
  name,
176
182
  type: hyperdbType(marker.prim),
177
- required: false
183
+ required: marker.required === true
178
184
  }))
179
185
  }
180
186
 
181
- function emitMain(dir, ns, { types, collections, dispatches }, { rpc }) {
187
+ function emitMain(dir, ns, { types, collections, dispatches }, { rpc, extend = {} }) {
182
188
  const schemaDir = join(dir, 'schema')
183
189
  const dbDir = join(dir, 'db')
184
190
  const dispatchDir = join(dir, 'dispatch')
@@ -186,14 +192,14 @@ function emitMain(dir, ns, { types, collections, dispatches }, { rpc }) {
186
192
 
187
193
  const s = Hyperschema.from(schemaDir)
188
194
  const sns = s.namespace(ns)
189
- for (const desc of builtinTypes()) sns.register(desc)
195
+ for (const desc of builtinTypes('main', extend)) sns.register(desc)
190
196
  for (const desc of types) sns.register(desc)
191
197
  if (rpc) for (const desc of rpcTypes()) sns.register(desc)
192
198
  Hyperschema.toDisk(s, schemaDir, { esm: true })
193
199
 
194
200
  const db = HyperdbBuilder.from(schemaDir, dbDir)
195
201
  const dns = db.namespace(ns)
196
- for (const desc of builtinCollections(ns)) dns.collections.register(desc)
202
+ for (const desc of builtinCollections(ns, 'main')) dns.collections.register(desc)
197
203
  for (const desc of collections) dns.collections.register(desc)
198
204
  HyperdbBuilder.toDisk(db, dbDir, { esm: true })
199
205
 
@@ -217,13 +223,13 @@ function emitLocal(dir, ns, { types, collections }) {
217
223
 
218
224
  const s = Hyperschema.from(schemaDir)
219
225
  const sns = s.namespace(ns)
220
- for (const desc of localBuiltinTypes()) sns.register(desc)
226
+ for (const desc of builtinTypes('local')) sns.register(desc)
221
227
  for (const desc of types) sns.register(desc)
222
228
  Hyperschema.toDisk(s, schemaDir, { esm: true })
223
229
 
224
230
  const db = HyperdbBuilder.from(schemaDir, dbDir)
225
231
  const dns = db.namespace(ns)
226
- for (const desc of localBuiltinCollections(ns)) dns.collections.register(desc)
232
+ for (const desc of builtinCollections(ns, 'local')) dns.collections.register(desc)
227
233
  for (const desc of collections) dns.collections.register(desc)
228
234
  HyperdbBuilder.toDisk(db, dbDir, { esm: true })
229
235
  }
@@ -0,0 +1,162 @@
1
+ // Builtin type schemas grouped by scope, in the schema DSL — kept separate from
2
+ // the build wiring (builtins.js) so apps can extend them with `t.extend`.
3
+ // Field order is the wire order; do not reorder.
4
+ import { t } from '../lib/spec.js'
5
+
6
+ const { string, bytes, int, uint, bool, required } = t
7
+
8
+ export const main = {
9
+ 'del-by-id': {
10
+ id: required(string)
11
+ },
12
+ writer: {
13
+ master: required(bytes),
14
+ writer: required(bytes),
15
+ sig: required(bytes),
16
+ isIndexer: bool
17
+ },
18
+ counter: {
19
+ name: required(string),
20
+ value: required(uint)
21
+ },
22
+ member: {
23
+ id: required(string),
24
+ key: required(bytes),
25
+ role: required(string),
26
+ name: string,
27
+ createdAt: int,
28
+ updatedAt: int,
29
+ sig: bytes,
30
+ index: uint
31
+ },
32
+ device: {
33
+ id: required(string),
34
+ memberId: string,
35
+ name: string,
36
+ isMobile: bool,
37
+ createdAt: int,
38
+ updatedAt: int,
39
+ index: uint
40
+ },
41
+ invite: {
42
+ id: required(string),
43
+ invite: required(bytes),
44
+ publicKey: required(bytes),
45
+ data: bytes,
46
+ sig: bytes,
47
+ role: required(string),
48
+ expires: int,
49
+ createdAt: int,
50
+ index: uint
51
+ },
52
+ handle: {
53
+ id: required(string),
54
+ type: required(string),
55
+ key: required(bytes),
56
+ encryptionKey: bytes,
57
+ name: string,
58
+ createdAt: int,
59
+ updatedAt: int,
60
+ index: uint
61
+ },
62
+ claim: {
63
+ identity: required(bytes),
64
+ writer: required(bytes),
65
+ sig: required(bytes)
66
+ }
67
+ }
68
+
69
+ export const local = {
70
+ master: {
71
+ seed: required(bytes)
72
+ },
73
+ keypair: {
74
+ publicKey: required(bytes),
75
+ secretKey: required(bytes)
76
+ },
77
+ 'handle-keypair': {
78
+ id: required(string),
79
+ publicKey: required(bytes),
80
+ secretKey: required(bytes),
81
+ encryptionKey: bytes
82
+ }
83
+ }
84
+
85
+ export const rpc = {
86
+ 'req-empty': {
87
+ ok: bool
88
+ },
89
+ 'req-restore': {
90
+ phrase: required(string)
91
+ },
92
+ 'req-row': {
93
+ handle: required(string),
94
+ ref: required(string),
95
+ data: required(bytes),
96
+ local: bool
97
+ },
98
+ 'req-id': {
99
+ handle: required(string),
100
+ ref: required(string),
101
+ id: required(string),
102
+ local: bool
103
+ },
104
+ 'req-query': {
105
+ handle: required(string),
106
+ ref: required(string),
107
+ query: bytes,
108
+ local: bool
109
+ },
110
+ 'req-call': {
111
+ handle: required(string),
112
+ op: required(string),
113
+ data: bytes
114
+ },
115
+ 'req-invite': {
116
+ handle: required(string),
117
+ role: string
118
+ },
119
+ 'req-revoke': {
120
+ handle: required(string),
121
+ invite: required(string)
122
+ },
123
+ 'req-join': {
124
+ parent: required(string),
125
+ ref: required(string),
126
+ invite: required(string)
127
+ },
128
+ 'req-open': {
129
+ parent: required(string),
130
+ row: required(string)
131
+ },
132
+ 'req-handle': {
133
+ handle: required(string)
134
+ },
135
+ 'res-data': {
136
+ data: bytes
137
+ },
138
+ 'res-rows': {
139
+ data: required(bytes),
140
+ total: required(int),
141
+ size: required(int)
142
+ },
143
+ 'res-count': {
144
+ count: required(int)
145
+ },
146
+ 'res-invite': {
147
+ invite: required(string)
148
+ },
149
+ 'res-handle': {
150
+ id: required(string),
151
+ type: required(string),
152
+ name: string
153
+ },
154
+ 'res-identity': {
155
+ id: required(string),
156
+ deviceId: string,
157
+ phrase: string
158
+ },
159
+ 'res-ok': {
160
+ ok: bool
161
+ }
162
+ }
@@ -0,0 +1,32 @@
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
+ me.on('handle', (child, opts) => {
20
+ if (!child.profile) return
21
+ if (opts.name) set(child.profile, { name: opts.name })
22
+ const sub = watch(child.profile)
23
+ sub.on('data', ({ data }) => {
24
+ if (!data?.name) return
25
+ child.name = data.name
26
+ set(me.handles, { id: child.id, ...data }, { upsert: false })
27
+ })
28
+ child.once('close', () => sub.destroy())
29
+ })
30
+ }
31
+ }
32
+ }
@@ -0,0 +1,2 @@
1
+ export { profileSync } from './profile-sync.js'
2
+ export { handleSync } from './handle-sync.js'
@@ -0,0 +1,30 @@
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 room. Declares a
6
+ * `profile` single (`name`, `avatar`, plus any extra `fields`) and mirrors them
7
+ * onto the `member` builtin, then publishes when you open/join a room and whenever
8
+ * your profile changes. An app may declare its own richer `profile` instead —
9
+ * 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 = async (room) => {
21
+ const { data: member } = await get(room.members, me.identity.id)
22
+ if (!member) return
23
+ const { data: profile } = await get(me.profile)
24
+ if (profile) await set(room.members, { id: me.identity.id, ...profile })
25
+ }
26
+ me.on('handle', publish)
27
+ after(me.profile, () => me.children.forEach(publish))
28
+ }
29
+ }
30
+ }