@cero-base/cero 0.8.9 → 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
 
@@ -85,6 +86,15 @@ const recovered = await cero.restore(me, 'twelve words …')
85
86
  | `{ … }` (plain object) | a child handle — its own scope, share by invite |
86
87
  | `local: { … }` | device-only — never leaves this device |
87
88
 
89
+ A collection can declare **secondary indexes** for fast exact-field lookups:
90
+
91
+ ```js
92
+ cero.t.collection(
93
+ { text: cero.t.string },
94
+ { indexes: { 'by-text': ['text'] } } // name → field(s); query via `cero.get(ref, { text: '…' })`
95
+ )
96
+ ```
97
+
88
98
  ## Operators
89
99
 
90
100
  Every operator takes a `ref` (e.g. `me.profile`, `room.messages`) as the first arg.
@@ -95,8 +105,9 @@ Append a row to a collection. Generates an `id`, `createdAt`, and `updatedAt` if
95
105
 
96
106
  ```js
97
107
  const { data } = await cero.put(room.messages, { text: 'hi' })
98
- data.id // → 'abc…'
99
- 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.
100
111
  ```
101
112
 
102
113
  ### `cero.set(ref, row)`
@@ -110,19 +121,30 @@ await cero.set(room.messages, { id: 'abc', text: 'edited' }) // upsert by id
110
121
 
111
122
  ### `cero.get(ref, q?)`
112
123
 
113
- Read. Three shapes depending on `q`:
124
+ Read. The shape of `q` decides what comes back:
114
125
 
115
126
  ```js
116
127
  const { data } = await cero.get(me.profile) // single → the row (or null)
117
128
  const { data } = await cero.get(room.messages, 'abc') // by id → the row (or null)
129
+
130
+ // collection → paginated, with range filters
118
131
  const { data, total, size } = await cero.get(room.messages, {
119
- gt: 'm-2025', // filters: gt / gte / lt / lte
132
+ gt: 'm-2025', // range: gt / gte / lt / lte
120
133
  limit: 20,
121
134
  reverse: true
122
- }) // collection → paginated
135
+ })
136
+
137
+ // search → case-insensitive substring across string fields
138
+ const { data } = await cero.get(room.messages, { search: 'jo sm' })
139
+ // every space-separated term must match (AND); folds case + diacritics ('jose' ⇢ 'José').
140
+ // `fields` restricts which fields are searched (default: all string fields):
141
+ const { data } = await cero.get(room.guests, { search: 'jose', fields: ['name'] })
142
+
143
+ // exact field match → routed through a declared secondary index when the field has one
144
+ const { data } = await cero.get(room.messages, { text: 'alpha' })
123
145
  ```
124
146
 
125
- `total` is the unfiltered row count; `size` is what came back after `limit`.
147
+ `total` is the unfiltered row count; `size` is what came back after `limit`. `search` and exact-field filters compose with `limit` / `reverse`, and `cero.count(ref, q)` honors them too.
126
148
 
127
149
  ### `cero.del(ref, id)`
128
150
 
@@ -146,9 +168,9 @@ A Readable stream that re-emits the latest snapshot on every change. Always emit
146
168
 
147
169
  ```js
148
170
  const stream = cero.watch(room.messages, { limit: 50, reverse: true })
149
- stream.on('data', ({ data, total, size }) => {
171
+ for await (const { data, total, size } of stream) {
150
172
  /* render */
151
- })
173
+ }
152
174
  // stops on room.close(), on stream.destroy(), or on `signal` abort:
153
175
  const ac = new AbortController()
154
176
  cero.watch(room.messages, null, { signal: ac.signal }) // ac.abort() ⇒ destroyed
@@ -191,6 +213,53 @@ cero.after(me.profile, ({ row }) => {
191
213
 
192
214
  `ctx` is `{ op, name, row }` (plus `result` in `after`).
193
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
+
194
263
  ## Custom operators
195
264
 
196
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.
@@ -340,8 +409,9 @@ The client side gets the **same operator API** as a local cero — `cero.put`, `
340
409
  import { serve } from '@cero-base/cero/server'
341
410
  import { spec } from './spec/index.js'
342
411
 
343
- // serve builds and owns the root cero — pass storage + the built spec, not a handle
344
- 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: '…' })
345
415
  ```
346
416
 
347
417
  ### Client (the UI process)
@@ -366,14 +436,14 @@ The same `spec/index.js` is imported on both sides. `@cero-base/cero/build` emit
366
436
 
367
437
  Everything the local API offers, plus lifecycle methods on returned handles:
368
438
 
369
- | Operator / method | Works over RPC |
370
- | ------------------------------------------------------------------------------ | -------------- |
371
- | `cero.put` / `set` / `get` / `del` / `count` / `watch` / `call` | ✓ |
372
- | `cero.open(ref, …)` — create, join, load by id | ✓ |
373
- | `room.invite({ role })` — mint an invite | ✓ |
374
- | `room.revoke(invite)` — invalidate an outstanding invite; `true` if it existed | ✓ |
375
- | `room.close()` — release the handle on the server | ✓ |
376
- | `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 | ✓ |
377
447
 
378
448
  `cero.watch(ref)` returns a Readable on both sides; snapshots flow as a server-streamed RPC.
379
449
 
package/package.json CHANGED
@@ -1,8 +1,17 @@
1
1
  {
2
2
  "name": "@cero-base/cero",
3
- "version": "0.8.9",
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.9",
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': {