@cero-base/cero 0.8.10 → 1.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.
package/README.md CHANGED
@@ -49,6 +49,7 @@ Ships with TypeScript declarations (`.d.ts`) generated from JSDoc.
49
49
  | Opt | Meaning |
50
50
  | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
51
51
  | `bootstrap` | Hyperswarm bootstrap nodes. |
52
+ | `channel` | Optional network-isolation label. Peers connect only to peers on the **same** channel (it salts every swarm topic); omit it for the global network. A storage remembers its channel and refuses to reopen under a different one. Any string works. |
52
53
  | `name` / `isMobile` | Stamped on the device's `add-writer` event. |
53
54
  | `seed` / `phrase` | Restore from explicit 16-/32-byte entropy or a BIP-39 mnemonic. Without either, a stored identity is loaded if present, else a fresh one is generated. |
54
55
  | `key` | Open against an existing bee key (multi-device flow). |
@@ -56,6 +57,7 @@ Ships with TypeScript declarations (`.d.ts`) generated from JSDoc.
56
57
  | `recoveryTimeout` | Bound on the recovery wait. |
57
58
  | `routes` | Custom action handlers keyed by route name. |
58
59
  | `encryptionKey` | Override the per-identity encryption key. |
60
+ | `onerror` | Called with background/async failures that would otherwise be swallowed (failed after-hooks, `onApply` callbacks, pairing candidate errors); the app decides how to log or report them. |
59
61
 
60
62
  Reads `me.id`, `me.device`, `me.identity` for canonical metadata. `me.identity.toPhrase()` renders the seed phrase.
61
63
 
@@ -104,8 +106,9 @@ Append a row to a collection. Generates an `id`, `createdAt`, and `updatedAt` if
104
106
 
105
107
  ```js
106
108
  const { data } = await cero.put(room.messages, { text: 'hi' })
107
- data.id // → 'abc…'
108
- data.memberId // stamped automatically by the writer→member backlink
109
+ data.id // → 'abc…' (plus createdAt / updatedAt)
110
+ // `memberId` (the writer→member backlink) is stamped in the apply layer, so it
111
+ // surfaces on a later `cero.get`, not on the row this call returns.
109
112
  ```
110
113
 
111
114
  ### `cero.set(ref, row)`
@@ -166,9 +169,9 @@ A Readable stream that re-emits the latest snapshot on every change. Always emit
166
169
 
167
170
  ```js
168
171
  const stream = cero.watch(room.messages, { limit: 50, reverse: true })
169
- stream.on('data', ({ data, total, size }) => {
172
+ for await (const { data, total, size } of stream) {
170
173
  /* render */
171
- })
174
+ }
172
175
  // stops on room.close(), on stream.destroy(), or on `signal` abort:
173
176
  const ac = new AbortController()
174
177
  cero.watch(room.messages, null, { signal: ac.signal }) // ac.abort() ⇒ destroyed
@@ -211,6 +214,53 @@ cero.after(me.profile, ({ row }) => {
211
214
 
212
215
  `ctx` is `{ op, name, row }` (plus `result` in `after`).
213
216
 
217
+ ## Files
218
+
219
+ Store a file (avatar, image, attachment) and get a ready-to-render URL. Bytes live in a per-handle blob core — replicated to members on demand, never inlined into the log — so rows carry only a small self-describing id.
220
+
221
+ Every handle has a builtin `files` collection. `cero.put(handle.files, …)` uploads bytes (a `Buffer` or a `Readable`) and returns a file with a `.url`:
222
+
223
+ ```js
224
+ const { data: file } = await cero.put(me.files, {
225
+ data: bytes, // Buffer | Readable
226
+ name: 'cat.jpg',
227
+ type: 'image/jpeg'
228
+ })
229
+
230
+ img.src = file.url
231
+ ```
232
+
233
+ Read them back — every row carries a fresh `.url`:
234
+
235
+ ```js
236
+ const { data: files } = await cero.get(me.files) // list all
237
+ const { data: one } = await cero.get(me.files, file.id) // one, by id
238
+ cero.watch(me.files).on('data', ({ data }) => render(data)) // live
239
+ ```
240
+
241
+ ### The `file()` column type
242
+
243
+ For a file referenced from a row — an avatar, a room icon, a message attachment — declare the field `cero.t.file()`. Store the file's `id`; read it back already resolved to `{ id, name, type, size, url }`, with **no extra lookup**:
244
+
245
+ ```js
246
+ // schema.js
247
+ profile: cero.t.single({ name: cero.t.string, avatar: cero.t.file() })
248
+
249
+ // save: upload, then store the id on the row
250
+ const { data: pic } = await cero.put(me.files, { data: bytes, type: 'image/png' })
251
+ await cero.set(me.profile, { avatar: pic.id })
252
+
253
+ // read: the avatar comes back url-ready
254
+ const { data: profile } = await cero.get(me.profile)
255
+ img.src = profile.avatar.url
256
+ ```
257
+
258
+ `cero.t.file({ embed: true })` also stores the file's `name` inline — handy for a list of named attachments, so rendering needs no per-item lookup.
259
+
260
+ ### URLs are local and ephemeral
261
+
262
+ A `.url` points at a localhost server this device runs, with a per-session token — it changes across restarts and is **not** shareable to other peers. Never persist a `.url`: store the **id** (cero does), and re-read to get a current one. Each member derives their own url from the same id, and the bytes download on demand when the url is first fetched.
263
+
214
264
  ## Custom operators
215
265
 
216
266
  Your app's business logic lives as **custom operators** — plain functions whose first arg is the handle they act on, composing the built-ins. Because the built-ins resolve through the handle (a real DB on the core, an RPC proxy on the client), custom operators are symmetric over RPC for free.
@@ -360,8 +410,9 @@ The client side gets the **same operator API** as a local cero — `cero.put`, `
360
410
  import { serve } from '@cero-base/cero/server'
