@cero-base/core 0.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 ADDED
@@ -0,0 +1,88 @@
1
+ # @cero-base/core
2
+
3
+ Collection-based CRUD layer on top of [cero](https://github.com/lekinox/cero). Define your schema, get `put/get/del/sub` across three scopes with built-in pagination.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install @cero-base/core
9
+ ```
10
+
11
+ ## Schema
12
+
13
+ ```js
14
+ import { t } from '@cero-base/core/schema'
15
+
16
+ export const schema = t.schema(
17
+ {
18
+ drafts: { fields: { id: t.string, content: t.string }, key: ['id'], scope: 'local' },
19
+ settings: { fields: { key: t.string, value: t.json }, key: ['key'] },
20
+ messages: {
21
+ fields: { id: t.string, text: t.string, memberId: t.string },
22
+ key: ['id'],
23
+ scope: 'shared',
24
+ timestamps: true
25
+ }
26
+ },
27
+ { namespace: 'chat' }
28
+ )
29
+ ```
30
+
31
+ | Scope | Replication |
32
+ | ------------------- | --------------------- |
33
+ | `local` | Device-only |
34
+ | `private` (default) | Across paired devices |
35
+ | `shared` | Between room members |
36
+
37
+ ## Usage
38
+
39
+ ```js
40
+ import { CeroBase } from '@cero-base/core'
41
+
42
+ const db = new CeroBase('./data', schema, spec)
43
+ await db.ready()
44
+
45
+ // Local/private collections
46
+ const drafts = db.collection('drafts')
47
+ await drafts.put({ content: 'WIP' })
48
+ await drafts.get()
49
+ await drafts.del({ id: 'abc' })
50
+ drafts.sub().on('data', console.log)
51
+
52
+ // Rooms (shared collections)
53
+ const room = await db.rooms.open()
54
+ const messages = room.collection('messages')
55
+ await messages.put({ text: 'Hello!', memberId: db.id })
56
+
57
+ // Batch writes
58
+ const batch = db.batch()
59
+ batch.put('settings', { key: 'a', value: '1' })
60
+ batch.put('settings', { key: 'b', value: '2' })
61
+ await batch.flush()
62
+ ```
63
+
64
+ ## Build
65
+
66
+ ```js
67
+ import { build } from '@cero-base/core/builder'
68
+
69
+ await build('./spec', schema)
70
+ ```
71
+
72
+ For RPC support, use `@cero-base/rpc`'s build which includes RPC types automatically.
73
+
74
+ ## Exports
75
+
76
+ | Export | Path | Description |
77
+ | ------------ | ------------------------- | ----------------------------------------------- |
78
+ | `CeroBase` | `@cero-base/core` | Main database class |
79
+ | `Room` | `@cero-base/core` | Room wrapper with collections, members, invites |
80
+ | `Collection` | `@cero-base/core` | Collection with put/get/del/sub |
81
+ | `Batch` | `@cero-base/core` | Buffered multi-collection writes |
82
+ | `Schema` | `@cero-base/core` | Schema class |
83
+ | `t` | `@cero-base/core/schema` | Type helpers for defining schemas |
84
+ | `build` | `@cero-base/core/builder` | Build spec from schema |
85
+
86
+ ## License
87
+
88
+ Apache-2.0
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@cero-base/core",
3
+ "version": "0.0.1",
4
+ "description": "P2P database with collection-based CRUD and RPC",
5
+ "files": [
6
+ "src",
7
+ "README.md"
8
+ ],
9
+ "publishConfig": {
10
+ "access": "public"
11
+ },
12
+ "type": "module",
13
+ "main": "src/index.js",
14
+ "exports": {
15
+ ".": "./src/index.js",
16
+ "./schema": "./src/lib/schema.js",
17
+ "./builder": "./src/lib/builder.js",
18
+ "./constants": "./src/lib/constants.js"
19
+ },
20
+ "scripts": {
21
+ "build:test": "rm -rf test/fixture/spec && node test/fixture/build.js",
22
+ "pretest": "npm run build:test",
23
+ "test": "brittle test/*.js"
24
+ },
25
+ "dependencies": {
26
+ "@lekinox/cero": "^1.0.10",
27
+ "compact-encoding": "^2.19.1",
28
+ "ready-resource": "^1.0.2",
29
+ "safety-catch": "^1.0.2"
30
+ },
31
+ "devDependencies": {
32
+ "@hyperswarm/testnet": "^3.1.4",
33
+ "autobase-test-helpers": "^3.1.0",
34
+ "brittle": "^3.7.0",
35
+ "corestore": "^7.9.1",
36
+ "duplex-through": "^1.0.2",
37
+ "hrpc": "^4.3.0",
38
+ "hyperdb": "^6.3.0",
39
+ "hyperdispatch": "^1.5.1",
40
+ "hyperschema": "^1.20.1"
41
+ },
42
+ "license": "Apache-2.0",
43
+ "repository": {
44
+ "type": "git",
45
+ "url": "https://github.com/lekinox/cero-base.git",
46
+ "directory": "packages/core"
47
+ }
48
+ }
package/src/index.js ADDED
@@ -0,0 +1 @@
1
+ export * from './lib/index.js'
@@ -0,0 +1,98 @@
1
+ import { Cero } from '@lekinox/cero'
2
+ import { SCOPE_PRIVATE, SCOPE_SHARED } from '../lib/constants.js'
3
+ import { Collection } from '../lib/collection.js'
4
+ import { Batch } from '../lib/batch.js'
5
+
6
+ const SEED_WORD_COUNT = 12
7
+
8
+ export class CeroBase extends Cero {
9
+ constructor(dir, schema, spec, opts = {}) {
10
+ super(
11
+ dir,
12
+ {
13
+ ...spec,
14
+ identity: {
15
+ ...spec.identity,
16
+ routes: schema.routes(SCOPE_PRIVATE),
17
+ counters: schema.counterNames(SCOPE_PRIVATE)
18
+ },
19
+ room: {
20
+ ...spec.room,
21
+ ns: schema.namespace || undefined,
22
+ routes: schema.routes(SCOPE_SHARED),
23
+ counters: schema.counterNames(SCOPE_SHARED)
24
+ }
25
+ },
26
+ opts
27
+ )
28
+
29
+ this.schema = schema
30
+ this._baseUpdateHandler = null
31
+ this.once('ready', () => {
32
+ this._bindIdentityBase()
33
+ this._initFacades()
34
+ })
35
+ }
36
+
37
+ _bindIdentityBase() {
38
+ if (this._baseUpdateHandler && this._prevBase) {
39
+ this._prevBase.removeListener('update', this._baseUpdateHandler)
40
+ }
41
+ this._baseUpdateHandler = () => this.emit('update')
42
+ this._prevBase = this.identity?.base
43
+ if (this._prevBase) this._prevBase.on('update', this._baseUpdateHandler)
44
+ }
45
+
46
+ async _swapIdentity(handler, opts) {
47
+ const result = await super._swapIdentity(handler, opts)
48
+ this._bindIdentityBase()
49
+ return result
50
+ }
51
+
52
+ seed() {
53
+ return this.identity?.seed?.get() || null
54
+ }
55
+
56
+ async join(input, opts = {}) {
57
+ const isSeed = typeof input === 'string' && input.trim().split(/\s+/).length >= SEED_WORD_COUNT
58
+ if (isSeed) return this.recover(input, opts)
59
+ return this.pair(input, opts)
60
+ }
61
+
62
+ _initFacades() {
63
+ const p = this.identity?.profile
64
+ if (p) {
65
+ this._profile = {
66
+ get: () => p.get(),
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
88
+ }
89
+
90
+ collection(name) {
91
+ this.schema.require(name, 'db')
92
+ return new Collection(this, name)
93
+ }
94
+
95
+ batch() {
96
+ return new Batch(this)
97
+ }
98
+ }
@@ -0,0 +1,95 @@
1
+ import { randomId } from './crypto.js'
2
+ import { isLocal, isPrivate, isShared } from './utils.js'
3
+
4
+ /**
5
+ * Batch — buffer multiple put/del operations and flush as single dispatch
6
+ *
7
+ * @example
8
+ * const batch = db.batch()
9
+ * batch.put('settings', { key: 'a', value: '1' })
10
+ * await batch.flush()
11
+ */
12
+ export class Batch {
13
+ constructor(owner) {
14
+ this.owner = owner
15
+ this.schema = owner.schema
16
+ this.operations = []
17
+ }
18
+
19
+ put(name, doc) {
20
+ const scope = this._validate(name)
21
+
22
+ const data = doc.id ? doc : { ...doc, id: randomId() }
23
+ if (this.schema.hasTimestamps(name)) {
24
+ data.createdAt = data.createdAt ?? Date.now()
25
+ }
26
+
27
+ this.operations.push({ action: 'add', name, data, scope })
28
+ return data
29
+ }
30
+
31
+ async del(name, query) {
32
+ const scope = this._validate(name)
33
+
34
+ const col = this.owner.collection(name)
35
+ const [existing] = await col.get(query)
36
+ if (!existing) return false
37
+
38
+ const keys = this.schema.collections[name]?.key || ['id']
39
+ const keyData = keys.reduce((acc, k) => ((acc[k] = existing[k]), acc), {})
40
+
41
+ this.operations.push({ action: 'del', name, data: keyData, scope })
42
+ return true
43
+ }
44
+
45
+ async flush() {
46
+ if (this.operations.length === 0) return
47
+
48
+ const localOps = this.operations.filter((op) => isLocal(op.scope))
49
+ const privateOps = this.operations.filter((op) => isPrivate(op.scope))
50
+ const sharedOps = this.operations.filter((op) => isShared(op.scope))
51
+
52
+ for (const op of localOps) {
53
+ const path = this.schema.getPath(op.name)
54
+ if (op.action === 'del') {
55
+ await this.owner.local.delete(path, op.data)
56
+ } else {
57
+ await this.owner.local.insert(path, op.data)
58
+ }
59
+ }
60
+
61
+ if (privateOps.length > 0) {
62
+ const identity = this.owner.identity
63
+ const ns = identity.ns
64
+ await identity.dispatch(`@${ns}/batch`, {
65
+ operations: privateOps.map((op) => {
66
+ const route = `@${ns}/${op.action}-${this.schema.types.get(op.name)}`
67
+ return identity.spec.dispatch.encode(route, op.data)
68
+ })
69
+ })
70
+ }
71
+
72
+ if (sharedOps.length > 0) {
73
+ const room = this.owner.room
74
+ const ns = room.ns
75
+ await room.dispatch(`@${ns}/batch`, {
76
+ operations: sharedOps.map((op) => {
77
+ const route = `@${ns}/${op.action}-${this.schema.types.get(op.name)}`
78
+ return room.spec.dispatch.encode(route, op.data)
79
+ })
80
+ })
81
+ }
82
+
83
+ this.operations = []
84
+
85
+ if (localOps.length > 0) {
86
+ this.owner.emit('update')
87
+ }
88
+ }
89
+
90
+ _validate(name) {
91
+ const scope = this.schema.getScope(name)
92
+ if (!scope) throw new Error(`Unknown collection: '${name}'`)
93
+ return scope
94
+ }
95
+ }
@@ -0,0 +1,24 @@
1
+ import { build as ceroBuild } from '@lekinox/cero'
2
+
3
+ /**
4
+ * Build all specs in one output directory.
5
+ *
6
+ * @param {string} dir - Output directory
7
+ * @param {object} [schema] - Schema from t.schema()
8
+ * @param {object} [opts] - Options
9
+ * @param {function} [opts.rpc] - RPC builder function ({ register, ns }) => void
10
+ */
11
+ export async function build(dir, schema, opts = {}) {
12
+ const ns = schema?.namespace || null
13
+ const builders = {
14
+ local: ({ register, ns }) => schema?.build('local', register, ns),
15
+ identity: ({ register, ns }) => schema?.build('private', register, ns),
16
+ room: ({ register, ns }) => schema?.build('shared', register, ns),
17
+ ...(opts.rpc ? { rpc: opts.rpc } : {})
18
+ }
19
+ if (ns) {
20
+ await ceroBuild(dir, ns, builders)
21
+ } else {
22
+ await ceroBuild(dir, builders)
23
+ }
24
+ }
@@ -0,0 +1,208 @@
1
+ import { Readable } from 'streamx'
2
+ import { randomId } from './crypto.js'
3
+ import { debounce, isLocal, isPrivate } from './utils.js'
4
+
5
+ /** Route a write operation based on scope */
6
+ function writeOp(owner, scope, path, ns, op, data, isDel) {
7
+ if (isLocal(scope)) {
8
+ const method = isDel ? 'delete' : 'insert'
9
+ return owner.local[method](path, data).then(() => owner.emit('update'))
10
+ }
11
+ const target = isPrivate(scope) ? owner.identity : owner.room
12
+ return target.dispatch(`${ns}/${op}`, data)
13
+ }
14
+
15
+ /**
16
+ * Collection — 4 verbs: put, get, del, sub
17
+ *
18
+ * @example
19
+ * const tasks = room.collection('tasks')
20
+ *
21
+ * await tasks.put({ text: 'Buy milk', done: false })
22
+ * await tasks.get() // all docs
23
+ * await tasks.get({ done: false }) // filtered
24
+ * await tasks.del({ id: 'abc' })
25
+ * tasks.sub().on('data', cb)
26
+ */
27
+ export class Collection {
28
+ constructor(owner, name) {
29
+ this.owner = owner
30
+ this.schema = owner.schema
31
+
32
+ const def = this.schema.collections[name]
33
+ this.scope = this.schema.getScope(name)
34
+ this.name = name
35
+ this.type = this.schema.types.get(name)
36
+ this.path = this.schema.getPath(name)
37
+ this.ns = this.path.split('/')[0]
38
+ this.keys = def.key || ['id']
39
+ this.timestamps = this.schema.hasTimestamps(name)
40
+ this.counter = !isLocal(this.scope) // all non-local collections have counter
41
+ this.indexes = def.indexes || null
42
+ this.db = isLocal(this.scope) ? null : isPrivate(this.scope) ? owner.identity.db : owner.room.db
43
+ }
44
+
45
+ /** Upsert — insert if new, update if exists */
46
+ async put(doc) {
47
+ const data = doc.id ? doc : { ...doc, id: randomId() }
48
+ const keyQuery = this.keys.reduce((acc, k) => ((acc[k] = data[k]), acc), {})
49
+ const existing = data.id ? await this._getOne(keyQuery) : null
50
+
51
+ if (existing) {
52
+ const updated = { ...existing, ...data }
53
+ if (this.timestamps) updated.updatedAt = Date.now()
54
+ return this._write(`set-${this.type}`, updated)
55
+ }
56
+
57
+ if (this.timestamps) data.createdAt = data.createdAt ?? Date.now()
58
+ return this._write(`add-${this.type}`, data)
59
+ }
60
+
61
+ /** Get docs — no query returns all, query filters, supports range queries on index */
62
+ async get(query = {}, opts = {}) {
63
+ if (isLocal(this.scope)) {
64
+ const all = await this.owner.local.find(this.path, opts)
65
+ return filter(all, query)
66
+ }
67
+
68
+ if (
69
+ this.counter &&
70
+ (typeof query.gte === 'number' ||
71
+ typeof query.lte === 'number' ||
72
+ typeof query.gt === 'number' ||
73
+ typeof query.lt === 'number')
74
+ ) {
75
+ return this._rangeByIndex(query, opts)
76
+ }
77
+
78
+ return findDocs(this.db, this.path, this.indexes, this.name, query, opts)
79
+ }
80
+
81
+ /** Get count */
82
+ async count() {
83
+ if (!this.counter) return (await this.get()).length
84
+
85
+ const counterPath = `${this.ns}/counters`
86
+ const snapshot = this.db.snapshot()
87
+ try {
88
+ const counter = await snapshot.get(counterPath, { name: this.name })
89
+ return counter?.value || 0
90
+ } finally {
91
+ await snapshot.close()
92
+ }
93
+ }
94
+
95
+ /** @private Range query on index field via the by-index index */
96
+ async _rangeByIndex(query, opts = {}) {
97
+ const indexPath = `${this.path}-by-index`
98
+ const snapshot = this.db.snapshot()
99
+ try {
100
+ const rangeQuery = {}
101
+ if (query.gte !== undefined) rangeQuery.gte = { index: query.gte }
102
+ if (query.lte !== undefined) rangeQuery.lte = { index: query.lte }
103
+ if (query.gt !== undefined) rangeQuery.gt = { index: query.gt }
104
+ if (query.lt !== undefined) rangeQuery.lt = { index: query.lt }
105
+ return snapshot.find(indexPath, { ...rangeQuery, ...opts }).toArray()
106
+ } finally {
107
+ await snapshot.close()
108
+ }
109
+ }
110
+
111
+ /** Delete a document by query */
112
+ async del(query) {
113
+ const [existing] = await this.get(query)
114
+ if (!existing) return false
115
+
116
+ const keyData = this.keys.reduce((acc, k) => ((acc[k] = existing[k]), acc), {})
117
+ await this._write(`del-${this.type}`, keyData, true)
118
+ return true
119
+ }
120
+
121
+ /** Subscribe — returns Readable stream of query results */
122
+ sub(query = {}, opts = {}) {
123
+ const stream = new Readable()
124
+ const emitter = this.owner
125
+
126
+ const refresh = debounce(async () => {
127
+ try {
128
+ const results = await this.get(query, opts)
129
+ stream.push(results)
130
+ } catch (err) {
131
+ stream.destroy(err)
132
+ }
133
+ })
134
+
135
+ refresh()
136
+ emitter.on('update', refresh)
137
+
138
+ const destroy = stream.destroy.bind(stream)
139
+ stream.destroy = (err) => {
140
+ emitter.off('update', refresh)
141
+ return destroy(err)
142
+ }
143
+
144
+ return stream
145
+ }
146
+
147
+ /** @private Single-doc lookup by key (used internally by put/del) */
148
+ async _getOne(query) {
149
+ if (isLocal(this.scope)) {
150
+ return this.owner.local.get(this.path, query)
151
+ }
152
+ const snapshot = this.db.snapshot()
153
+ try {
154
+ return await snapshot.get(this.path, query)
155
+ } finally {
156
+ await snapshot.close()
157
+ }
158
+ }
159
+
160
+ /** @private Route write to local/identity/room */
161
+ async _write(op, data, isDel = false) {
162
+ await writeOp(this.owner, this.scope, this.path, this.ns, op, data, isDel)
163
+ return isDel ? true : data
164
+ }
165
+ }
166
+
167
+ // ==================== Private Helpers ====================
168
+
169
+ function filter(all, query) {
170
+ const entries = Object.entries(query)
171
+ if (entries.length === 0) return all
172
+ return all.filter((doc) => entries.every(([k, v]) => doc[k] === v))
173
+ }
174
+
175
+ async function findDocs(db, path, indexes, name, query, opts) {
176
+ const snapshot = db.snapshot()
177
+
178
+ try {
179
+ const rangeKey = query.gte || query.gt || query.lte || query.lt
180
+ if (rangeKey) {
181
+ const index = matchIndex(path, indexes, rangeKey)
182
+ if (!index) throw new Error(`No index found for range query on ${name}`)
183
+ return snapshot.find(index, { ...query, ...opts }).toArray()
184
+ }
185
+
186
+ const index = matchIndex(path, indexes, query)
187
+ if (index) {
188
+ return snapshot.find(index, { gte: query, lte: query, ...opts }).toArray()
189
+ }
190
+
191
+ const all = await snapshot.find(path, opts).toArray()
192
+ return filter(all, query)
193
+ } finally {
194
+ await snapshot.close()
195
+ }
196
+ }
197
+
198
+ function matchIndex(path, indexes, query) {
199
+ if (!indexes) return null
200
+
201
+ const firstField = Object.keys(query)[0]
202
+ if (!firstField) return null
203
+
204
+ for (const [name, fields] of Object.entries(indexes)) {
205
+ if (fields[0] === firstField) return `${path}-${name}`
206
+ }
207
+ return null
208
+ }
@@ -0,0 +1,4 @@
1
+ // User-facing scope names for cero-base schemas
2
+ export const SCOPE_LOCAL = 'local'
3
+ export const SCOPE_PRIVATE = 'private'
4
+ export const SCOPE_SHARED = 'shared'
@@ -0,0 +1,6 @@
1
+ import crypto from 'hypercore-crypto'
2
+ import z32 from 'z32'
3
+
4
+ export function randomId() {
5
+ return z32.encode(crypto.randomBytes(16))
6
+ }
@@ -0,0 +1,6 @@
1
+ export * from './base.js'
2
+ export * from './room.js'
3
+ export * from './batch.js'
4
+ export * from './collection.js'
5
+ export * from './schema.js'
6
+ export * from './constants.js'
@@ -0,0 +1,156 @@
1
+ import ReadyResource from 'ready-resource'
2
+ import safetyCatch from 'safety-catch'
3
+ import HypercoreId from 'hypercore-id-encoding'
4
+
5
+ import { Collection } from './collection.js'
6
+ import { Batch } from './batch.js'
7
+
8
+ /**
9
+ * Room - Constructable ReadyResource for shared collections
10
+ *
11
+ * @example
12
+ * const room = new Room(db) // create new
13
+ * const room = new Room(db, roomId) // open existing
14
+ * const room = new Room(db, invite) // join via invite
15
+ * await room.ready()
16
+ *
17
+ * const tasks = room.collection('tasks')
18
+ * await tasks.insert({ text: 'Hello', done: false })
19
+ *
20
+ * @extends ReadyResource
21
+ */
22
+ export class Room extends ReadyResource {
23
+ /**
24
+ * @param {Cero} db - Cero instance
25
+ * @param {string} [arg] - Room ID or invite string (omit to create new)
26
+ * @param {object} [opts] - Options
27
+ * @param {AbortSignal} [opts.signal] - AbortSignal for cancellation
28
+ */
29
+ constructor(db, arg, opts = {}) {
30
+ super()
31
+
32
+ this.db = db
33
+ this.schema = db.schema
34
+
35
+ this._arg = arg
36
+ this._opts = opts
37
+
38
+ /** @type {import('cero').Room|null} - The underlying cero room */
39
+ this.room = null
40
+
41
+ this.ready().catch(safetyCatch)
42
+ }
43
+
44
+ /** Room ID (z32) */
45
+ get id() {
46
+ return this.room?.id
47
+ }
48
+
49
+ /** Room public key */
50
+ get publicKey() {
51
+ return this.room?.key
52
+ }
53
+
54
+ async _open() {
55
+ if (!this.db.opened) await this.db.ready()
56
+
57
+ const type = detectType(this._arg)
58
+
59
+ if (type === 'create') {
60
+ this.room = await this.db.rooms.create(this._opts)
61
+ } else if (type === 'invite') {
62
+ this.room = await this.db.rooms.pair(this._arg, this._opts)
63
+ } else {
64
+ this.room = await this.db.rooms.open(this._arg)
65
+ }
66
+
67
+ this.profile = withSub(this.room.profile)
68
+ this.members = withSub(this.room.members)
69
+ this.invites = withSub(this.room.invites)
70
+
71
+ this.room.on('update', () => this.emit('update'))
72
+ }
73
+
74
+ async _close() {
75
+ if (!this.room) return
76
+ this.db.rooms.release(this.room)
77
+ this.room = null
78
+ }
79
+
80
+ /**
81
+ * Get a collection handle for shared data
82
+ * @param {string} name - Collection name
83
+ * @returns {Collection}
84
+ */
85
+ collection(name) {
86
+ this.schema.require(name, 'room')
87
+ return new Collection(this, name)
88
+ }
89
+
90
+ /**
91
+ * Create a batch for multiple operations
92
+ * @returns {Batch}
93
+ */
94
+ batch() {
95
+ return new Batch(this)
96
+ }
97
+
98
+ /** Shorthand — create invite and return the string */
99
+ async invite() {
100
+ if (!this.opened) await this.ready()
101
+ const { invite } = await this.room.invites.create()
102
+ return invite
103
+ }
104
+
105
+ /**
106
+ * Dispatch an operation to the room
107
+ * @param {string} op - Operation path
108
+ * @param {object} data - Operation data
109
+ */
110
+ async dispatch(op, data) {
111
+ if (!this.opened) await this.ready()
112
+ return this.room.dispatch(op, data)
113
+ }
114
+
115
+ /**
116
+ * Async factory — creates room + awaits ready
117
+ * @param {Cero} db - Cero instance
118
+ * @param {string} [arg] - Room ID, invite, or null to create
119
+ * @param {object} [opts] - Options
120
+ * @returns {Promise<Room>}
121
+ */
122
+ static async from(db, arg, opts) {
123
+ const room = new this(db, arg, opts)
124
+ await room.ready()
125
+ return room
126
+ }
127
+ }
128
+
129
+ // ==================== Private Helpers ====================
130
+
131
+ /**
132
+ * Detect input type: nothing (create), id, or invite
133
+ * @private
134
+ */
135
+ function detectType(input) {
136
+ if (!input) return 'create'
137
+
138
+ // z32 is ~52 chars, hex is 64 chars
139
+ if (input.length <= 64) {
140
+ try {
141
+ HypercoreId.decode(input)
142
+ return 'id'
143
+ } catch {
144
+ // Not a valid ID, assume invite
145
+ }
146
+ }
147
+
148
+ return 'invite'
149
+ }
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
+ }
@@ -0,0 +1,203 @@
1
+ import { singular, toFields, scopeNs, isLocal, isPrivate, isShared } from './utils.js'
2
+ import { SCOPE_LOCAL, SCOPE_PRIVATE, SCOPE_SHARED } from './constants.js'
3
+
4
+ /**
5
+ * Type helpers for defining schema fields
6
+ * Usage: { name: t.string, count: t.int, active: t.bool }
7
+ */
8
+ export const t = {
9
+ string: { type: 'string', required: true },
10
+ int: { type: 'int', required: true },
11
+ uint: { type: 'uint', required: true },
12
+ float: { type: 'float', required: true },
13
+ bool: { type: 'bool', required: true },
14
+ buffer: { type: 'buffer', required: true },
15
+ fixed32: { type: 'fixed32', required: true },
16
+ json: { type: 'json', required: true },
17
+
18
+ optional: (field) => ({ ...field, required: false }),
19
+ array: (field) => ({ ...field, array: true }),
20
+ ref: (collection) => ({ type: 'string', required: true, ref: collection })
21
+ }
22
+
23
+ function schemaFields(def) {
24
+ const fields = toFields(def.fields)
25
+ // every type gets index for range queries / pagination (assigned by cero's Base)
26
+ fields.push({ name: 'index', type: 'uint', required: false })
27
+ if (def.timestamps) {
28
+ fields.push({ name: 'createdAt', type: 'uint', required: true })
29
+ fields.push({ name: 'updatedAt', type: 'uint', required: false })
30
+ }
31
+ return fields
32
+ }
33
+
34
+ function inputFields(def) {
35
+ return toFields(def.fields).map((f) => ({ ...f, required: false }))
36
+ }
37
+
38
+ function delFields(def) {
39
+ const keys = {}
40
+ for (const k of def.key || ['id']) keys[k] = def.fields[k]
41
+ return toFields(keys)
42
+ }
43
+
44
+ t.schema = defineSchema
45
+
46
+ export function defineSchema(collections, opts = {}) {
47
+ const entries = Object.entries(collections)
48
+ const types = new Map(entries.map(([name]) => [name, singular(name)]))
49
+
50
+ const getScope = ([, def]) => def.scope || SCOPE_PRIVATE
51
+ const localEntries = entries.filter((e) => getScope(e) === SCOPE_LOCAL)
52
+ const privateEntries = entries.filter((e) => getScope(e) === SCOPE_PRIVATE)
53
+ const sharedEntries = entries.filter((e) => getScope(e) === SCOPE_SHARED)
54
+
55
+ const nsFor = (scope) => (scope === SCOPE_SHARED && opts.namespace) || scopeNs(scope)
56
+
57
+ return {
58
+ namespace: opts.namespace || null,
59
+ collections,
60
+ types,
61
+
62
+ getScope(collection) {
63
+ const def = collections[collection]
64
+ return def ? def.scope || SCOPE_PRIVATE : null
65
+ },
66
+
67
+ isLocal(collection) {
68
+ return isLocal(this.getScope(collection))
69
+ },
70
+ isPrivate(collection) {
71
+ return isPrivate(this.getScope(collection))
72
+ },
73
+ isShared(collection) {
74
+ return isShared(this.getScope(collection))
75
+ },
76
+
77
+ hasTimestamps(collection) {
78
+ return collections[collection]?.timestamps === true
79
+ },
80
+
81
+ hasCounter(collection) {
82
+ return collections[collection]?.counter === true
83
+ },
84
+
85
+ counterNames(scope) {
86
+ const items =
87
+ scope === SCOPE_SHARED
88
+ ? sharedEntries
89
+ : scope === SCOPE_LOCAL
90
+ ? localEntries
91
+ : privateEntries
92
+ // map: type name (singular) → collection name (plural)
93
+ const map = {}
94
+ for (const [name, def] of items) {
95
+ if (def.counter) map[types.get(name)] = name
96
+ }
97
+ return map
98
+ },
99
+
100
+ resolve(collection, prefix) {
101
+ const scope = this.getScope(collection)
102
+ const type = types.get(collection)
103
+ const name = prefix ? `${prefix}-${type}` : type
104
+ return { spec: scopeNs(scope), path: `@${nsFor(scope)}/${name}` }
105
+ },
106
+
107
+ require(collection, context) {
108
+ const scope = this.getScope(collection)
109
+ if (!scope) throw new Error(`Unknown collection: '${collection}'`)
110
+ if (context === 'db' && scope === SCOPE_SHARED) {
111
+ throw new Error(`'${collection}' is shared — use room.collection('${collection}')`)
112
+ }
113
+ if (context === 'room' && scope !== SCOPE_SHARED) {
114
+ throw new Error(`'${collection}' is ${scope} — use db.collection('${collection}')`)
115
+ }
116
+ return scope
117
+ },
118
+
119
+ getPath(collection) {
120
+ const scope = this.getScope(collection)
121
+ return `@${nsFor(scope)}/${collection}`
122
+ },
123
+
124
+ build(scope, register, nsName) {
125
+ const items =
126
+ scope === SCOPE_SHARED
127
+ ? sharedEntries
128
+ : scope === SCOPE_LOCAL
129
+ ? localEntries
130
+ : privateEntries
131
+ const local = scope === SCOPE_LOCAL
132
+ const ns = nsName || scopeNs(scope)
133
+ for (const [name, def] of items) {
134
+ const type = types.get(name)
135
+ const fields = schemaFields(def)
136
+ const typePath = `@${ns}/${type}`
137
+
138
+ register.type({ name: type, compact: false, fields })
139
+ register.type({ name: `input-${type}`, compact: false, fields: inputFields(def) })
140
+ if (!local) register.type({ name: `del-${type}`, compact: false, fields: delFields(def) })
141
+
142
+ register.collection({
143
+ name,
144
+ schema: typePath,
145
+ key: def.key || ['id'],
146
+ counter: !!def.counter
147
+ })
148
+
149
+ if (def.indexes) {
150
+ for (const [idx, idxFields] of Object.entries(def.indexes)) {
151
+ register.index({ name: `${name}-${idx}`, collection: `@${ns}/${name}`, key: idxFields })
152
+ }
153
+ }
154
+
155
+ if (!local) {
156
+ register.dispatch({ name: `add-${type}`, requestType: typePath })
157
+ register.dispatch({ name: `set-${type}`, requestType: typePath })
158
+ register.dispatch({ name: `del-${type}`, requestType: `@${ns}/del-${type}` })
159
+ }
160
+ }
161
+ },
162
+
163
+ routes(scope) {
164
+ const items = scope === SCOPE_PRIVATE ? privateEntries : sharedEntries
165
+ const handlers = {}
166
+ const p = (name) => `@${nsFor(scope)}/${name}`
167
+ for (const [name, def] of items) {
168
+ const type = types.get(name)
169
+ const path = p(name)
170
+ const keys = def.key || ['id']
171
+
172
+ handlers[p(`add-${type}`)] = async (op, ctx) => {
173
+ const counterPath = p('counters')
174
+ const counter = (await ctx.view.get(counterPath, { name })) || { name, value: 0 }
175
+ op.index = counter.value
176
+ counter.value++
177
+ await ctx.view.insert(counterPath, counter)
178
+ await ctx.view.insert(path, op)
179
+ }
180
+
181
+ handlers[p(`set-${type}`)] = (op, ctx) => ctx.view.insert(path, op)
182
+ handlers[p(`del-${type}`)] = async (op, ctx) => {
183
+ await ctx.view.delete(
184
+ path,
185
+ keys.reduce((acc, k) => ((acc[k] = op[k]), acc), {})
186
+ )
187
+ const counterPath = p('counters')
188
+ const counter = (await ctx.view.get(counterPath, { name })) || { name, value: 0 }
189
+ counter.value = Math.max(0, counter.value - 1)
190
+ await ctx.view.insert(counterPath, counter)
191
+ }
192
+
193
+ if (def.handlers) {
194
+ for (const [op, handler] of Object.entries(def.handlers)) {
195
+ handlers[p(op)] = handler
196
+ }
197
+ }
198
+ }
199
+
200
+ return handlers
201
+ }
202
+ }
203
+ }
@@ -0,0 +1,60 @@
1
+ import { SCOPE_LOCAL, SCOPE_PRIVATE, SCOPE_SHARED } from './constants.js'
2
+
3
+ /** Map user-facing scope to cero namespace */
4
+ const SCOPE_NS = {
5
+ [SCOPE_LOCAL]: 'local',
6
+ [SCOPE_PRIVATE]: 'identity',
7
+ [SCOPE_SHARED]: 'room'
8
+ }
9
+
10
+ /**
11
+ * Convert plural collection name to singular type name
12
+ * tasks -> task, categories -> category
13
+ */
14
+ export function singular(name) {
15
+ if (name.endsWith('ies')) return name.slice(0, -3) + 'y'
16
+ if (name.endsWith('s')) return name.slice(0, -1)
17
+ return name
18
+ }
19
+
20
+ /** Get cero namespace for a scope */
21
+ export function scopeNs(scope) {
22
+ return SCOPE_NS[scope]
23
+ }
24
+
25
+ /** Get dispatch/collection path: @ns/name */
26
+ export function scopePath(scope, name) {
27
+ return `@${SCOPE_NS[scope]}/${name}`
28
+ }
29
+
30
+ export function isLocal(scope) {
31
+ return scope === SCOPE_LOCAL
32
+ }
33
+
34
+ export function isPrivate(scope) {
35
+ return scope === SCOPE_PRIVATE
36
+ }
37
+
38
+ export function isShared(scope) {
39
+ return scope === SCOPE_SHARED
40
+ }
41
+
42
+ export function debounce(fn, ms = 0) {
43
+ let timer
44
+ return (...args) => {
45
+ clearTimeout(timer)
46
+ timer = setTimeout(() => fn(...args), ms)
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Convert field definitions to hyperschema field array
52
+ */
53
+ export function toFields(fields) {
54
+ return Object.entries(fields).map(([name, def]) => ({
55
+ name,
56
+ type: def.type,
57
+ required: def.required ?? true,
58
+ array: def.array ?? false
59
+ }))
60
+ }