@cero-base/cero 1.19.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +38 -643
  2. package/package.json +9 -6
  3. package/src/build/index.js +63 -61
  4. package/src/build/internal.js +123 -0
  5. package/src/build/schemas.js +6 -11
  6. package/src/extensions/handle-sync.js +3 -11
  7. package/src/extensions/index.js +86 -0
  8. package/src/extensions/profile-sync.js +15 -16
  9. package/src/handle/index.js +290 -284
  10. package/src/index.js +24 -81
  11. package/src/lib/bluetooth.js +25 -56
  12. package/src/lib/constants.js +0 -15
  13. package/src/lib/operators.js +35 -134
  14. package/src/lib/peek.js +4 -8
  15. package/src/lib/refs.js +10 -8
  16. package/src/lib/spec.js +2 -3
  17. package/src/local/index.js +2 -3
  18. package/src/rpc/client.js +54 -77
  19. package/src/rpc/index.js +3 -3
  20. package/src/rpc/server.js +40 -43
  21. package/types/build/index.d.ts +11 -9
  22. package/types/build/{builtins.d.ts → internal.d.ts} +20 -38
  23. package/types/build/schemas.d.ts +4 -3
  24. package/types/extensions/handle-sync.d.ts +2 -8
  25. package/types/extensions/index.d.ts +102 -0
  26. package/types/extensions/profile-sync.d.ts +0 -5
  27. package/types/handle/index.d.ts +90 -108
  28. package/types/index.d.ts +21 -17
  29. package/types/lib/bluetooth.d.ts +8 -32
  30. package/types/lib/constants.d.ts +0 -11
  31. package/types/lib/operators.d.ts +30 -83
  32. package/types/lib/peek.d.ts +2 -3
  33. package/types/lib/refs.d.ts +5 -5
  34. package/types/lib/spec.d.ts +2 -3
  35. package/types/local/index.d.ts +2 -3
  36. package/types/rpc/client.d.ts +29 -26
  37. package/types/rpc/index.d.ts +3 -3
  38. package/types/rpc/server.d.ts +7 -10
  39. package/src/build/builtins.js +0 -174
  40. package/src/lib/internal.js +0 -9
  41. package/types/lib/internal.d.ts +0 -24
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@cero-base/cero",
3
- "version": "1.19.0",
3
+ "version": "2.1.0",
4
4
  "description": "The ideal p2p API — everything is a handle, handles contain refs, refs contain rows.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
7
7
  "engines": {
8
- "node": ">=20"
8
+ "node": ">=22"
9
9
  },
10
10
  "repository": {
11
11
  "type": "git",
@@ -18,8 +18,7 @@
18
18
  "src",
19
19
  "types",
20
20
  "README.md",
21
- "LICENSE",
22
- "!**/CLAUDE.md"
21
+ "LICENSE"
23
22
  ],
24
23
  "publishConfig": {
25
24
  "access": "public"
@@ -82,6 +81,10 @@
82
81
  "events": {
83
82
  "bare": "bare-events",
84
83
  "default": "events"
84
+ },
85
+ "process": {
86
+ "bare": "bare-process",
87
+ "default": "process"
85
88
  }
86
89
  },
87
90
  "scripts": {
@@ -91,11 +94,11 @@
91
94
  "prepublishOnly": "npm run build:types",
92
95
  "test": "npm run test:bare",
93
96
  "pretest:bare": "npm run build:test",
94
- "test:bare": "find test -name '*.test.js' | sort | xargs -P1 -n1 brittle-bare test/bare.js",
97
+ "test:bare": "find test -name '*.test.js' | sort | xargs -P1 -n1 brittle-bare",
95
98
  "test:node": "find test -name '*.test.js' | sort | xargs -P1 -n1 brittle-node"
96
99
  },