361
411
  import { spec } from './spec/index.js'
362
412
 
363
- // serve builds and owns the root cero — pass storage + the built spec, not a handle
364
- await serve(Bare.IPC, { storage: './data', spec, seed: '…' })
413
+ // serve builds and owns the root cero — pass storage + the built spec, not a handle.
414
+ // Returns a Server instance (call server.close() to shut down).
415
+ const server = await serve(Bare.IPC, { storage: './data', spec, seed: '…' })
365
416
  ```
366
417
 
367
418
  ### Client (the UI process)
@@ -386,14 +437,14 @@ The same `spec/index.js` is imported on both sides. `@cero-base/cero/build` emit
386
437
 
387
438
  Everything the local API offers, plus lifecycle methods on returned handles:
388
439
 
389
- | Operator / method | Works over RPC |
390
- | ------------------------------------------------------------------------------ | -------------- |
391
- | `cero.put` / `set` / `get` / `del` / `count` / `watch` / `call` | ✓ |
392
- | `cero.open(ref, …)` — create, join, load by id | ✓ |
393
- | `room.invite({ role })` — mint an invite | ✓ |
394
- | `room.revoke(invite)` — invalidate an outstanding invite; `true` if it existed | ✓ |
395
- | `room.close()` — release the handle on the server | ✓ |
396
- | `room.leave()` — remove yourself from the room and from your handles list | ✓ |
440
+ | Operator / method | Works over RPC |
441
+ | -------------------------------------------------------------------------------------------------------------------- | -------------- |
442
+ | `cero.put` / `set` / `get` / `del` / `count` / `watch` / `call` | ✓ |
443
+ | `cero.open(ref, …)` — create, join, load by id | ✓ |
444
+ | `room.invite({ role, expiresIn, multiUse })` — mint an invite (`multiUse: true` keeps it alive after the first join) | ✓ |
445
+ | `room.revoke(invite)` — invalidate an outstanding invite; `true` if it existed | ✓ |
446
+ | `room.close()` — release the handle on the server | ✓ |
447
+ | `room.leave()` — remove yourself from the room and from your handles list | ✓ |
397
448
 
398
449
  `cero.watch(ref)` returns a Readable on both sides; snapshots flow as a server-streamed RPC.
399
450
 
package/package.json CHANGED
@@ -1,8 +1,17 @@
1
1
  {
2
2
  "name": "@cero-base/cero",
3
- "version": "0.8.10",
3
+ "version": "1.1.0",
4
4
  "description": "The ideal p2p API — everything is a handle, handles contain refs, refs contain rows.",
5
5
  "type": "module",
6
+ "sideEffects": false,
7
+ "engines": {
8
+ "node": ">=20"
9
+ },
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "https://github.com/lekinox/cero-base.git",
13
+ "directory": "packages/cero"
14
+ },
6
15
  "main": "./src/index.js",
7
16
  "types": "./types/index.d.ts",
8
17
  "files": [
@@ -39,7 +48,7 @@
39
48
  "typesVersions": {
40
49
  "*": {
41
50
  "build": [
42
- "types/builder.d.ts"
51
+ "types/build/index.d.ts"
43
52
  ],
44
53
  "server": [
45
54
  "types/rpc/server.d.ts"
@@ -64,10 +73,6 @@
64
73
  "crypto": {
65
74
  "bare": "bare-crypto",
66
75
  "default": "crypto"
67
- },
68
- "abort-controller": {
69
- "bare": "bare-abort-controller",
70
- "default": "bare-abort-controller"
71
76
  }
72
77
  },
73
78
  "scripts": {
@@ -78,25 +83,30 @@
78
83
  "test": "ls test/*.test.js | xargs -P1 -n1 brittle-node"
79
84
  },
80
85
  "dependencies": {
81
- "@cero-base/core": "^0.8.10",
86
+ "@cero-base/core": "^1.1.0",
82
87
  "b4a": "^1.8.1",
83
88
  "bare-abort-controller": "^1.1.2",
84
- "bare-crypto": "^1.14.1",
89
+ "bare-crypto": "^1.15.3",
85
90
  "bare-fs": "^4.7.2",
86
91
  "bare-path": "^3.0.1",
87
92
  "blind-pairing": "^2.3.1",
88
- "compact-encoding": "^3.1.0",
93
+ "compact-encoding": "^3.2.0",
94
+ "corestore": "^7.10.1",
89
95
  "hrpc": "^4.3.0",
96
+ "hypercore": "^11.33.1",
97
+ "hypercore-crypto": "^3.7.0",
98
+ "hypercore-storage": "^3.1.1",
90
99
  "hyperdb": "^6.7.0",
91
100
  "hyperdispatch": "^1.6.0",
92
101
  "hyperschema": "^1.21.0",
93
102
  "ready-resource": "^1.2.0",
94
103
  "safety-catch": "^1.0.3",
104
+ "streamx": "^2.28.0",
95
105
  "z32": "^1.1.0"
96
106
  },
97
107
  "devDependencies": {
98
108
  "@hyperswarm/testnet": "^3.1.4",
99
- "brittle": "^4.0.0",
109
+ "brittle": "^4.0.2",
100
110
  "typescript": "^6.0.3"
101
111
  },
102
112
  "license": "Apache-2.0"
@@ -13,16 +13,18 @@ export const refs = {
13
13
  members: { type: 'member' },
14
14
  devices: { type: 'device' },
15
15
  invites: { type: 'invite' },
16
- handles: { type: 'handle' }
16
+ handles: { type: 'handle' },
17
+ files: { type: 'file' }
17
18
  },
18
19
  local: {
19
20
  master: { type: 'master', kind: 'single' },
20
21
  keypair: { type: 'keypair', kind: 'single' },
21
- 'handle-keypairs': { type: 'handle-keypair' }
22
+ 'handle-keypairs': { type: 'handle-keypair' },
23
+ environment: { type: 'environment', kind: 'single' }
22
24
  }
23
25
  }
24
26
 
25
- export const hyperdbType = (prim) => DB_TYPE[prim] || 'string'
27
+ export const getHyperdbType = (prim) => DB_TYPE[prim] || 'string'
26
28
 
27
29
  const at = (ns, n) => `@${ns}/${n}`
28
30
  const keyOf = (def) => (def.kind === 'single' ? [] : ['id'])
@@ -30,7 +32,7 @@ const keyOf = (def) => (def.kind === 'single' ? [] : ['id'])
30
32
  const fields = (map) =>
31
33
  Object.entries(map).map(([name, m]) => ({
32
34
  name,
33
- type: hyperdbType(m.prim),
35
+ type: getHyperdbType(m.prim),
34
36
  required: m.required === true
35
37
  }))
36
38
 
@@ -102,7 +104,13 @@ export const rpcCommands = (ns) => {
102
104
  request: { name: ref('req-restore') },
103
105
  response: { name: ref('res-identity') }
104
106
  },
107
+ { name: 'seed', request: { name: ref('req-empty') }, response: { name: ref('res-seed') } },
105
108
  { name: 'add-row', request: { name: ref('req-row') }, response: { name: ref('res-data') } },
109
+ {
110
+ name: 'add-file',
111
+ request: { name: ref('req-add-file') },
112
+ response: { name: ref('res-data') }
113
+ },
106
114
  {
107
115
  name: 'add-handle',
108
116
  request: { name: ref('req-row') },
@@ -18,7 +18,7 @@ import {
18
18
  builtinDispatches,
19
19
  rpcTypes,
20
20
  rpcCommands,
21
- hyperdbType
21
+ getHyperdbType
22
22
  } from './builtins.js'
23
23
 
24
24
  /**
@@ -42,6 +42,8 @@ import {
42
42
  * @param {BuildOpts} [opts]
43
43
  * @returns {Promise<void>}
44
44
  */
45
+ export { getHyperdbType } from './builtins.js'
46
+
45
47
  export async function build(specDir, schema, { ns = NS } = {}) {
46
48
  const raw = /** @type {SchemaDefs & { local?: SchemaDefs }} */ (schema?.defs || schema)
47
49
  if (!raw || typeof raw !== 'object') throw CeroError.REQUIRED('schema')
@@ -148,6 +150,12 @@ function compile(root, ns, scope = 'main') {
148
150
  }
149
151
  }
150
152
 
153
+ function fileFieldNames(fields) {
154
+ return Object.entries(fields)
155
+ .filter(([, m]) => m.prim === 'file')
156
+ .map(([name]) => name)
157
+ }
158
+
151
159
  function register(name, node, ctx) {
152
160
  const fqn = `@${ctx.ns}/${name}`
153
161
  if (node.kind === 'action') {
@@ -160,7 +168,11 @@ function register(name, node, ctx) {
160
168
  ctx.types.push({ name, compact: false, fields: fieldsFor(node.fields) })
161
169
  ctx.collections.push({ name, schema: fqn, key: [] })
162
170
  ctx.dispatches.push({ name: `set-${name}`, requestType: fqn })
163
- ctx.meta.refs[name] = { kind: 'single', path: [name], schema: fqn }
171
+ ctx.dispatches.push({ name: `del-${name}`, requestType: `@${ctx.ns}/del-by-id` })
172
+ const refEntry = { kind: 'single', path: [name], schema: fqn, fields: Object.keys(node.fields) }
173
+ const fileFields = fileFieldNames(node.fields)
174
+ if (fileFields.length) refEntry.files = fileFields
175
+ ctx.meta.refs[name] = refEntry
164
176
  return
165
177
  }
166
178
  if (node.kind === 'collection') {
@@ -180,7 +192,15 @@ function register(name, node, ctx) {
180
192
  ctx.dispatches.push({ name: `add-${name}`, requestType: fqn })
181
193
  ctx.dispatches.push({ name: `set-${name}`, requestType: fqn })
182
194
  ctx.dispatches.push({ name: `del-${name}`, requestType: `@${ctx.ns}/del-by-id` })
183
- ctx.meta.refs[name] = { kind: 'collection', path: [name], schema: fqn }
195
+ const refEntry = {
196
+ kind: 'collection',
197
+ path: [name],
198
+ schema: fqn,
199
+ fields: Object.keys(node.fields)
200
+ }
201
+ const fileFields = fileFieldNames(node.fields)
202
+ if (fileFields.length) refEntry.files = fileFields
203
+ ctx.meta.refs[name] = refEntry
184
204
  if (node.indexes) {
185
205
  for (const [idx, fields] of Object.entries(node.indexes)) {
186
206
  ctx.indexes.push({ name: `${name}-${idx}`, collection: fqn, key: fields })
@@ -193,7 +213,7 @@ function register(name, node, ctx) {
193
213
  function fieldsFor(fields) {
194
214
  return Object.entries(fields).map(([name, marker]) => ({
195
215
  name,
196
- type: hyperdbType(marker.prim),
216
+ type: getHyperdbType(marker.prim),
197
217
  required: marker.required === true
198
218
  }))
199
219
  }
@@ -59,6 +59,14 @@ export const main = {
59
59
  updatedAt: int,
60
60
  index: uint
61
61
  },
62
+ file: {
63
+ id: required(string),
64
+ memberId: string,
65
+ name: string,
66
+ createdAt: int,
67
+ updatedAt: int,
68
+ index: uint
69
+ },
62
70
  claim: {
63
71
  identity: required(bytes),
64
72
  writer: required(bytes),
@@ -79,6 +87,9 @@ export const local = {
79
87
  publicKey: required(bytes),
80
88
  secretKey: required(bytes),
81
89
  encryptionKey: bytes
90
+ },
91
+ environment: {
92
+ channel: required(string)
82
93
  }
83
94
  }
84
95
 
@@ -93,7 +104,8 @@ export const rpc = {
93
104
  handle: required(string),
94
105
  ref: required(string),
95
106
  data: required(bytes),
96
- local: bool
107
+ local: bool,
108
+ noUpsert: bool
97
109
  },
98
110
  'req-id': {
99
111
  handle: required(string),
@@ -151,9 +163,19 @@ export const rpc = {
151
163
  type: required(string),
152
164
  name: string
153
165
  },
166
+ 'req-add-file': {
167
+ handle: required(string),
168
+ data: required(bytes),
169
+ name: string,
170
+ type: string
171
+ },
154
172
  'res-identity': {
155
173
  id: required(string),
156
174
  deviceId: string,
175
+ fileBase: string,
176
+ fileToken: string
177
+ },
178
+ 'res-seed': {
157
179
  phrase: string
158
180
  },
159
181
  'res-ok': {