@cero-base/core 0.2.0 → 0.4.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 (52) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +180 -136
  3. package/package.json +85 -26
  4. package/src/blobs/index.js +297 -0
  5. package/src/database/CLAUDE.md +3 -0
  6. package/src/database/bootstrap.js +76 -0
  7. package/src/database/dispatch.js +156 -0
  8. package/src/database/index.js +572 -0
  9. package/src/identity/CLAUDE.md +3 -0
  10. package/src/identity/index.js +232 -0
  11. package/src/index.js +20 -1
  12. package/src/lib/CLAUDE.md +3 -0
  13. package/src/lib/constants.js +24 -4
  14. package/src/lib/errors.js +150 -0
  15. package/src/lib/schema.js +58 -440
  16. package/src/lib/spec/index.js +353 -0
  17. package/src/lib/spec/schema.json +284 -0
  18. package/src/lib/utils.js +54 -49
  19. package/src/network/discovery.js +80 -0
  20. package/src/network/index.js +231 -0
  21. package/src/pairing/index.js +482 -0
  22. package/src/pairing/invite.js +199 -0
  23. package/src/rpc/client.js +45 -0
  24. package/src/rpc/index.js +141 -0
  25. package/src/rpc/server.js +45 -0
  26. package/src/storage/index.js +261 -0
  27. package/types/blobs/index.d.ts +169 -0
  28. package/types/database/bootstrap.d.ts +17 -0
  29. package/types/database/dispatch.d.ts +8 -0
  30. package/types/database/index.d.ts +329 -0
  31. package/types/identity/index.d.ts +160 -0
  32. package/types/index.d.ts +11 -0
  33. package/types/lib/constants.d.ts +13 -0
  34. package/types/lib/errors.d.ts +110 -0
  35. package/types/lib/schema.d.ts +53 -0
  36. package/types/lib/spec/index.d.ts +95 -0
  37. package/types/lib/utils.d.ts +39 -0
  38. package/types/network/discovery.d.ts +44 -0
  39. package/types/network/index.d.ts +115 -0
  40. package/types/pairing/index.d.ts +194 -0
  41. package/types/pairing/invite.d.ts +157 -0
  42. package/types/rpc/client.d.ts +18 -0
  43. package/types/rpc/index.d.ts +67 -0
  44. package/types/rpc/server.d.ts +18 -0
  45. package/types/storage/index.d.ts +163 -0
  46. package/src/lib/base.js +0 -84
  47. package/src/lib/batch.js +0 -98
  48. package/src/lib/builder.js +0 -24
  49. package/src/lib/collection.js +0 -252
  50. package/src/lib/crypto.js +0 -6
  51. package/src/lib/index.js +0 -6
  52. package/src/lib/room.js +0 -145
package/src/lib/schema.js CHANGED
@@ -1,450 +1,68 @@
1
- import Hyperschema from 'hyperschema'
2
- import { extendIdentitySchema, extendIdentityDispatch } from '@lekinox/cero/src/identity/schema.js'
3
- import { extendRoomSchema, extendRoomDispatch } from '@lekinox/cero/src/room/schema.js'
4
- import { singular, toFields, scopeNs, isLocal, isPrivate, isShared } from './utils.js'
5
- import { SCOPE_LOCAL, SCOPE_PRIVATE, SCOPE_SHARED } from './constants.js'
1
+ import { CeroError } from './errors.js'
2
+ import { SINGLE, COLLECTION, ACTION } from './constants.js'
6
3
 
