@cero-base/core 0.0.5 → 0.2.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 +104 -9
- package/package.json +2 -2
- package/src/lib/base.js +13 -27
- package/src/lib/batch.js +5 -2
- package/src/lib/collection.js +52 -8
- package/src/lib/room.js +2 -13
- package/src/lib/schema.js +249 -2
package/README.md
CHANGED
|
@@ -34,6 +34,99 @@ export const schema = t.schema(
|
|
|
34
34
|
| `private` (default) | Across paired devices |
|
|
35
35
|
| `shared` | Between room members |
|
|
36
36
|
|
|
37
|
+
## Built-in collections
|
|
38
|
+
|
|
39
|
+
cero provides built-in collections that always exist on every database — you don't declare them in `t.schema()` to use them:
|
|
40
|
+
|
|
41
|
+
| Name | Scope | Shape |
|
|
42
|
+
| ---------- | --------- | ---------------------------------------------------- |
|
|
43
|
+
| `profile` | `private` | Single-row identity profile (`name`, `avatar`, etc.) |
|
|
44
|
+
| `devices` | `private` | All devices paired to this identity |
|
|
45
|
+
| `rooms` | `private` | Rooms saved to this identity (id, key, name, …) |
|
|
46
|
+
| `invites` | `private` | Pending invites this identity has created |
|
|
47
|
+
| `mirrors` | `private` | Replicated-room hints (autobase mirror keys) |
|
|
48
|
+
| `settings` | `private` | Key/value app settings (replicated across devices) |
|
|
49
|
+
| `members` | `shared` | Members of the current room |
|
|
50
|
+
|
|
51
|
+
They're accessed via the same `Collection` API:
|
|
52
|
+
|
|
53
|
+
```js
|
|
54
|
+
const profile = (await db.collection('profile').get())[0]
|
|
55
|
+
await db.collection('profile').put({ name: 'Alice' })
|
|
56
|
+
|
|
57
|
+
const devices = await db.collection('devices').get()
|
|
58
|
+
const rooms = await db.collection('rooms').get() // saved rooms
|
|
59
|
+
|
|
60
|
+
const room = await db.rooms.open() // open one
|
|
61
|
+
const members = await room.collection('members').get()
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
`db.collection('rooms')` returns the identity-replicated saved-room list. Use `db.rooms.create/open/pair/close` for room _lifecycle_ (those aren't Collection ops).
|
|
65
|
+
|
|
66
|
+
### Local-scope built-ins via `db.local.collection(name)`
|
|
67
|
+
|
|
68
|
+
cero also maintains device-local tables that don't replicate (rooms cache with rich indexes, file metadata, etc.). Reach them via `db.local.collection(name)`:
|
|
69
|
+
|
|
70
|
+
| Name | Key | Purpose |
|
|
71
|
+
| ---------- | ----------------------------- | ----------------------------------------------------------------------------------- |
|
|
72
|
+
| `identity` | `[]` | Single-row identity record (key, keyPair, …) |
|
|
73
|
+
| `rooms` | `['key']` | Local rooms cache with `by-name`/`by-updatedAt`/`by-discoveryKey` indexes |
|
|
74
|
+
| `files` | `['key', 'blob.blockOffset']` | Local file metadata |
|
|
75
|
+
| `settings` | `['key']` | Local key/value settings (distinct from the identity-replicated `settings` builtin) |
|
|
76
|
+
| `counters` | `['name']` | Local counter table |
|
|
77
|
+
|
|
78
|
+
```js
|
|
79
|
+
const localRooms = await db.local.collection('rooms').get()
|
|
80
|
+
const cachedFiles = await db.local.collection('files').get()
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
`db.local.collection(name)` also resolves user-declared local collections (those declared with `scope: 'local'` in `t.schema()`), so `db.local.collection('drafts')` and `db.collection('drafts')` are equivalent for a user-local `drafts`.
|
|
84
|
+
|
|
85
|
+
### Indexes and handlers
|
|
86
|
+
|
|
87
|
+
`indexes` and `handlers` work on any collection — user-defined or built-in:
|
|
88
|
+
|
|
89
|
+
```js
|
|
90
|
+
import { t } from '@cero-base/core/schema'
|
|
91
|
+
|
|
92
|
+
export const schema = t.schema(
|
|
93
|
+
{
|
|
94
|
+
// User-defined collection
|
|
95
|
+
tasks: {
|
|
96
|
+
fields: { id: t.string, title: t.string, dept: t.string, done: t.bool },
|
|
97
|
+
key: ['id'],
|
|
98
|
+
scope: 'shared',
|
|
99
|
+
indexes: { 'by-dept': ['dept'] },
|
|
100
|
+
handlers: {
|
|
101
|
+
'archive-task': async (op, ctx) => {
|
|
102
|
+
const path = '@chat/tasks'
|
|
103
|
+
const m = await ctx.view.get(path, { id: op.id })
|
|
104
|
+
if (m) await ctx.view.insert(path, { ...m, done: true })
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
// Built-in extension — same shape, no key/scope/timestamps (those are fixed)
|
|
110
|
+
members: {
|
|
111
|
+
fields: { department: t.string, level: t.uint },
|
|
112
|
+
indexes: { 'by-department': ['department'] },
|
|
113
|
+
handlers: {
|
|
114
|
+
'promote-member': async (op, ctx) => {
|
|
115
|
+
const path = '@chat/members'
|
|
116
|
+
const m = await ctx.view.get(path, { id: op.id })
|
|
117
|
+
if (m) await ctx.view.insert(path, { ...m, role: 'admin' })
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
{ namespace: 'chat' }
|
|
123
|
+
)
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Custom dispatch payloads are encoded as `input-${type}` (all fields optional), so partial payloads like `{ id }` work without re-supplying every field.
|
|
127
|
+
|
|
128
|
+
Built-ins are **opt-out**, not opt-in: omit them from `t.schema()` and they still work with cero's default fields. List them only when you need to customize. `key`, `scope`, and `timestamps` are fixed for built-ins — passing them throws.
|
|
129
|
+
|
|
37
130
|
## Usage
|
|
38
131
|
|
|
39
132
|
```js
|
|
@@ -54,6 +147,9 @@ const room = await db.rooms.open()
|
|
|
54
147
|
const messages = room.collection('messages')
|
|
55
148
|
await messages.put({ text: 'Hello!', memberId: db.id })
|
|
56
149
|
|
|
150
|
+
// Custom dispatches declared via the built-in's handlers
|
|
151
|
+
await room.collection('members').dispatch('promote-member', { id: someId })
|
|
152
|
+
|
|
57
153
|
// Batch writes
|
|
58
154
|
const batch = db.batch()
|
|
59
155
|
batch.put('settings', { key: 'a', value: '1' })
|
|
@@ -73,15 +169,14 @@ For RPC support, use `@cero-base/rpc`'s build which includes RPC types automatic
|
|
|
73
169
|
|
|
74
170
|
## Exports
|
|
75
171
|
|
|
76
|
-
| Export | Path | Description
|
|
77
|
-
| ------------ | ------------------------- |
|
|
78
|
-
| `CeroBase` | `@cero-base/core` | Main database class
|
|
79
|
-
| `Room` | `@cero-base/core` | Room wrapper with
|
|
80
|
-
| `Collection` | `@cero-base/core` | Collection with put/get/del/sub
|
|
81
|
-
| `Batch` | `@cero-base/core` | Buffered multi-collection writes
|
|
82
|
-
| `
|
|
83
|
-
| `
|
|
84
|
-
| `build` | `@cero-base/core/builder` | Build spec from schema |
|
|
172
|
+
| Export | Path | Description |
|
|
173
|
+
| ------------ | ------------------------- | ----------------------------------------------------------------------------------------------------------------- |
|
|
174
|
+
| `CeroBase` | `@cero-base/core` | Main database class — `.collection(name)`, `.local.collection(name)`, `.invite(opts?)`, `.join(input)`, `.seed()` |
|
|
175
|
+
| `Room` | `@cero-base/core` | Room wrapper with `.collection(name)` + `.invite(opts?)` |
|
|
176
|
+
| `Collection` | `@cero-base/core` | Collection with put/get/del/sub/dispatch |
|
|
177
|
+
| `Batch` | `@cero-base/core` | Buffered multi-collection writes |
|
|
178
|
+
| `t` | `@cero-base/core/schema` | Type helpers (`t.string`, `t.optional`, `t.array`, …) |
|
|
179
|
+
| `build` | `@cero-base/core/builder` | Build spec from schema |
|
|
85
180
|
|
|
86
181
|
## License
|
|
87
182
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cero-base/core",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "P2P database with collection-based CRUD and RPC",
|
|
5
5
|
"files": [
|
|
6
6
|
"src",
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"test": "brittle test/*.js"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
|
-
"@lekinox/cero": "^1.
|
|
26
|
+
"@lekinox/cero": "^1.3.1",
|
|
27
27
|
"compact-encoding": "^2.19.1",
|
|
28
28
|
"ready-resource": "^1.0.2",
|
|
29
29
|
"safety-catch": "^1.0.2"
|
package/src/lib/base.js
CHANGED
|
@@ -28,9 +28,16 @@ export class CeroBase extends Cero {
|
|
|
28
28
|
|
|
29
29
|
this.schema = schema
|
|
30
30
|
this._baseUpdateHandler = null
|
|
31
|
+
|
|
32
|
+
// Expose cero's local-scope tables (rooms cache, files, settings, …) and
|
|
33
|
+
// user-declared `scope: 'local'` collections via the unified Collection API.
|
|
34
|
+
// db.local already has cero's find/insert/delete; we just add .collection.
|
|
35
|
+
if (this.local) {
|
|
36
|
+
this.local.collection = (name) => new Collection(this, name, { local: true })
|
|
37
|
+
}
|
|
38
|
+
|
|
31
39
|
this.once('ready', () => {
|
|
32
40
|
this._bindIdentityBase()
|
|
33
|
-
this._initFacades()
|
|
34
41
|
})
|
|
35
42
|
}
|
|
36
43
|
|
|
@@ -59,32 +66,11 @@ export class CeroBase extends Cero {
|
|
|
59
66
|
return this.pair(input, opts)
|
|
60
67
|
}
|
|
61
68
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
if (
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
set: (data) => p.set(data),
|
|
68
|
-
sub: () => p.subscribe()
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
const d = this.identity?.devices
|
|
73
|
-
if (d) {
|
|
74
|
-
this._devices = {
|
|
75
|
-
get: (query) => d.list(query),
|
|
76
|
-
sub: (id) => d.subscribe(id),
|
|
77
|
-
invite: () => this.invites.create().then(({ invite }) => invite)
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
get profile() {
|
|
83
|
-
return this._profile || null
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
get devices() {
|
|
87
|
-
return this._devices || null
|
|
69
|
+
/** Create a device-pairing invite string for this identity. */
|
|
70
|
+
async invite(opts = {}) {
|
|
71
|
+
if (!this.opened) await this.ready()
|
|
72
|
+
const { invite } = await this.invites.create(opts)
|
|
73
|
+
return invite
|
|
88
74
|
}
|
|
89
75
|
|
|
90
76
|
collection(name) {
|
package/src/lib/batch.js
CHANGED
|
@@ -24,7 +24,10 @@ export class Batch {
|
|
|
24
24
|
data.createdAt = data.createdAt ?? Date.now()
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
// If cero doesn't have add-${type}, fall back to set-${type} which is
|
|
28
|
+
// itself an upsert.
|
|
29
|
+
const action = this.schema.hasAdd(name) ? 'add' : 'set'
|
|
30
|
+
this.operations.push({ action, name, data, scope })
|
|
28
31
|
return data
|
|
29
32
|
}
|
|
30
33
|
|
|
@@ -35,7 +38,7 @@ export class Batch {
|
|
|
35
38
|
const [existing] = await col.get(query)
|
|
36
39
|
if (!existing) return false
|
|
37
40
|
|
|
38
|
-
const keys = this.schema.
|
|
41
|
+
const keys = this.schema.keysOf(name)
|
|
39
42
|
const keyData = keys.reduce((acc, k) => ((acc[k] = existing[k]), acc), {})
|
|
40
43
|
|
|
41
44
|
this.operations.push({ action: 'del', name, data: keyData, scope })
|
package/src/lib/collection.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Readable } from 'streamx'
|
|
2
2
|
import { randomId } from './crypto.js'
|
|
3
3
|
import { debounce, isLocal, isPrivate } from './utils.js'
|
|
4
|
+
import { SCOPE_LOCAL } from './constants.js'
|
|
4
5
|
|
|
5
6
|
/** Route a write operation based on scope */
|
|
6
7
|
function writeOp(owner, scope, path, ns, op, data, isDel) {
|
|
@@ -25,28 +26,62 @@ function writeOp(owner, scope, path, ns, op, data, isDel) {
|
|
|
25
26
|
* tasks.sub().on('data', cb)
|
|
26
27
|
*/
|
|
27
28
|
export class Collection {
|
|
28
|
-
constructor(owner, name) {
|
|
29
|
+
constructor(owner, name, opts = {}) {
|
|
29
30
|
this.owner = owner
|
|
30
31
|
this.schema = owner.schema
|
|
32
|
+
this.name = name
|
|
33
|
+
|
|
34
|
+
if (opts.local) {
|
|
35
|
+
// Forced local resolution — used by db.local.collection(name) to reach
|
|
36
|
+
// cero's local-scope built-ins (rooms cache, files, etc.) and user-
|
|
37
|
+
// declared `scope: 'local'` collections via the same accessor.
|
|
38
|
+
const type = this.schema.localTypeOf(name)
|
|
39
|
+
if (!type) throw new Error(`'${name}' is not a local collection`)
|
|
40
|
+
this.scope = SCOPE_LOCAL
|
|
41
|
+
this.type = type
|
|
42
|
+
this.keys = this.schema.localKeysOf(name)
|
|
43
|
+
this.path = `@local/${name}`
|
|
44
|
+
this.ns = '@local'
|
|
45
|
+
this.hasAdd = true // local merges via direct insert; no dispatch involved
|
|
46
|
+
this.timestamps = false
|
|
47
|
+
this.counter = false
|
|
48
|
+
this.indexes = null
|
|
49
|
+
return
|
|
50
|
+
}
|
|
31
51
|
|
|
32
52
|
const def = this.schema.collections[name]
|
|
33
53
|
this.scope = this.schema.getScope(name)
|
|
34
|
-
this.name = name
|
|
35
54
|
this.type = this.schema.types.get(name)
|
|
36
55
|
this.path = this.schema.getPath(name)
|
|
37
56
|
this.ns = this.path.split('/')[0]
|
|
38
|
-
this.keys =
|
|
57
|
+
this.keys = this.schema.keysOf(name)
|
|
58
|
+
this.hasAdd = this.schema.hasAdd(name)
|
|
39
59
|
this.timestamps = this.schema.hasTimestamps(name)
|
|
40
60
|
this.counter = !isLocal(this.scope) // all non-local collections have counter
|
|
41
|
-
this.indexes = def
|
|
42
|
-
|
|
61
|
+
this.indexes = def?.indexes || null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
get db() {
|
|
65
|
+
if (isLocal(this.scope)) return null
|
|
66
|
+
return isPrivate(this.scope) ? this.owner.identity.db : this.owner.room.db
|
|
43
67
|
}
|
|
44
68
|
|
|
45
69
|
/** Upsert — insert if new, update if exists */
|
|
46
70
|
async put(doc) {
|
|
47
|
-
const
|
|
48
|
-
const
|
|
49
|
-
|
|
71
|
+
const isNew = !doc.id
|
|
72
|
+
const data = isNew ? { ...doc, id: randomId() } : doc
|
|
73
|
+
|
|
74
|
+
// If cero doesn't have add-${type} for this built-in (profile, settings),
|
|
75
|
+
// there's no auto-incrementing index — set-${type} is the only path and
|
|
76
|
+
// is itself an idempotent upsert.
|
|
77
|
+
if (!this.hasAdd) {
|
|
78
|
+
return this._write(`set-${this.type}`, data)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Freshly randomized id can't possibly exist — skip the lookup.
|
|
82
|
+
const existing = isNew
|
|
83
|
+
? null
|
|
84
|
+
: await this._getOne(this.keys.reduce((acc, k) => ((acc[k] = data[k]), acc), {}))
|
|
50
85
|
|
|
51
86
|
if (existing) {
|
|
52
87
|
const updated = { ...existing, ...data }
|
|
@@ -118,6 +153,15 @@ export class Collection {
|
|
|
118
153
|
return true
|
|
119
154
|
}
|
|
120
155
|
|
|
156
|
+
/** Dispatch a custom op (registered via the schema's handlers) on this collection */
|
|
157
|
+
async dispatch(op, data) {
|
|
158
|
+
if (isLocal(this.scope)) {
|
|
159
|
+
throw new Error(`Cannot dispatch on local collection '${this.name}'`)
|
|
160
|
+
}
|
|
161
|
+
const target = isPrivate(this.scope) ? this.owner.identity : this.owner.room
|
|
162
|
+
return target.dispatch(`${this.ns}/${op}`, data)
|
|
163
|
+
}
|
|
164
|
+
|
|
121
165
|
/** Subscribe — returns Readable stream of query results */
|
|
122
166
|
sub(query = {}, opts = {}) {
|
|
123
167
|
const stream = new Readable()
|
package/src/lib/room.js
CHANGED
|
@@ -64,10 +64,6 @@ export class Room extends ReadyResource {
|
|
|
64
64
|
this.room = await this.db.rooms.open(this._arg)
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
this.profile = withSub(this.room.profile)
|
|
68
|
-
this.members = withSub(this.room.members)
|
|
69
|
-
this.invites = withSub(this.room.invites)
|
|
70
|
-
|
|
71
67
|
this.room.on('update', () => this.emit('update'))
|
|
72
68
|
}
|
|
73
69
|
|
|
@@ -96,9 +92,9 @@ export class Room extends ReadyResource {
|
|
|
96
92
|
}
|
|
97
93
|
|
|
98
94
|
/** Shorthand — create invite and return the string */
|
|
99
|
-
async invite() {
|
|
95
|
+
async invite(opts = {}) {
|
|
100
96
|
if (!this.opened) await this.ready()
|
|
101
|
-
const { invite } = await this.room.invites.create()
|
|
97
|
+
const { invite } = await this.room.invites.create(opts)
|
|
102
98
|
return invite
|
|
103
99
|
}
|
|
104
100
|
|
|
@@ -147,10 +143,3 @@ function detectType(input) {
|
|
|
147
143
|
|
|
148
144
|
return 'invite'
|
|
149
145
|
}
|
|
150
|
-
|
|
151
|
-
function withSub(obj) {
|
|
152
|
-
if (!obj) return obj
|
|
153
|
-
const wrapped = { ...obj, sub: obj.subscribe.bind(obj) }
|
|
154
|
-
if (obj.list) wrapped.get = obj.list.bind(obj)
|
|
155
|
-
return wrapped
|
|
156
|
-
}
|
package/src/lib/schema.js
CHANGED
|
@@ -1,6 +1,86 @@
|
|
|
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'
|
|
1
4
|
import { singular, toFields, scopeNs, isLocal, isPrivate, isShared } from './utils.js'
|
|
2
5
|
import { SCOPE_LOCAL, SCOPE_PRIVATE, SCOPE_SHARED } from './constants.js'
|
|
3
6
|
|
|
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
|
+
}
|
|
78
|
+
|
|
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
|
+
}
|
|
83
|
+
|
|
4
84
|
/**
|
|
5
85
|
* Type helpers for defining schema fields
|
|
6
86
|
* Usage: { name: t.string, count: t.int, active: t.bool }
|
|
@@ -45,9 +125,32 @@ t.schema = defineSchema
|
|
|
45
125
|
|
|
46
126
|
export function defineSchema(collections, opts = {}) {
|
|
47
127
|
const entries = Object.entries(collections)
|
|
48
|
-
const types = new Map(entries.map(([name]) => [name, singular(name)]))
|
|
49
128
|
|
|
50
|
-
|
|
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
|
|
51
154
|
const localEntries = entries.filter((e) => getScope(e) === SCOPE_LOCAL)
|
|
52
155
|
const privateEntries = entries.filter((e) => getScope(e) === SCOPE_PRIVATE)
|
|
53
156
|
const sharedEntries = entries.filter((e) => getScope(e) === SCOPE_SHARED)
|
|
@@ -60,10 +163,76 @@ export function defineSchema(collections, opts = {}) {
|
|
|
60
163
|
types,
|
|
61
164
|
|
|
62
165
|
getScope(collection) {
|
|
166
|
+
if (BUILTINS[collection]) return BUILTINS[collection].scope
|
|
63
167
|
const def = collections[collection]
|
|
64
168
|
return def ? def.scope || SCOPE_PRIVATE : null
|
|
65
169
|
},
|
|
66
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
|
+
|
|
67
236
|
isLocal(collection) {
|
|
68
237
|
return isLocal(this.getScope(collection))
|
|
69
238
|
},
|
|
@@ -131,6 +300,51 @@ export function defineSchema(collections, opts = {}) {
|
|
|
131
300
|
const local = scope === SCOPE_LOCAL
|
|
132
301
|
const ns = nsName || scopeNs(scope)
|
|
133
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
|
+
|
|
134
348
|
const type = types.get(name)
|
|
135
349
|
const fields = schemaFields(def)
|
|
136
350
|
const typePath = `@${ns}/${type}`
|
|
@@ -157,6 +371,29 @@ export function defineSchema(collections, opts = {}) {
|
|
|
157
371
|
register.dispatch({ name: `set-${type}`, requestType: typePath })
|
|
158
372
|
register.dispatch({ name: `del-${type}`, requestType: `@${ns}/del-${type}` })
|
|
159
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
|
+
}
|
|
160
397
|
}
|
|
161
398
|
},
|
|
162
399
|
|
|
@@ -165,6 +402,16 @@ export function defineSchema(collections, opts = {}) {
|
|
|
165
402
|
const handlers = {}
|
|
166
403
|
const p = (name) => `@${nsFor(scope)}/${name}`
|
|
167
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
|
+
|
|
168
415
|
const type = types.get(name)
|
|
169
416
|
const path = p(name)
|
|
170
417
|
const keys = def.key || ['id']
|