@cero-base/cero 0.8.10 → 1.0.1

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
@@ -56,6 +56,7 @@ Ships with TypeScript declarations (`.d.ts`) generated from JSDoc.
56
56
  | `recoveryTimeout` | Bound on the recovery wait. |
57
57
  | `routes` | Custom action handlers keyed by route name. |
58
58
  | `encryptionKey` | Override the per-identity encryption key. |
59
+ | `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
60
 
60
61
  Reads `me.id`, `me.device`, `me.identity` for canonical metadata. `me.identity.toPhrase()` renders the seed phrase.
61
62
 
@@ -104,8 +105,9 @@ Append a row to a collection. Generates an `id`, `createdAt`, and `updatedAt` if
104
105
 
105
106
  ```js
106
107
  const { data } = await cero.put(room.messages, { text: 'hi' })
107
- data.id // → 'abc…'
108
- data.memberId // stamped automatically by the writer→member backlink
108
+ data.id // → 'abc…' (plus createdAt / updatedAt)
109
+ // `memberId` (the writer→member backlink) is stamped in the apply layer, so it
110
+ // surfaces on a later `cero.get`, not on the row this call returns.
109
111
  ```
110
112
 
111
113
  ### `cero.set(ref, row)`
@@ -166,9 +168,9 @@ A Readable stream that re-emits the latest snapshot on every change. Always emit
166
168
 
167
169
  ```js
168
170
  const stream = cero.watch(room.messages, { limit: 50, reverse: true })
169
- stream.on('data', ({ data, total, size }) => {
171
+ for await (const { data, total, size } of stream) {
170
172
  /* render */
171
- })
173
+ }
172
174
  // stops on room.close(), on stream.destroy(), or on `signal` abort:
173
175
  const ac = new AbortController()
174
176
  cero.watch(room.messages, null, { signal: ac.signal }) // ac.abort() ⇒ destroyed