7
- // Built-in collections inherited from cero. Names map to:
8
- // - scope: private = identity-replicated, shared = room-replicated
9
- // - type: singular type name in cero's schema (e.g. 'member' for 'members')
10
- // - key: primary key fields (empty array = single-row collection, e.g. profile)
11
- //
12
- // Whether each built-in supports add/set/del is DERIVED from cero's dispatch
13
- // table at schema-build time (see ceroDispatches), not hardcoded here.
14
- const BUILTINS = {
15
- members: { scope: SCOPE_SHARED, type: 'member', key: ['id'] },
16
- profile: { scope: SCOPE_PRIVATE, type: 'profile', key: [] },
17
- devices: { scope: SCOPE_PRIVATE, type: 'device', key: ['id'] },
18
- rooms: { scope: SCOPE_PRIVATE, type: 'room', key: ['id'] },
19
- invites: { scope: SCOPE_PRIVATE, type: 'invite', key: ['id'] },
20
- mirrors: { scope: SCOPE_PRIVATE, type: 'mirror', key: ['key'] },
21
- settings: { scope: SCOPE_PRIVATE, type: 'setting', key: ['key'] }
22
- }
23
-
24
- // Local-scope built-ins cero registers in @local/* — exposed to apps via
25
- // db.local.collection(name). Distinct from BUILTINS (which are private/shared).
26
- const LOCAL_BUILTINS = {
27
- identity: { type: 'identity', key: [] },
28
- rooms: { type: 'room', key: ['key'] },
29
- files: { type: 'file', key: ['key', 'blob.blockOffset'] },
30
- settings: { type: 'setting', key: ['key'] },
31
- counters: { type: 'counter', key: ['name'] }
32
- }
33
-
34
- // One in-memory Hyperschema per (scope, ns) — built once by running cero's
35
- // extender and reused for every type lookup. The extender registers all
36
- // built-in types in the namespace; baseFieldsOf just reads them back.
37
- const schemaCache = new Map()
38
- function builtinSchema(scope, nsName) {
39
- const key = `${scope}:${nsName}`
40
- let schema = schemaCache.get(key)
41
- if (schema) return schema
42
-
43
- schema = new Hyperschema()
44
- const ns = schema.namespace(nsName)
45
- if (scope === SCOPE_SHARED) {
46
- extendRoomSchema(schema, ns)
47
- } else {
48
- extendIdentitySchema(schema, ns)
49
- }
50
- schemaCache.set(key, schema)
51
- return schema
52
- }
53
-
54
- function baseFieldsOf(scope, nsName, typeName) {
55
- const type = builtinSchema(scope, nsName).types.get(`@${nsName}/${typeName}`)
56
- if (!type) throw new Error(`Built-in type '${typeName}' not found in cero schema`)
57
- return type.toJSON().fields
58
- }
59
-
60
- // Cache of dispatch names cero registers per scope. Runs cero's extender
61
- // against a stub namespace that just captures register() calls.
62
- const dispatchCache = new Map()
63
- function ceroDispatches(scope) {
64
- if (dispatchCache.has(scope)) return dispatchCache.get(scope)
65
- const captured = new Set()
66
- const stubNs = {
67
- name: scope === SCOPE_SHARED ? 'room' : 'identity',
68
- register: (desc) => captured.add(desc.name)
69
- }
70
- if (scope === SCOPE_SHARED) {
71
- extendRoomDispatch(null, stubNs)
72
- } else {
73
- extendIdentityDispatch(null, stubNs)
74
- }
75
- dispatchCache.set(scope, captured)
76
- return captured
77
- }
4
+ /**
5
+ * @typedef {{ prim: string }} Prim
6
+ * @typedef {{ kind: 'single' | 'collection' | 'action', fields: Record<string, Prim> }} TypeDef
7
+ * @typedef {{ [name: string]: TypeDef | Record<string, TypeDef> } & { local?: Record<string, TypeDef> }} SchemaDefs
8
+ * @typedef {{ defs: SchemaDefs }} Schema
9
+ */
78
10
 
79
- // Does cero have an `add-${type}` dispatch for this built-in?
80
- function ceroHasAdd(scope, typeName) {
81
- return ceroDispatches(scope).has(`add-${typeName}`)
82
- }
11
+ /** @param {string} name @returns {Prim} */
12
+ const prim = (name) => ({ prim: name })
83
13
 
84
14
  /**
85
- * Type helpers for defining schema fields
86
- * Usage: { name: t.string, count: t.int, active: t.bool }
15
+ * Schema DSL. Primitive field types and kind-constructors used to describe
16
+ * a database's tables before the builder turns them into a wire spec.
87
17
  */
