@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 +88 -0
- package/package.json +48 -0
- package/src/index.js +1 -0
- package/src/lib/base.js +98 -0
- package/src/lib/batch.js +95 -0
- package/src/lib/builder.js +24 -0
- package/src/lib/collection.js +208 -0
- package/src/lib/constants.js +4 -0
- package/src/lib/crypto.js +6 -0
- package/src/lib/index.js +6 -0
- package/src/lib/room.js +156 -0
- package/src/lib/schema.js +203 -0
- package/src/lib/utils.js +60 -0
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'
|
package/src/lib/base.js
ADDED
|
@@ -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
|
+
}
|
package/src/lib/batch.js
ADDED
|
@@ -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
|
+
}
|
package/src/lib/index.js
ADDED
package/src/lib/room.js
ADDED
|
@@ -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
|
+
}
|
package/src/lib/utils.js
ADDED
|
@@ -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
|
+
}
|