@@ -211,6 +213,53 @@ cero.after(me.profile, ({ row }) => {
211
213
 
212
214
  `ctx` is `{ op, name, row }` (plus `result` in `after`).
213
215
 
216
+ ## Files
217
+
218
+ 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.
219
+
220
+ 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`:
221
+
222
+ ```js
223
+ const { data: file } = await cero.put(me.files, {
224
+ data: bytes, // Buffer | Readable
225
+ name: 'cat.jpg',
226
+ type: 'image/jpeg'
227
+ })
228
+
229
+ img.src = file.url
230
+ ```
231
+
232
+ Read them back — every row carries a fresh `.url`:
233
+
234
+ ```js
235
+ const { data: files } = await cero.get(me.files) // list all
236
+ const { data: one } = await cero.get(me.files, file.id) // one, by id
237
+ cero.watch(me.files).on('data', ({ data }) => render(data)) // live
238
+ ```
239
+
240
+ ### The `file()` column type
241
+
242
+ 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**:
243
+
244
+ ```js
245
+ // schema.js
246
+ profile: cero.t.single({ name: cero.t.string, avatar: cero.t.file() })
247
+
248
+ // save: upload, then store the id on the row
249
+ const { data: pic } = await cero.put(me.files, { data: bytes, type: 'image/png' })
250
+ await cero.set(me.profile, { avatar: pic.id })
251
+
252
+ // read: the avatar comes back url-ready
253
+ const { data: profile } = await cero.get(me.profile)
254
+ img.src = profile.avatar.url
255
+ ```
256
+
257
+ `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.
258
+
259
+ ### URLs are local and ephemeral
260
+
261
+ 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.
262
+
214
263
  ## Custom operators
215
264
 
216
265
  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 +409,9 @@ The client side gets the **same operator API** as a local cero — `cero.put`, `
360
409
  import { serve } from '@cero-base/cero/server'
361
410
  import { spec } from './spec/index.js'
362
411
 
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: '…' })
412
+ // serve builds and owns the root cero — pass storage + the built spec, not a handle.
413
+ // Returns a Server instance (call server.close() to shut down).
414
+ const server = await serve(Bare.IPC, { storage: './data', spec, seed: '…' })
365
415
  ```
366
416
 
367
417
  ### Client (the UI process)
@@ -386,14 +436,14 @@ The same `spec/index.js` is imported on both sides. `@cero-base/cero/build` emit
386
436
 
387
437
  Everything the local API offers, plus lifecycle methods on returned handles:
388
438
 
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 | ✓ |
439
+ | Operator / method | Works over RPC |
440
+ | -------------------------------------------------------------------------------------------------------------------- | -------------- |
441
+ | `cero.put` / `set` / `get` / `del` / `count` / `watch` / `call` | ✓ |
442
+ | `cero.open(ref, …)` — create, join, load by id | ✓ |
443
+ | `room.invite({ role, expiresIn, multiUse })` — mint an invite (`multiUse: true` keeps it alive after the first join) | ✓ |
444
+ | `room.revoke(invite)` — invalidate an outstanding invite; `true` if it existed | ✓ |
445
+ | `room.close()` — release the handle on the server | ✓ |
446
+ | `room.leave()` — remove yourself from the room and from your handles list | ✓ |
397
447
 
398
448
  `cero.watch(ref)` returns a Readable on both sides; snapshots flow as a server-streamed RPC.
399
449
 
package/package.json CHANGED
@@ -1,8 +1,17 @@
1
1
  {
2
2
  "name": "@cero-base/cero",
3
- "version": "0.8.10",
3
+ "version": "1.0.1",
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.0.1",
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,7 +13,8 @@ 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' },
@@ -22,7 +23,7 @@ export const refs = {
22
23
  }
23
24
  }
24
25
 
25
- export const hyperdbType = (prim) => DB_TYPE[prim] || 'string'
26
+ export const getHyperdbType = (prim) => DB_TYPE[prim] || 'string'
26
27
 
27
28
  const at = (ns, n) => `@${ns}/${n}`
28
29
  const keyOf = (def) => (def.kind === 'single' ? [] : ['id'])
@@ -30,7 +31,7 @@ const keyOf = (def) => (def.kind === 'single' ? [] : ['id'])
30
31
  const fields = (map) =>
31
32
  Object.entries(map).map(([name, m]) => ({
32
33
  name,
33
- type: hyperdbType(m.prim),
34
+ type: getHyperdbType(m.prim),
34
35
  required: m.required === true
35
36
  }))
36
37
 
@@ -102,7 +103,13 @@ export const rpcCommands = (ns) => {
102
103
  request: { name: ref('req-restore') },
103
104
  response: { name: ref('res-identity') }
104
105
  },
106
+ { name: 'seed', request: { name: ref('req-empty') }, response: { name: ref('res-seed') } },
105
107
  { name: 'add-row', request: { name: ref('req-row') }, response: { name: ref('res-data') } },
108
+ {
109
+ name: 'add-file',
110
+ request: { name: ref('req-add-file') },
111
+ response: { name: ref('res-data') }
112
+ },
106
113
  {
107
114
  name: 'add-handle',
108
115
  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),
@@ -93,7 +101,8 @@ export const rpc = {
93
101
  handle: required(string),
94
102
  ref: required(string),
95
103
  data: required(bytes),
96
- local: bool
104
+ local: bool,
105
+ noUpsert: bool
97
106
  },
98
107
  'req-id': {
99
108
  handle: required(string),
@@ -151,9 +160,19 @@ export const rpc = {
151
160
  type: required(string),
152
161
  name: string
153
162
  },
163
+ 'req-add-file': {
164
+ handle: required(string),
165
+ data: required(bytes),
166
+ name: string,
167
+ type: string
168
+ },
154
169
  'res-identity': {
155
170
  id: required(string),
156
171
  deviceId: string,
172
+ fileBase: string,
173
+ fileToken: string
174
+ },
175
+ 'res-seed': {
157
176
  phrase: string
158
177
  },
159
178
  'res-ok': {