88
18
  export const t = {
89
- string: { type: 'string', required: true },
90
- int: { type: 'int', required: true },
91
- uint: { type: 'uint', required: true },
92
- float: { type: 'float', required: true },
93
- bool: { type: 'bool', required: true },
94
- buffer: { type: 'buffer', required: true },
95
- fixed32: { type: 'fixed32', required: true },
96
- json: { type: 'json', required: true },
97
-
98
- optional: (field) => ({ ...field, required: false }),
99
- array: (field) => ({ ...field, array: true }),
100
- ref: (collection) => ({ type: 'string', required: true, ref: collection })
101
- }
102
-
103
- function schemaFields(def) {
104
- const fields = toFields(def.fields)
105
- // every type gets index for range queries / pagination (assigned by cero's Base)
106
- fields.push({ name: 'index', type: 'uint', required: false })
107
- if (def.timestamps) {
108
- fields.push({ name: 'createdAt', type: 'uint', required: true })
109
- fields.push({ name: 'updatedAt', type: 'uint', required: false })
19
+ string: prim('string'),
20
+ uint: prim('uint'),
21
+ int: prim('int'),
22
+ bool: prim('bool'),
23
+ bytes: prim('bytes'),
24
+ json: prim('json'),
25
+ fixed32: prim('fixed32'),
26
+ fixed64: prim('fixed64'),
27
+
28
+ /**
29
+ * Single-row table (one record, set/get by name).
30
+ *
31
+ * @param {Record<string, Prim>} fields
32
+ * @returns {TypeDef}
33
+ */
34
+ single(fields) {
35
+ return { kind: SINGLE, fields }
36
+ },
37
+
38
+ /**
39
+ * Multi-row table keyed by `id`.
40
+ *
41
+ * @param {Record<string, Prim>} fields
42
+ * @returns {TypeDef}
43
+ */
44
+ collection(fields) {
45
+ return { kind: COLLECTION, fields }
46
+ },
47
+
48
+ /**
49
+ * RPC-style mutation that doesn't persist a row.
50
+ *
51
+ * @param {Record<string, Prim>} fields
52
+ * @returns {TypeDef}
53
+ */
54
+ action(fields) {
55
+ return { kind: ACTION, fields }
110
56
  }
111
- return fields
112
- }
113
-
114
- function inputFields(def) {
115
- return toFields(def.fields).map((f) => ({ ...f, required: false }))
116
57
  }
117
58
 
118
- function delFields(def) {
119
- const keys = {}
120
- for (const k of def.key || ['id']) keys[k] = def.fields[k]
121
- return toFields(keys)
122
- }
123
-
124
- t.schema = defineSchema
125
-
126
- export function defineSchema(collections, opts = {}) {
127
- const entries = Object.entries(collections)
128
-
129
- // Validate built-in entries: user can't override fixed metadata (key, scope, timestamps).
130
- for (const [name, def] of entries) {
131
- if (!BUILTINS[name]) continue
132
- if (def.key !== undefined) {
133
- throw new Error(`'${name}' is a built-in; key is fixed and cannot be set`)
134
- }
135
- if (def.scope !== undefined && def.scope !== BUILTINS[name].scope) {
136
- throw new Error(
137
- `'${name}' is a built-in with scope '${BUILTINS[name].scope}'; cannot override`
138
- )
139
- }
140
- if (def.timestamps !== undefined) {
141
- throw new Error(`'${name}' is a built-in; timestamps is fixed and cannot be set`)
142
- }
143
- }
144
-
145
- // Type name resolution: built-ins use cero's singular type name, user collections use singular(plural).
146
- const types = new Map([
147
- // Pre-populate with built-ins so they resolve even when user didn't declare them.
148
- ...Object.entries(BUILTINS).map(([name, info]) => [name, info.type]),
149
- ...entries.map(([name]) => [name, BUILTINS[name]?.type || singular(name)])
150
- ])
151
-
152
- const getScope = ([name, def]) =>
153
- BUILTINS[name] ? BUILTINS[name].scope : def.scope || SCOPE_PRIVATE
154
- const localEntries = entries.filter((e) => getScope(e) === SCOPE_LOCAL)
155
- const privateEntries = entries.filter((e) => getScope(e) === SCOPE_PRIVATE)
156
- const sharedEntries = entries.filter((e) => getScope(e) === SCOPE_SHARED)
157
-
158
- const nsFor = (scope) => (scope === SCOPE_SHARED && opts.namespace) || scopeNs(scope)
159
-
160
- return {
161
- namespace: opts.namespace || null,
162
- collections,
163
- types,
164
-
165
- getScope(collection) {
166
- if (BUILTINS[collection]) return BUILTINS[collection].scope
167
- const def = collections[collection]
168
- return def ? def.scope || SCOPE_PRIVATE : null
169
- },
170
-
171
- isBuiltin(collection) {
172
- return !!BUILTINS[collection]
173
- },
174
-
175
- /**
176
- * Does Collection.put follow the add/set merge path for this collection?
177
- *
178
- * - User-defined: always yes. Non-local user collections get auto-generated
179
- * `add-${type}` dispatches; local collections take the merge path even
180
- * without dispatches (they write straight to the local store).
181
- * - Built-ins: derived from cero's actual dispatch table — `false` for
182
- * collections cero exposes only via `set-${type}` (e.g. profile, settings).
183
- */
184
- hasAdd(collection) {
185
- const builtin = BUILTINS[collection]
186
- if (builtin) return ceroHasAdd(builtin.scope, builtin.type)
187
- return !!collections[collection]
188
- },
189
-
190
- keysOf(collection) {
191
- if (BUILTINS[collection]) return BUILTINS[collection].key
192
- return collections[collection]?.key || ['id']
193
- },
194
-
195
- isLocalBuiltin(collection) {
196
- return !!LOCAL_BUILTINS[collection]
197
- },
198
-
199
- /**
200
- * Resolve a name to its local-scope type. Returns null if the name doesn't
201
- * map to a local collection (cero built-in or user-declared `scope: 'local'`).
202
- */
203
- localTypeOf(collection) {
204
- if (LOCAL_BUILTINS[collection]) return LOCAL_BUILTINS[collection].type
205
- const def = collections[collection]
206
- if (def?.scope === SCOPE_LOCAL) return singular(collection)
207
- return null
208
- },
209
-
210
- localKeysOf(collection) {
211
- if (LOCAL_BUILTINS[collection]) return LOCAL_BUILTINS[collection].key
212
- const def = collections[collection]
213
- if (def?.scope === SCOPE_LOCAL) return def.key || ['id']
214
- return null
215
- },
216
-
217
- /**
218
- * Resolve a local collection name to its hyperdb spec + path. Returns null
219
- * if `name` isn't a local collection. Local built-ins don't have input-X
220
- * types, so the prefix is ignored for them.
221
- */
222
- localResolve(collection, prefix) {
223
- const ns = scopeNs(SCOPE_LOCAL)
224
- if (LOCAL_BUILTINS[collection]) {
225
- return { spec: ns, path: `@${ns}/${LOCAL_BUILTINS[collection].type}` }
226
- }
227
- const def = collections[collection]
228
- if (def?.scope === SCOPE_LOCAL) {
229
- const type = singular(collection)
230
- const name = prefix ? `${prefix}-${type}` : type
231
- return { spec: ns, path: `@${ns}/${name}` }
232
- }
233
- return null
234
- },
235
-
236
- isLocal(collection) {
237
- return isLocal(this.getScope(collection))
238
- },
239
- isPrivate(collection) {
240
- return isPrivate(this.getScope(collection))
241
- },
242
- isShared(collection) {
243
- return isShared(this.getScope(collection))
244
- },
245
-
246
- hasTimestamps(collection) {
247
- return collections[collection]?.timestamps === true
248
- },
249
-
250
- hasCounter(collection) {
251
- return collections[collection]?.counter === true
252
- },
253
-
254
- counterNames(scope) {
255
- const items =
256
- scope === SCOPE_SHARED
257
- ? sharedEntries
258
- : scope === SCOPE_LOCAL
259
- ? localEntries
260
- : privateEntries
261
- // map: type name (singular) → collection name (plural)
262
- const map = {}
263
- for (const [name, def] of items) {
264
- if (def.counter) map[types.get(name)] = name
265
- }
266
- return map
267
- },
268
-
269
- resolve(collection, prefix) {
270
- const scope = this.getScope(collection)
271
- const type = types.get(collection)
272
- const name = prefix ? `${prefix}-${type}` : type
273
- return { spec: scopeNs(scope), path: `@${nsFor(scope)}/${name}` }
274
- },
275
-
276
- require(collection, context) {
277
- const scope = this.getScope(collection)
278
- if (!scope) throw new Error(`Unknown collection: '${collection}'`)
279
- if (context === 'db' && scope === SCOPE_SHARED) {
280
- throw new Error(`'${collection}' is shared — use room.collection('${collection}')`)
281
- }
282
- if (context === 'room' && scope !== SCOPE_SHARED) {
283
- throw new Error(`'${collection}' is ${scope} — use db.collection('${collection}')`)
284
- }
285
- return scope
286
- },
287
-
288
- getPath(collection) {
289
- const scope = this.getScope(collection)
290
- return `@${nsFor(scope)}/${collection}`
291
- },
292
-
293
- build(scope, register, nsName) {
294
- const items =
295
- scope === SCOPE_SHARED
296
- ? sharedEntries
297
- : scope === SCOPE_LOCAL
298
- ? localEntries
299
- : privateEntries
300
- const local = scope === SCOPE_LOCAL
301
- const ns = nsName || scopeNs(scope)
302
- for (const [name, def] of items) {
303
- if (BUILTINS[name]) {
304
- // Built-in extension: re-register the type with extra fields appended.
305
- // cero's later registration is idempotent and skips when the type already exists.
306
- const typeName = BUILTINS[name].type
307
- const baseFields = baseFieldsOf(scope, ns, typeName)
308
- const userFields = toFields(def.fields || {}).map((f) => ({ ...f, required: false }))
309
-
310
- const baseNames = new Set(baseFields.map((f) => f.name))
311
- for (const uf of userFields) {
312
- if (baseNames.has(uf.name)) {
313
- throw new Error(
314
- `'${uf.name}' is a base field of '${typeName}' and cannot be redeclared`
315
- )
316
- }
317
- }
318
-
319
- const allFields = [...baseFields, ...userFields]
320
- register.type({ name: typeName, compact: false, fields: allFields })
321
- register.type({
322
- name: `input-${typeName}`,
323
- compact: false,
324
- fields: allFields.map((f) => ({ ...f, required: false }))
325
- })
326
-
327
- if (def.indexes) {
328
- for (const [idx, idxFields] of Object.entries(def.indexes)) {
329
- register.index({
330
- name: `${name}-${idx}`,
331
- collection: `@${ns}/${name}`,
332
- key: idxFields
333
- })
334
- }
335
- }
336
-
337
- // Register custom dispatches for user-supplied handlers.
338
- // Use input-${type} (all-optional fields) so callers can pass partial
339
- // payloads like { id } without re-supplying every base field.
340
- if (def.handlers) {
341
- for (const op of Object.keys(def.handlers)) {
342
- register.dispatch({ name: op, requestType: `@${ns}/input-${typeName}` })
343
- }
344
- }
345
- continue
346
- }
347
-
348
- const type = types.get(name)
349
- const fields = schemaFields(def)
350
- const typePath = `@${ns}/${type}`
351
-
352
- register.type({ name: type, compact: false, fields })
353
- register.type({ name: `input-${type}`, compact: false, fields: inputFields(def) })
354
- if (!local) register.type({ name: `del-${type}`, compact: false, fields: delFields(def) })
355
-
356
- register.collection({
357
- name,
358
- schema: typePath,
359
- key: def.key || ['id'],
360
- counter: !!def.counter
361
- })
362
-
363
- if (def.indexes) {
364
- for (const [idx, idxFields] of Object.entries(def.indexes)) {
365
- register.index({ name: `${name}-${idx}`, collection: `@${ns}/${name}`, key: idxFields })
366
- }
367
- }
368
-
369
- if (!local) {
370
- register.dispatch({ name: `add-${type}`, requestType: typePath })
371
- register.dispatch({ name: `set-${type}`, requestType: typePath })
372
- register.dispatch({ name: `del-${type}`, requestType: `@${ns}/del-${type}` })
373
- }
374
-
375
- // Register custom dispatches for user-supplied handlers.
376
- // Use input-${type} so callers can pass partial payloads.
377
- if (!local && def.handlers) {
378
- for (const op of Object.keys(def.handlers)) {
379
- register.dispatch({ name: op, requestType: `@${ns}/input-${type}` })
380
- }
381
- }
382
- }
383
-
384
- // Register input-${type} for implicit built-ins (not in user schema)
385
- // so the RPC bridge can encode partial payloads for Collection writes.
386
- if (!local) {
387
- for (const [name, info] of Object.entries(BUILTINS)) {
388
- if (info.scope !== scope) continue
389
- if (collections[name]) continue // already handled by built-in branch above
390
- const baseFields = baseFieldsOf(info.scope, ns, info.type)
391
- register.type({
392
- name: `input-${info.type}`,
393
- compact: false,
394
- fields: baseFields.map((f) => ({ ...f, required: false }))
395
- })
396
- }
397
- }
398
- },
399
-
400
- routes(scope) {
401
- const items = scope === SCOPE_PRIVATE ? privateEntries : sharedEntries
402
- const handlers = {}
403
- const p = (name) => `@${nsFor(scope)}/${name}`
404
- for (const [name, def] of items) {
405
- if (BUILTINS[name]) {
406
- // Built-in extension: cero already owns add/set/del — only register custom handlers.
407
- if (def.handlers) {
408
- for (const [op, handler] of Object.entries(def.handlers)) {
409
- handlers[p(op)] = handler
410
- }
411
- }
412
- continue
413
- }
414
-
415
- const type = types.get(name)
416
- const path = p(name)
417
- const keys = def.key || ['id']
418
-
419
- handlers[p(`add-${type}`)] = async (op, ctx) => {
420
- const counterPath = p('counters')
421
- const counter = (await ctx.view.get(counterPath, { name })) || { name, value: 0 }
422
- op.index = counter.value
423
- counter.value++
424
- await ctx.view.insert(counterPath, counter)
425
- await ctx.view.insert(path, op)
426
- }
427
-
428
- handlers[p(`set-${type}`)] = (op, ctx) => ctx.view.insert(path, op)
429
- handlers[p(`del-${type}`)] = async (op, ctx) => {
430
- await ctx.view.delete(
431
- path,
432
- keys.reduce((acc, k) => ((acc[k] = op[k]), acc), {})
433
- )
434
- const counterPath = p('counters')
435
- const counter = (await ctx.view.get(counterPath, { name })) || { name, value: 0 }
436
- counter.value = Math.max(0, counter.value - 1)
437
- await ctx.view.insert(counterPath, counter)
438
- }
439
-
440
- if (def.handlers) {
441
- for (const [op, handler] of Object.entries(def.handlers)) {
442
- handlers[p(op)] = handler
443
- }
444
- }
445
- }
446
-
447
- return handlers
448
- }
449
- }
59
+ /**
60
+ * Wrap a definitions object so the builder can recognise it as a schema.
61
+ *
62
+ * @param {SchemaDefs} defs
63
+ * @returns {Schema}
64
+ */
65
+ export function schema(defs) {
66
+ if (!defs || typeof defs !== 'object') throw CeroError.INVALID('schema(defs) must be an object')
67
+ return { defs }
450
68
  }