97
100
  "dependencies": {
98
- "@cero-base/core": "^1.19.0",
101
+ "@cero-base/core": "^2.1.0",
99
102
  "b4a": "^1.8.1",
100
103
  "bare-abort-controller": "^1.1.2",
101
104
  "bare-crypto": "^1.15.3",
@@ -1,5 +1,6 @@
1
- import { join } from 'path'
1
+ import { join, resolve } from 'path'
2
2
  import { promises as fs } from 'fs'
3
+ import { pathToFileURL } from 'url'
3
4
 
4
5
  import Hyperschema from 'hyperschema'
5
6
  import HyperdbBuilder from 'hyperdb/builder'
@@ -9,18 +10,8 @@ import HRPCBuilder from 'hrpc'
9
10
  import { CeroError } from '@cero-base/core/errors'
10
11
 
11
12
  import { NS } from '../lib/constants.js'
12
- import { internal } from '../lib/internal.js'
13
- import {
14
- refs,
15
- builtinRefs,
16
- builtinTypes,
17
- builtinCollections,
18
- builtinDispatches,
19
- rotateDispatch,
20
- rpcTypes,
21
- rpcCommands,
22
- getHyperdbType
23
- } from './builtins.js'
13
+ import { extensionsOf } from '../extensions/index.js'
14
+ import * as internal from './internal.js'
24
15
 
25
16
  /**
26
17
  * @typedef {import('@cero-base/core/schema').Schema} Schema
@@ -29,47 +20,46 @@ import {
29
20
  *
30
21
  * @typedef {object} BuildOpts
31
22
  * @property {string} [ns] Namespace prefix for emitted schema ids. Defaults to `'cero'`.
23
+ * @property {string | import('../extensions/index.js').Extension[]} [extensions] The extensions to fold in. A module specifier, relative to `specDir`, is imported here for its `extensions` export and written into the spec, so every process runs the same list. A list is folded in only. The bundled two by default, `[]` for none.
24
+ * @property {string} [operators] A module specifier, relative to `specDir`, written into the spec for its `operators` export, so every process binds the same map.
32
25
  */
33
26
 
34
27
  /**
35
28
  * Compile a cero schema into wire-level artifacts and write them to disk.
36
29
  *
37
- * Emits a `main/` tree (schema + hyperdb + dispatch + rpc), a `local/` tree
38
- * for per-device data, one `handles/<name>/` tree per child handle type, and
39
- * an `index.js` that re-exports a ready-to-use `spec` object.
40
- *
41
30
  * @param {string} specDir Output directory.
42
31
  * @param {SchemaInput} schema Either a `schema(...)` wrapper or its raw defs object.
43
32
  * @param {BuildOpts} [opts]
44
33
  * @returns {Promise<void>}
45
34
  */
46
- export { getHyperdbType } from './builtins.js'
47
-
48
- export async function build(specDir, schema, { ns = NS, extensions = true } = {}) {
35
+ export async function build(specDir, schema, { ns = NS, extensions, operators } = {}) {
49
36
  const raw = /** @type {SchemaDefs & { local?: SchemaDefs }} */ (schema?.defs || schema)
50
37
  if (!raw || typeof raw !== 'object') throw CeroError.REQUIRED('schema')
38
+ const from = { extensions: str(extensions), operators: str(operators) }
39
+ const exts = extensionsOf(
40
+ null,
41
+ from.extensions ? await load(from.extensions, specDir) : extensions
42
+ )
51
43
 
52
- // Pull out `t.extend(...)` entries (keyed by builtin ref name) builtin type
53
- // name, merged across scopes. App schema first, then each `cero.use()` extension.
44
+ // t.extend entries by internal type: the app schema first, then each extension at its scope
54
45
  const extend = {}
55
46
  const defs = {}
56
- const collect = (entries, fromExt = false) => {
47
+ const fold = (entries, target, fromExt) => {
57
48
  for (const [k, v] of Object.entries(entries)) {
58
49
  if (v && v.kind === 'extend') {
59
- const type = refs.main[k]?.type
50
+ const type = internal.defs.main[k]?.type
60
51
  if (!type) throw CeroError.INVALID(`'${k}' is not an extendable builtin`)
61
52
  // app schema wins on field conflicts, extensions only add new fields
62
53
  extend[type] = fromExt ? { ...v.fields, ...extend[type] } : { ...extend[type], ...v.fields }
63
- } else if (!fromExt || !(k in defs)) {
64
- defs[k] = v // app schema wins over an extension's default
54
+ } else if (fromExt && isPlainHandle(v)) {
55
+ fold(v, (target[k] = { ...target[k] }), true) // into the handle type, the app's refs kept
56
+ } else if (!fromExt || !(k in target)) {
57
+ target[k] = v // app schema wins over an extension's default
65
58
  }
66
59
  }
67
60
  }
68
- collect(raw)
69
- for (const ext of internal.extensions) {
70
- if (ext.bundled && extensions === false) continue
71
- if (ext.schema) collect(ext.schema, true)
72
- }
61
+ fold(raw, defs, false)
62
+ for (const ext of exts) if (ext.schema) fold(ext.schema, defs, true)
73
63
 
74
64
  const main = compile(splitMain(defs), ns, 'main')
75
65
  const local = compile(defs.local || {}, ns, 'local')
@@ -93,12 +83,29 @@ export async function build(specDir, schema, { ns = NS, extensions = true } = {}
93
83
  )
94
84
  }
95
85
 
96
- // Hang a `type: <name>` marker on each handle ref in main so the runtime knows which type to open.
86
+ // the type marker tells the runtime which handle type to open
97
87
  for (const [name] of Object.entries(handles)) {
98
88
  if (meta.refs[name]) meta.refs[name] = { kind: 'handle', type: name, schema: `@${ns}/handle` }
99
89
  }
100
90
 
101
- await fs.writeFile(join(specDir, 'index.js'), wireModule(meta, Object.keys(handles)), 'utf-8')
91
+ await fs.writeFile(
92
+ join(specDir, 'index.js'),
93
+ wireModule(meta, Object.keys(handles), from),
94
+ 'utf-8'
95
+ )
96
+ }
97
+
98
+ const str = (v) => (typeof v === 'string' ? v : null)
99
+
100
+ async function load(specifier, specDir) {
101
+ const url = specifier.startsWith('.')
102
+ ? pathToFileURL(resolve(specDir, specifier)).href
103
+ : specifier
104
+ const { extensions } = await import(url)
105
+ if (!Array.isArray(extensions)) {
106
+ throw CeroError.INVALID(`'${specifier}' must export an extensions array`)
107
+ }
108
+ return extensions
102
109
  }
103
110
 
104
111
  function splitMain(defs) {
@@ -138,7 +145,7 @@ function compile(root, ns, scope = 'main') {
138
145
  scope
139
146
  }
140
147
 
141
- Object.assign(ctx.meta.refs, builtinRefs(ns, scope))
148
+ Object.assign(ctx.meta.refs, internal.refs(ns, scope))
142
149
 
143
150
  for (const [name, node] of Object.entries(root)) {
144
151
  if (node.kind === 'handle') {
@@ -166,13 +173,13 @@ function fileFieldNames(fields) {
166
173
  function register(name, node, ctx) {
167
174
  const fqn = `@${ctx.ns}/${name}`
168
175
  if (node.kind === 'action') {
169
- ctx.types.push({ name, compact: false, fields: fieldsFor(node.fields) })
176
+ ctx.types.push({ name, compact: false, fields: internal.fields(node.fields) })
170
177
  ctx.dispatches.push({ name, requestType: fqn })
171
178
  ctx.meta.refs[name] = { kind: 'action', path: [name], schema: fqn }
172
179
  return
173
180
  }
174
181
  if (node.kind === 'single') {
175
- ctx.types.push({ name, compact: false, fields: fieldsFor(node.fields) })
182
+ ctx.types.push({ name, compact: false, fields: internal.fields(node.fields) })
176
183
  ctx.collections.push({ name, schema: fqn, key: [] })
177
184
  ctx.dispatches.push({ name: `set-${name}`, requestType: fqn })
178
185
  ctx.dispatches.push({ name: `del-${name}`, requestType: `@${ctx.ns}/del-by-id` })
@@ -192,7 +199,7 @@ function register(name, node, ctx) {
192
199
  { name: 'index', type: 'uint', required: false },
193
200
  { name: 'createdAt', type: 'int', required: false },
194
201
  { name: 'updatedAt', type: 'int', required: false },
195
- ...fieldsFor(node.fields)
202
+ ...internal.fields(node.fields)
196
203
  ]
197
204
  })
198
205
  ctx.collections.push({ name, schema: fqn, key: ['id'] })
@@ -215,10 +222,7 @@ function register(name, node, ctx) {
215
222
  }
216
223
  ctx.meta.refs[name].indexes = node.indexes
217
224
  }
218
- // implicit secondary index on the auto-increment `index` lets reads push
219
- // reverse/limit down to hyperdb instead of scanning the whole collection.
220
- // main scope only: local (Storage) rows never get `index` stamped, and an
221
- // undefined index key crashes the encoder on insert.
225
+ // implicit index on the auto-increment `index`, so reverse and limit push down; main scope only
222
226
  if (ctx.scope === 'main' && !node.indexes?.index) {
223
227
  ctx.indexes.push({ name: `${name}-index`, collection: fqn, key: ['index'] })
224
228
  refEntry.orderIndex = true
@@ -226,17 +230,7 @@ function register(name, node, ctx) {
226
230
  }
227
231
  }
228
232
 
229
- function fieldsFor(fields) {
230
- return Object.entries(fields).map(([name, marker]) => ({
231
- name,
232
- type: getHyperdbType(marker.prim),
233
- required: marker.required === true
234
- }))
235
- }
236
-
237
- // The contract version is the highest of the three auto-bumped artifact
238
- // versions — any schema/db/dispatch change advances it, so a peer can tell
239
- // whether an op predates or postdates its own build.
233
+ // the contract version is the highest of the schema, db and dispatch versions
240
234
  async function contractVersion(mainDir) {
241
235
  const read = async (rel) => {
242
236
  const j = JSON.parse(await fs.readFile(join(mainDir, rel), 'utf-8'))
@@ -258,29 +252,30 @@ function emitMain(dir, ns, { types, collections, dispatches, indexes = [] }, { r
258
252
 
259
253
  const s = Hyperschema.from(schemaDir)
260
254
  const sns = s.namespace(ns)
261
- for (const desc of builtinTypes('main', extend)) sns.register(desc)
255
+ for (const desc of internal.types('main', extend)) sns.register(desc)
262
256
  for (const desc of types) sns.register(desc)
263
- if (rpc) for (const desc of rpcTypes()) sns.register(desc)
257
+ if (rpc) for (const desc of internal.types('rpc')) sns.register(desc)
264
258
  Hyperschema.toDisk(s, schemaDir, { esm: true })
265
259
 
266
260
  const db = HyperdbBuilder.from(schemaDir, dbDir)
267
261
  const dns = db.namespace(ns)
268
- for (const desc of builtinCollections(ns, 'main')) dns.collections.register(desc)
262
+ for (const desc of internal.collections(ns, 'main')) dns.collections.register(desc)
269
263
  for (const desc of collections) dns.collections.register(desc)
270
264
  for (const desc of indexes) dns.indexes.register(desc)
271
265
  HyperdbBuilder.toDisk(db, dbDir, { esm: true })
272
266
 
273
267
  const d = Hyperdispatch.from(schemaDir, dispatchDir)
274
268
  const xns = d.namespace(ns)
275
- for (const desc of builtinDispatches(ns)) xns.register(desc)
269
+ for (const desc of internal.dispatches(ns)) xns.register(desc)
276
270
  for (const desc of dispatches) xns.register(desc)
277
- xns.register(rotateDispatch(ns))
271
+ // after the app's dispatches: hyperdispatch numbers routes positionally and persists them
272
+ xns.register({ name: 'rotate-key', requestType: `@${ns}/epoch` })
278
273
  Hyperdispatch.toDisk(d, dispatchDir, { esm: true })
279
274
 
280
275
  if (rpc) {
281
276
  const r = HRPCBuilder.from(schemaDir, rpcDir)
282
277
  const rns = r.namespace(ns)
283
- for (const desc of rpcCommands(ns)) rns.register(desc)
278
+ for (const desc of internal.commands(ns)) rns.register(desc)
284
279
  HRPCBuilder.toDisk(r, rpcDir, { esm: true })
285
280
  }
286
281
  }
@@ -291,13 +286,13 @@ function emitLocal(dir, ns, { types, collections, indexes = [] }) {
291
286
 
292
287
  const s = Hyperschema.from(schemaDir)
293
288
  const sns = s.namespace(ns)
294
- for (const desc of builtinTypes('local')) sns.register(desc)
289
+ for (const desc of internal.types('local')) sns.register(desc)
295
290
  for (const desc of types) sns.register(desc)
296
291
  Hyperschema.toDisk(s, schemaDir, { esm: true })
297
292
 
298
293
  const db = HyperdbBuilder.from(schemaDir, dbDir)
299
294
  const dns = db.namespace(ns)
300
- for (const desc of builtinCollections(ns, 'local')) dns.collections.register(desc)
295
+ for (const desc of internal.collections(ns, 'local')) dns.collections.register(desc)
301
296
  for (const desc of collections) dns.collections.register(desc)
302
297
  for (const desc of indexes) dns.indexes.register(desc)
303
298
  HyperdbBuilder.toDisk(db, dbDir, { esm: true })
@@ -324,7 +319,10 @@ function handleEntries(names, kinds) {
324
319
  .join(',\n')
325
320
  }
326
321
 
327
- function wireModule(meta, names) {
322
+ // the spec imports what the build named, so every process finds the same lists
323
+ const named = (what, from) => (from ? `import { ${what} } from '${from}'` : `const ${what} = null`)
324
+
325
+ function wireModule(meta, names, from) {
328
326
  const kinds = ['database', 'dispatch', 'schema']
329
327
  return `// autogenerated by cero/build
330
328
  import database from './main/db/index.js'
@@ -334,6 +332,8 @@ import rpc from './main/rpc/index.js'
334
332
  import localDatabase from './local/db/index.js'
335
333
  import * as localSchema from './local/schema/index.js'
336
334
  ${handleImports(names, kinds)}
335
+ ${named('extensions', from.extensions)}
336
+ ${named('operators', from.operators)}
337
337
 
338
338
  export const meta = ${JSON.stringify(meta, null, 2)}
339
339
 
@@ -344,6 +344,8 @@ export const spec = {
344
344
  rpc,
345
345
  local: { database: localDatabase, schema: localSchema, meta: meta.local },
346
346
  meta,
347
+ extensions,
348
+ operators,
347
349
  handles: {
348
350
  ${handleEntries(names, kinds)}
349
351
  }
@@ -0,0 +1,123 @@
1
+ import { CeroError } from '@cero-base/core/errors'
2
+ import { COUNTERS, EPOCHS } from '../lib/constants.js'
3
+ import * as schemas from './schemas.js'
4
+
5
+ // internal refs by scope; kind defaults to collection
6
+ export const defs = {
7
+ main: {
8
+ members: { type: 'member' },
9
+ devices: { type: 'device' },
10
+ invites: { type: 'invite' },
11
+ handles: { type: 'handle' },
12
+ files: { type: 'file' }
13
+ },
14
+ local: {
15
+ master: { type: 'master', kind: 'single' },
16
+ keypair: { type: 'keypair', kind: 'single' },
17
+ 'handle-keypairs': { type: 'handle-keypair' },
18
+ environment: { type: 'environment', kind: 'single' }
19
+ }
20
+ }
21
+
22
+ // a DSL primitive is its hyperdb column type, except bytes (a buffer) and file (an id string)
23
+ const COLUMN = { bytes: 'buffer', file: 'string' }
24
+
25
+ export function fields(map) {
26
+ return Object.entries(map).map(([name, m]) => ({
27
+ name,
28
+ type: COLUMN[m.prim] || m.prim,
29
+ required: m.required === true
30
+ }))
31
+ }
32
+
33
+ // a base field cannot be redeclared
34
+ function merge(type, base, extra) {
35
+ if (!extra) return base
36
+ const dup = Object.keys(extra).find((k) => k in base)
37
+ if (dup) throw CeroError.INVALID(`'${dup}' is a base field of '${type}' and cannot be redeclared`)
38
+ return { ...base, ...extra }
39
+ }
40
+
41
+ export function types(scope, extend = {}) {
42
+ return Object.entries(schemas[scope]).map(([name, base]) => ({
43
+ name,
44
+ compact: false,
45
+ fields: fields(merge(name, base, extend[name]))
46
+ }))
47
+ }
48
+
49
+ export function refs(ns, scope) {
50
+ return Object.fromEntries(
51
+ Object.entries(defs[scope]).map(([name, def]) => [
52
+ name,
53
+ {
54
+ kind: def.kind || 'collection',
55
+ path: [name],
56
+ internal: true,
57
+ ...(scope === 'main' && { verb: def.type }),
58
+ schema: `@${ns}/${def.type}`
59
+ }
60
+ ])
61
+ )
62
+ }
63
+
64
+ export function collections(ns, scope) {
65
+ const out = Object.entries(defs[scope]).map(([name, def]) => ({
66
+ name,
67
+ schema: `@${ns}/${def.type}`,
68
+ key: def.kind === 'single' ? [] : ['id']
69
+ }))
70
+ if (scope === 'main') {
71
+ out.push({ name: COUNTERS, schema: `@${ns}/counter`, key: ['name'] })
72
+ out.push({ name: EPOCHS, schema: `@${ns}/epoch`, key: ['epoch'] })
73
+ }
74
+ return out
75
+ }
76
+
77
+ export function dispatches(ns) {
78
+ return [
79
+ { name: 'add-writer', requestType: `@${ns}/writer` },
80
+ { name: 'del-writer', requestType: `@${ns}/writer` },
81
+ { name: 'claim-writer', requestType: `@${ns}/claim` },
82
+ ...Object.values(defs.main).flatMap(({ type }) => [
83
+ { name: `add-${type}`, requestType: `@${ns}/${type}` },
84
+ { name: `set-${type}`, requestType: `@${ns}/${type}` },
85
+ { name: `del-${type}`, requestType: `@${ns}/del-by-id` }
86
+ ])
87
+ ]
88
+ }
89
+
90
+ // hrpc commands: name, request type, response type, streaming response
91
+ const COMMANDS = [
92
+ ['init', 'req-empty', 'res-identity'],
93
+ ['restore', 'req-restore', 'res-identity'],
94
+ ['seed', 'req-empty', 'res-seed'],
95
+ ['add-row', 'req-row', 'res-data'],
96
+ ['add-file', 'req-add-file', 'res-data'],
97
+ ['add-handle', 'req-row', 'res-handle'],
98
+ ['set', 'req-row', 'res-data'],
99
+ ['get', 'req-query', 'res-rows'],
100
+ ['get-one', 'req-id', 'res-data'],
101
+ ['del', 'req-id', 'res-ok'],
102
+ ['watch', 'req-query', 'res-rows', true],
103
+ ['call', 'req-call', 'res-data'],
104
+ ['invite', 'req-invite', 'res-invite'],
105
+ ['revoke', 'req-revoke', 'res-ok'],
106
+ ['join', 'req-join', 'res-handle'],
107
+ ['open-handle', 'req-open', 'res-handle'],
108
+ ['close-handle', 'req-handle', 'res-ok'],
109
+ ['leave', 'req-handle', 'res-ok'],
110
+ ['changes', 'req-query', 'res-changes', true],
111
+ ['rotate', 'req-handle', 'res-epoch'],
112
+ ['set-active', 'req-set-active', 'res-ok'],
113
+ ['suspend', 'req-empty', 'res-ok'],
114
+ ['resume', 'req-empty', 'res-ok']
115
+ ]
116
+
117
+ export function commands(ns) {
118
+ return COMMANDS.map(([name, req, res, stream]) => ({
119
+ name,
120
+ request: { name: `@${ns}/${req}` },
121
+ response: { name: `@${ns}/${res}`, ...(stream && { stream }) }
122
+ }))
123
+ }
@@ -1,6 +1,4 @@
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.
1
+ // field order is the wire format: append only, never reorder
4
2
  import { t } from '../lib/spec.js'
5
3
 
6
4
  const { string, bytes, int, uint, bool, required } = t
@@ -15,11 +13,7 @@ export const main = {
15
13
  sig: required(bytes),
16
14
  isIndexer: bool,
17
15
  ts: int,
18
- // Who this writer belongs to. The paired add-member decides the rank in
19
- // the same transaction, and a refused grant discards this admission with
20
- // it — so `role` is unread since 77e5ed0 and stays only for older peers.
21
- // Absent on a device you add for yourself (recovery), where the device
22
- // binds to the master identity.
16
+ // the paired add-member decides the rank in the same transaction; `role` here is unread
23
17
  memberId: string,
24
18
  role: string
25
19
  },
@@ -165,6 +159,10 @@ export const rpc = {
165
159
  'req-handle': {
166
160
  handle: required(string)
167
161
  },
162
+ 'req-set-active': {
163
+ handle: required(string),
164
+ active: bool
165
+ },
168
166
  'res-data': {
169
167
  data: bytes
170
168
  },
@@ -177,9 +175,6 @@ export const rpc = {
177
175
  changes: required(bytes),
178
176
  reset: bool
179
177
  },
180
- 'res-count': {
181
- count: required(int)
182
- },
183
178
  'res-invite': {
184
179
  invite: required(string)
185
180
  },
@@ -2,26 +2,18 @@ import { t } from '../lib/spec.js'
2
2
  import { get, set, watch } from '../lib/operators.js'
3
3
 
4
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.
5
+ * Mirror a child handle's `profile` (name + avatar) onto its row in the parent's `handles`
6
+ * list — so a handle list shows names + avatars without opening each one.
12
7
  *
13
8
  * @param {{ fields?: Record<string, any> }} [opts]
14
9
  */
15
10
  export function handleSync({ fields = { avatar: t.string } } = {}) {
16
11
  return {
17
12
  name: 'handle-sync',
18
- bundled: true,
19
13
  schema: { handles: t.extend(fields) },
20
14
  setup(me) {
21
15
  const keys = ['name', ...Object.keys(fields)]
22
- // skip the write when the row already mirrors the profile every open
23
- // re-emits the watch snapshot, and an unconditional set is a new op in
24
- // the replicated log forever
16
+ // an unconditional set on every open is a new op in the log forever
25
17
  const reflect = async (child, data) => {
26
18
  child.name = data.name
27
19
  const { data: row } = await get(me.handles, child.id)
@@ -1,2 +1,88 @@
1
+ import { profileSync } from './profile-sync.js'
2
+ import { handleSync } from './handle-sync.js'
3
+ import { t, schema } from '../lib/spec.js'
4
+ import * as operators from '../lib/operators.js'
5
+
1
6
  export * from './profile-sync.js'
2
7
  export * from './handle-sync.js'
8
+
9
+ // everything an extension module needs, light enough for the UI bundle the spec pulls it into
10
+ export { t, schema }
11
+ export const { put, set, get, del, watch, changes, call, open, rotate, before, after } = operators
12
+ export const cero = {
13
+ t,
14
+ schema,
15
+ put,
16
+ set,
17
+ get,
18
+ del,
19
+ watch,
20
+ changes,
21
+ call,
22
+ open,
23
+ rotate,
24
+ before,
25
+ after
26
+ }
27
+
28
+ /**
29
+ * @typedef {object} Extension
30
+ * @property {Record<string, any>} [schema] Refs to add, or `t.extend` on a builtin, nested by handle type like the app schema.
31
+ * @property {(me: any) => any} [setup] Runs once the root is ready; a returned function runs on close.
32
+ */
33
+
34
+ /** The two every app gets unless its build names a list. */
35
+ export const bundled = [profileSync(), handleSync()]
36
+
37
+ /**
38
+ * The extensions a spec carries, else the bundled two. A bare function is `{ setup }`.
39
+ *
40
+ * @param {any} spec
41
+ * @param {Array<any>} [override]
42
+ * @returns {Extension[]}
43
+ */
44
+ export function extensionsOf(spec, override) {
45
+ const list = override || spec?.extensions || bundled
46
+ return list.map((e) => (typeof e === 'function' ? { setup: e } : e))
47
+ }
48
+
49
+ /**
50
+ * The operators a spec carries: functions taking the handle first, keyed by namespace, a
51
+ * key naming a handle type holding that type's namespaces.
52
+ *
53
+ * @param {any} spec
54
+ * @param {Record<string, any>} [override]
55
+ * @returns {Record<string, any>}
56
+ */
57
+ export function operatorsOf(spec, override) {
58
+ return override || spec?.operators || {}
59
+ }
60
+
61
+ // handle.ns.fn(args) calls fn(handle, args)
62
+ function attach(handle, ns, fns) {
63
+ const bound = {}
64
+ for (const key of Object.keys(fns)) {
65
+ if (typeof fns[key] === 'function') bound[key] = (...args) => fns[key](handle, ...args)
66
+ }
67
+ handle[ns] = bound
68
+ }
69
+
70
+ /**
71
+ * Put the operators for `handle` on it: the root when `type` is null, else a child of `type`.
72
+ *
73
+ * @param {any} handle
74
+ * @param {string | null} type
75
+ * @param {Record<string, any>} operators
76
+ * @returns {any} handle
77
+ */
78
+ export function bind(handle, type, operators) {
79
+ const handles = handle.spec?.meta?.handles || {}
80
+ for (const [ns, fns] of Object.entries(operators)) {
81
+ if (type === null) {
82
+ if (!(ns in handles)) attach(handle, ns, fns)
83
+ } else if (ns === type) {
84
+ for (const [k, group] of Object.entries(fns)) attach(handle, k, group)
85
+ }
86
+ }
87
+ return handle
88
+ }
@@ -1,44 +1,43 @@
1
1
  import { t } from '../lib/spec.js'
2
- import { get, set, after } from '../lib/operators.js'
2
+ import { get, set, changes } from '../lib/operators.js'
3
3
 
4
4
  /**
5
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
6
  *
11
7
  * @param {{ fields?: Record<string, any> }} [opts]
12
8
  */
13
9
  export function profileSync({ fields = { avatar: t.string } } = {}) {
14
10
  return {
15
11
  name: 'profile-sync',
16
- bundled: true,
17
12
  schema: {
18
13
  profile: t.single({ name: t.string, ...fields }),
19
14
  members: t.extend(fields)
20
15
  },
21
16
  setup(me) {
22
17
  const keys = ['name', ...Object.keys(fields)]
23
- const publish = (child, profile) =>
24
- set(child.members, { id: me.identity.id, ...profile }, { upsert: false }).catch(me._onerror)
25
18
 
26
- // skip the broadcast when the member row already mirrors the profile —
27
19
  // an unconditional set on every open is a room-wide op forever
28
- const onHandle = async (child) => {
29
- const { data: profile } = await get(me.profile)
30
- if (!profile) return
20
+ const publish = async (child, profile) => {
31
21
  const { data: member } = await get(child.members, me.identity.id)
32
22
  if (member && keys.every((k) => member[k] === profile[k])) return
33
- publish(child, profile)
23
+ await set(child.members, { id: me.identity.id, ...profile }, { upsert: false })
34
24
  }
35
25
 
36
- const onProfile = (ctx) => {
37
- if (ctx.row) me.children.forEach((child) => publish(child, ctx.row))
26
+ const onHandle = async (child) => {
27
+ const { data: profile } = await get(me.profile)
28
+ if (profile) await publish(child, profile)
38
29
  }
39
30
 
40
31
  me.on('handle', (child) => onHandle(child).catch(me._onerror), { signal: me.signal })
41
- after(me.profile, onProfile, { signal: me.signal })
32
+
33
+ // a local edit and one replicated from another device both land here
34
+ const stream = changes(me.profile, {}, { signal: me.signal })
35
+ stream.on('error', me._onerror)
36
+ stream.on('data', ({ changes: batch }) => {
37
+ for (const { next } of batch) {
38
+ if (next) me.children.forEach((child) => publish(child, next).catch(me._onerror))
39
+ }
40
+ })
42
41
  }
43
42
  }
44
43
  }