@cero-base/cero 1.19.0 → 2.1.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 +38 -643
- package/package.json +9 -6
- package/src/build/index.js +63 -61
- package/src/build/internal.js +123 -0
- package/src/build/schemas.js +6 -11
- package/src/extensions/handle-sync.js +3 -11
- package/src/extensions/index.js +86 -0
- package/src/extensions/profile-sync.js +15 -16
- package/src/handle/index.js +290 -284
- package/src/index.js +24 -81
- package/src/lib/bluetooth.js +25 -56
- package/src/lib/constants.js +0 -15
- package/src/lib/operators.js +35 -134
- package/src/lib/peek.js +4 -8
- package/src/lib/refs.js +10 -8
- package/src/lib/spec.js +2 -3
- package/src/local/index.js +2 -3
- package/src/rpc/client.js +54 -77
- package/src/rpc/index.js +3 -3
- package/src/rpc/server.js +40 -43
- package/types/build/index.d.ts +11 -9
- package/types/build/{builtins.d.ts → internal.d.ts} +20 -38
- package/types/build/schemas.d.ts +4 -3
- package/types/extensions/handle-sync.d.ts +2 -8
- package/types/extensions/index.d.ts +102 -0
- package/types/extensions/profile-sync.d.ts +0 -5
- package/types/handle/index.d.ts +90 -108
- package/types/index.d.ts +21 -17
- package/types/lib/bluetooth.d.ts +8 -32
- package/types/lib/constants.d.ts +0 -11
- package/types/lib/operators.d.ts +30 -83
- package/types/lib/peek.d.ts +2 -3
- package/types/lib/refs.d.ts +5 -5
- package/types/lib/spec.d.ts +2 -3
- package/types/local/index.d.ts +2 -3
- package/types/rpc/client.d.ts +29 -26
- package/types/rpc/index.d.ts +3 -3
- package/types/rpc/server.d.ts +7 -10
- package/src/build/builtins.js +0 -174
- package/src/lib/internal.js +0 -9
- package/types/lib/internal.d.ts +0 -24
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cero-base/cero",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"description": "The ideal p2p API — everything is a handle, handles contain refs, refs contain rows.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
7
7
|
"engines": {
|
|
8
|
-
"node": ">=
|
|
8
|
+
"node": ">=22"
|
|
9
9
|
},
|
|
10
10
|
"repository": {
|
|
11
11
|
"type": "git",
|
|
@@ -18,8 +18,7 @@
|
|
|
18
18
|
"src",
|
|
19
19
|
"types",
|
|
20
20
|
"README.md",
|
|
21
|
-
"LICENSE"
|
|
22
|
-
"!**/CLAUDE.md"
|
|
21
|
+
"LICENSE"
|
|
23
22
|
],
|
|
24
23
|
"publishConfig": {
|
|
25
24
|
"access": "public"
|
|
@@ -82,6 +81,10 @@
|
|
|
82
81
|
"events": {
|
|
83
82
|
"bare": "bare-events",
|
|
84
83
|
"default": "events"
|
|
84
|
+
},
|
|
85
|
+
"process": {
|
|
86
|
+
"bare": "bare-process",
|
|
87
|
+
"default": "process"
|
|
85
88
|
}
|
|
86
89
|
},
|
|
87
90
|
"scripts": {
|
|
@@ -91,11 +94,11 @@
|
|
|
91
94
|
"prepublishOnly": "npm run build:types",
|
|
92
95
|
"test": "npm run test:bare",
|
|
93
96
|
"pretest:bare": "npm run build:test",
|
|
94
|
-
"test:bare": "find test -name '*.test.js' | sort | xargs -P1 -n1 brittle-bare
|
|
97
|
+
"test:bare": "find test -name '*.test.js' | sort | xargs -P1 -n1 brittle-bare",
|
|
95
98
|
"test:node": "find test -name '*.test.js' | sort | xargs -P1 -n1 brittle-node"
|
|
96
99
|
},
|
|
97
100
|
"dependencies": {
|
|
98
|
-
"@cero-base/core": "^1.
|
|
101
|
+
"@cero-base/core": "^2.1.0",
|
|
99
102
|
"b4a": "^1.8.1",
|
|
100
103
|
"bare-abort-controller": "^1.1.2",
|
|
101
104
|
"bare-crypto": "^1.15.3",
|
package/src/build/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { join } from 'path'
|
|
1
|
+
import { join, resolve } from 'path'
|
|
2
2
|
import { promises as fs } from 'fs'
|
|
3
|
+
import { pathToFileURL } from 'url'
|
|
3
4
|
|
|
4
5
|
import Hyperschema from 'hyperschema'
|
|
5
6
|
import HyperdbBuilder from 'hyperdb/builder'
|
|
@@ -9,18 +10,8 @@ import HRPCBuilder from 'hrpc'
|
|
|
9
10
|
import { CeroError } from '@cero-base/core/errors'
|
|
10
11
|
|
|
11
12
|
import { NS } from '../lib/constants.js'
|
|
12
|
-
import {
|
|
13
|
-
import
|
|
14
|
-
refs,
|
|
15
|
-
builtinRefs,
|
|
16
|
-
builtinTypes,
|
|
17
|
-
builtinCollections,
|
|
18
|
-
builtinDispatches,
|
|
19
|
-
rotateDispatch,
|
|
20
|
-
rpcTypes,
|
|
21
|
-
rpcCommands,
|
|
22
|
-
getHyperdbType
|
|
23
|
-
} from './builtins.js'
|
|
13
|
+
import { extensionsOf } from '../extensions/index.js'
|
|
14
|
+
import * as internal from './internal.js'
|
|
24
15
|
|
|
25
16
|
/**
|
|
26
17
|
* @typedef {import('@cero-base/core/schema').Schema} Schema
|
|
@@ -29,47 +20,46 @@ import {
|
|
|
29
20
|
*
|
|
30
21
|
* @typedef {object} BuildOpts
|
|
31
22
|
* @property {string} [ns] Namespace prefix for emitted schema ids. Defaults to `'cero'`.
|
|
23
|
+
* @property {string | import('../extensions/index.js').Extension[]} [extensions] The extensions to fold in. A module specifier, relative to `specDir`, is imported here for its `extensions` export and written into the spec, so every process runs the same list. A list is folded in only. The bundled two by default, `[]` for none.
|
|
24
|
+
* @property {string} [operators] A module specifier, relative to `specDir`, written into the spec for its `operators` export, so every process binds the same map.
|
|
32
25
|
*/
|
|
33
26
|
|
|
34
27
|
/**
|
|
35
28
|
* Compile a cero schema into wire-level artifacts and write them to disk.
|
|
36
29
|
*
|
|
37
|
-
* Emits a `main/` tree (schema + hyperdb + dispatch + rpc), a `local/` tree
|
|
38
|
-
* for per-device data, one `handles/<name>/` tree per child handle type, and
|
|
39
|
-
* an `index.js` that re-exports a ready-to-use `spec` object.
|
|
40
|
-
*
|
|
41
30
|
* @param {string} specDir Output directory.
|
|
42
31
|
* @param {SchemaInput} schema Either a `schema(...)` wrapper or its raw defs object.
|
|
43
32
|
* @param {BuildOpts} [opts]
|
|
44
33
|
* @returns {Promise<void>}
|
|
45
34
|
*/
|
|
46
|
-
export {
|
|
47
|
-
|
|
48
|
-
export async function build(specDir, schema, { ns = NS, extensions = true } = {}) {
|
|
35
|
+
export async function build(specDir, schema, { ns = NS, extensions, operators } = {}) {
|
|
49
36
|
const raw = /** @type {SchemaDefs & { local?: SchemaDefs }} */ (schema?.defs || schema)
|
|
50
37
|
if (!raw || typeof raw !== 'object') throw CeroError.REQUIRED('schema')
|
|
38
|
+
const from = { extensions: str(extensions), operators: str(operators) }
|
|
39
|
+
const exts = extensionsOf(
|
|
40
|
+
null,
|
|
41
|
+
from.extensions ? await load(from.extensions, specDir) : extensions
|
|
42
|
+
)
|
|
51
43
|
|
|
52
|
-
//
|
|
53
|
-
// name, merged across scopes. App schema first, then each `cero.use()` extension.
|
|
44
|
+
// t.extend entries by internal type: the app schema first, then each extension at its scope
|
|
54
45
|
const extend = {}
|
|
55
46
|
const defs = {}
|
|
56
|
-
const
|
|
47
|
+
const fold = (entries, target, fromExt) => {
|
|
57
48
|
for (const [k, v] of Object.entries(entries)) {
|
|
58
49
|
if (v && v.kind === 'extend') {
|
|
59
|
-
const type =
|
|
50
|
+
const type = internal.defs.main[k]?.type
|
|
60
51
|
if (!type) throw CeroError.INVALID(`'${k}' is not an extendable builtin`)
|
|
61
52
|
// app schema wins on field conflicts, extensions only add new fields
|
|
62
53
|
extend[type] = fromExt ? { ...v.fields, ...extend[type] } : { ...extend[type], ...v.fields }
|
|
63
|
-
} else if (
|
|
64
|
-
|
|
54
|
+
} else if (fromExt && isPlainHandle(v)) {
|
|
55
|
+
fold(v, (target[k] = { ...target[k] }), true) // into the handle type, the app's refs kept
|
|
56
|
+
} else if (!fromExt || !(k in target)) {
|
|
57
|
+
target[k] = v // app schema wins over an extension's default
|
|
65
58
|
}
|
|
66
59
|
}
|
|
67
60
|
}
|
|
68
|
-
|
|
69
|
-
for (const ext of
|
|
70
|
-
if (ext.bundled && extensions === false) continue
|
|
71
|
-
if (ext.schema) collect(ext.schema, true)
|
|
72
|
-
}
|
|
61
|
+
fold(raw, defs, false)
|
|
62
|
+
for (const ext of exts) if (ext.schema) fold(ext.schema, defs, true)
|
|
73
63
|
|
|
74
64
|
const main = compile(splitMain(defs), ns, 'main')
|
|
75
65
|
const local = compile(defs.local || {}, ns, 'local')
|
|
@@ -93,12 +83,29 @@ export async function build(specDir, schema, { ns = NS, extensions = true } = {}
|
|
|
93
83
|
)
|
|
94
84
|
}
|
|
95
85
|
|
|
96
|
-
//
|
|
86
|
+
// the type marker tells the runtime which handle type to open
|
|
97
87
|
for (const [name] of Object.entries(handles)) {
|
|
98
88
|
if (meta.refs[name]) meta.refs[name] = { kind: 'handle', type: name, schema: `@${ns}/handle` }
|
|
99
89
|
}
|
|
100
90
|
|
|
101
|
-
await fs.writeFile(
|
|
91
|
+
await fs.writeFile(
|
|
92
|
+
join(specDir, 'index.js'),
|
|
93
|
+
wireModule(meta, Object.keys(handles), from),
|
|
94
|
+
'utf-8'
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const str = (v) => (typeof v === 'string' ? v : null)
|
|
99
|
+
|
|
100
|
+
async function load(specifier, specDir) {
|
|
101
|
+
const url = specifier.startsWith('.')
|
|
102
|
+
? pathToFileURL(resolve(specDir, specifier)).href
|
|
103
|
+
: specifier
|
|
104
|
+
const { extensions } = await import(url)
|
|
105
|
+
if (!Array.isArray(extensions)) {
|
|
106
|
+
throw CeroError.INVALID(`'${specifier}' must export an extensions array`)
|
|
107
|
+
}
|
|
108
|
+
return extensions
|
|
102
109
|
}
|
|
103
110
|
|
|
104
111
|
function splitMain(defs) {
|
|
@@ -138,7 +145,7 @@ function compile(root, ns, scope = 'main') {
|
|
|
138
145
|
scope
|
|
139
146
|
}
|
|
140
147
|
|
|
141
|
-
Object.assign(ctx.meta.refs,
|
|
148
|
+
Object.assign(ctx.meta.refs, internal.refs(ns, scope))
|
|
142
149
|
|
|
143
150
|
for (const [name, node] of Object.entries(root)) {
|
|
144
151
|
if (node.kind === 'handle') {
|
|
@@ -166,13 +173,13 @@ function fileFieldNames(fields) {
|
|
|
166
173
|
function register(name, node, ctx) {
|
|
167
174
|
const fqn = `@${ctx.ns}/${name}`
|
|
168
175
|
if (node.kind === 'action') {
|
|
169
|
-
ctx.types.push({ name, compact: false, fields:
|
|
176
|
+
ctx.types.push({ name, compact: false, fields: internal.fields(node.fields) })
|
|
170
177
|
ctx.dispatches.push({ name, requestType: fqn })
|
|
171
178
|
ctx.meta.refs[name] = { kind: 'action', path: [name], schema: fqn }
|
|
172
179
|
return
|
|
173
180
|
}
|
|
174
181
|
if (node.kind === 'single') {
|
|
175
|
-
ctx.types.push({ name, compact: false, fields:
|
|
182
|
+
ctx.types.push({ name, compact: false, fields: internal.fields(node.fields) })
|
|
176
183
|
ctx.collections.push({ name, schema: fqn, key: [] })
|
|
177
184
|
ctx.dispatches.push({ name: `set-${name}`, requestType: fqn })
|
|
178
185
|
ctx.dispatches.push({ name: `del-${name}`, requestType: `@${ctx.ns}/del-by-id` })
|
|
@@ -192,7 +199,7 @@ function register(name, node, ctx) {
|
|
|
192
199
|
{ name: 'index', type: 'uint', required: false },
|
|
193
200
|
{ name: 'createdAt', type: 'int', required: false },
|
|
194
201
|
{ name: 'updatedAt', type: 'int', required: false },
|
|
195
|
-
...
|
|
202
|
+
...internal.fields(node.fields)
|
|
196
203
|
]
|
|
197
204
|
})
|
|
198
205
|
ctx.collections.push({ name, schema: fqn, key: ['id'] })
|
|
@@ -215,10 +222,7 @@ function register(name, node, ctx) {
|
|
|
215
222
|
}
|
|
216
223
|
ctx.meta.refs[name].indexes = node.indexes
|
|
217
224
|
}
|
|
218
|
-
// implicit
|
|
219
|
-
// reverse/limit down to hyperdb instead of scanning the whole collection.
|
|
220
|
-
// main scope only: local (Storage) rows never get `index` stamped, and an
|
|
221
|
-
// undefined index key crashes the encoder on insert.
|
|
225
|
+
// implicit index on the auto-increment `index`, so reverse and limit push down; main scope only
|
|
222
226
|
if (ctx.scope === 'main' && !node.indexes?.index) {
|
|
223
227
|
ctx.indexes.push({ name: `${name}-index`, collection: fqn, key: ['index'] })
|
|
224
228
|
refEntry.orderIndex = true
|
|
@@ -226,17 +230,7 @@ function register(name, node, ctx) {
|
|
|
226
230
|
}
|
|
227
231
|
}
|
|
228
232
|
|
|
229
|
-
|
|
230
|
-
return Object.entries(fields).map(([name, marker]) => ({
|
|
231
|
-
name,
|
|
232
|
-
type: getHyperdbType(marker.prim),
|
|
233
|
-
required: marker.required === true
|
|
234
|
-
}))
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
// The contract version is the highest of the three auto-bumped artifact
|
|
238
|
-
// versions — any schema/db/dispatch change advances it, so a peer can tell
|
|
239
|
-
// whether an op predates or postdates its own build.
|
|
233
|
+
// the contract version is the highest of the schema, db and dispatch versions
|
|
240
234
|
async function contractVersion(mainDir) {
|
|
241
235
|
const read = async (rel) => {
|
|
242
236
|
const j = JSON.parse(await fs.readFile(join(mainDir, rel), 'utf-8'))
|
|
@@ -258,29 +252,30 @@ function emitMain(dir, ns, { types, collections, dispatches, indexes = [] }, { r
|
|
|
258
252
|
|
|
259
253
|
const s = Hyperschema.from(schemaDir)
|
|
260
254
|
const sns = s.namespace(ns)
|
|
261
|
-
for (const desc of
|
|
255
|
+
for (const desc of internal.types('main', extend)) sns.register(desc)
|
|
262
256
|
for (const desc of types) sns.register(desc)
|
|
263
|
-
if (rpc) for (const desc of
|
|
257
|
+
if (rpc) for (const desc of internal.types('rpc')) sns.register(desc)
|
|
264
258
|
Hyperschema.toDisk(s, schemaDir, { esm: true })
|
|
265
259
|
|
|
266
260
|
const db = HyperdbBuilder.from(schemaDir, dbDir)
|
|
267
261
|
const dns = db.namespace(ns)
|
|
268
|
-
for (const desc of
|
|
262
|
+
for (const desc of internal.collections(ns, 'main')) dns.collections.register(desc)
|
|
269
263
|
for (const desc of collections) dns.collections.register(desc)
|
|
270
264
|
for (const desc of indexes) dns.indexes.register(desc)
|
|
271
265
|
HyperdbBuilder.toDisk(db, dbDir, { esm: true })
|
|
272
266
|
|
|
273
267
|
const d = Hyperdispatch.from(schemaDir, dispatchDir)
|
|
274
268
|
const xns = d.namespace(ns)
|
|
275
|
-
for (const desc of
|
|
269
|
+
for (const desc of internal.dispatches(ns)) xns.register(desc)
|
|
276
270
|
for (const desc of dispatches) xns.register(desc)
|
|
277
|
-
|
|
271
|
+
// after the app's dispatches: hyperdispatch numbers routes positionally and persists them
|
|
272
|
+
xns.register({ name: 'rotate-key', requestType: `@${ns}/epoch` })
|
|
278
273
|
Hyperdispatch.toDisk(d, dispatchDir, { esm: true })
|
|
279
274
|
|
|
280
275
|
if (rpc) {
|
|
281
276
|
const r = HRPCBuilder.from(schemaDir, rpcDir)
|
|
282
277
|
const rns = r.namespace(ns)
|
|
283
|
-
for (const desc of
|
|
278
|
+
for (const desc of internal.commands(ns)) rns.register(desc)
|
|
284
279
|
HRPCBuilder.toDisk(r, rpcDir, { esm: true })
|
|
285
280
|
}
|
|
286
281
|
}
|
|
@@ -291,13 +286,13 @@ function emitLocal(dir, ns, { types, collections, indexes = [] }) {
|
|
|
291
286
|
|
|
292
287
|
const s = Hyperschema.from(schemaDir)
|
|
293
288
|
const sns = s.namespace(ns)
|
|
294
|
-
for (const desc of
|
|
289
|
+
for (const desc of internal.types('local')) sns.register(desc)
|
|
295
290
|
for (const desc of types) sns.register(desc)
|
|
296
291
|
Hyperschema.toDisk(s, schemaDir, { esm: true })
|
|
297
292
|
|
|
298
293
|
const db = HyperdbBuilder.from(schemaDir, dbDir)
|
|
299
294
|
const dns = db.namespace(ns)
|
|
300
|
-
for (const desc of
|
|
295
|
+
for (const desc of internal.collections(ns, 'local')) dns.collections.register(desc)
|
|
301
296
|
for (const desc of collections) dns.collections.register(desc)
|
|
302
297
|
for (const desc of indexes) dns.indexes.register(desc)
|
|
303
298
|
HyperdbBuilder.toDisk(db, dbDir, { esm: true })
|
|
@@ -324,7 +319,10 @@ function handleEntries(names, kinds) {
|
|
|
324
319
|
.join(',\n')
|
|
325
320
|
}
|
|
326
321
|
|
|
327
|
-
|
|
322
|
+
// the spec imports what the build named, so every process finds the same lists
|
|
323
|
+
const named = (what, from) => (from ? `import { ${what} } from '${from}'` : `const ${what} = null`)
|
|
324
|
+
|
|
325
|
+
function wireModule(meta, names, from) {
|
|
328
326
|
const kinds = ['database', 'dispatch', 'schema']
|
|
329
327
|
return `// autogenerated by cero/build
|
|
330
328
|
import database from './main/db/index.js'
|
|
@@ -334,6 +332,8 @@ import rpc from './main/rpc/index.js'
|
|
|
334
332
|
import localDatabase from './local/db/index.js'
|
|
335
333
|
import * as localSchema from './local/schema/index.js'
|
|
336
334
|
${handleImports(names, kinds)}
|
|
335
|
+
${named('extensions', from.extensions)}
|
|
336
|
+
${named('operators', from.operators)}
|
|
337
337
|
|
|
338
338
|
export const meta = ${JSON.stringify(meta, null, 2)}
|
|
339
339
|
|
|
@@ -344,6 +344,8 @@ export const spec = {
|
|
|
344
344
|
rpc,
|
|
345
345
|
local: { database: localDatabase, schema: localSchema, meta: meta.local },
|
|
346
346
|
meta,
|
|
347
|
+
extensions,
|
|
348
|
+
operators,
|
|
347
349
|
handles: {
|
|
348
350
|
${handleEntries(names, kinds)}
|
|
349
351
|
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { CeroError } from '@cero-base/core/errors'
|
|
2
|
+
import { COUNTERS, EPOCHS } from '../lib/constants.js'
|
|
3
|
+
import * as schemas from './schemas.js'
|
|
4
|
+
|
|
5
|
+
// internal refs by scope; kind defaults to collection
|
|
6
|
+
export const defs = {
|
|
7
|
+
main: {
|
|
8
|
+
members: { type: 'member' },
|
|
9
|
+
devices: { type: 'device' },
|
|
10
|
+
invites: { type: 'invite' },
|
|
11
|
+
handles: { type: 'handle' },
|
|
12
|
+
files: { type: 'file' }
|
|
13
|
+
},
|
|
14
|
+
local: {
|
|
15
|
+
master: { type: 'master', kind: 'single' },
|
|
16
|
+
keypair: { type: 'keypair', kind: 'single' },
|
|
17
|
+
'handle-keypairs': { type: 'handle-keypair' },
|
|
18
|
+
environment: { type: 'environment', kind: 'single' }
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// a DSL primitive is its hyperdb column type, except bytes (a buffer) and file (an id string)
|
|
23
|
+
const COLUMN = { bytes: 'buffer', file: 'string' }
|
|
24
|
+
|
|
25
|
+
export function fields(map) {
|
|
26
|
+
return Object.entries(map).map(([name, m]) => ({
|
|
27
|
+
name,
|
|
28
|
+
type: COLUMN[m.prim] || m.prim,
|
|
29
|
+
required: m.required === true
|
|
30
|
+
}))
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// a base field cannot be redeclared
|
|
34
|
+
function merge(type, base, extra) {
|
|
35
|
+
if (!extra) return base
|
|
36
|
+
const dup = Object.keys(extra).find((k) => k in base)
|
|
37
|
+
if (dup) throw CeroError.INVALID(`'${dup}' is a base field of '${type}' and cannot be redeclared`)
|
|
38
|
+
return { ...base, ...extra }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function types(scope, extend = {}) {
|
|
42
|
+
return Object.entries(schemas[scope]).map(([name, base]) => ({
|
|
43
|
+
name,
|
|
44
|
+
compact: false,
|
|
45
|
+
fields: fields(merge(name, base, extend[name]))
|
|
46
|
+
}))
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function refs(ns, scope) {
|
|
50
|
+
return Object.fromEntries(
|
|
51
|
+
Object.entries(defs[scope]).map(([name, def]) => [
|
|
52
|
+
name,
|
|
53
|
+
{
|
|
54
|
+
kind: def.kind || 'collection',
|
|
55
|
+
path: [name],
|
|
56
|
+
internal: true,
|
|
57
|
+
...(scope === 'main' && { verb: def.type }),
|
|
58
|
+
schema: `@${ns}/${def.type}`
|
|
59
|
+
}
|
|
60
|
+
])
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function collections(ns, scope) {
|
|
65
|
+
const out = Object.entries(defs[scope]).map(([name, def]) => ({
|
|
66
|
+
name,
|
|
67
|
+
schema: `@${ns}/${def.type}`,
|
|
68
|
+
key: def.kind === 'single' ? [] : ['id']
|
|
69
|
+
}))
|
|
70
|
+
if (scope === 'main') {
|
|
71
|
+
out.push({ name: COUNTERS, schema: `@${ns}/counter`, key: ['name'] })
|
|
72
|
+
out.push({ name: EPOCHS, schema: `@${ns}/epoch`, key: ['epoch'] })
|
|
73
|
+
}
|
|
74
|
+
return out
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function dispatches(ns) {
|
|
78
|
+
return [
|
|
79
|
+
{ name: 'add-writer', requestType: `@${ns}/writer` },
|
|
80
|
+
{ name: 'del-writer', requestType: `@${ns}/writer` },
|
|
81
|
+
{ name: 'claim-writer', requestType: `@${ns}/claim` },
|
|
82
|
+
...Object.values(defs.main).flatMap(({ type }) => [
|
|
83
|
+
{ name: `add-${type}`, requestType: `@${ns}/${type}` },
|
|
84
|
+
{ name: `set-${type}`, requestType: `@${ns}/${type}` },
|
|
85
|
+
{ name: `del-${type}`, requestType: `@${ns}/del-by-id` }
|
|
86
|
+
])
|
|
87
|
+
]
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// hrpc commands: name, request type, response type, streaming response
|
|
91
|
+
const COMMANDS = [
|
|
92
|
+
['init', 'req-empty', 'res-identity'],
|
|
93
|
+
['restore', 'req-restore', 'res-identity'],
|
|
94
|
+
['seed', 'req-empty', 'res-seed'],
|
|
95
|
+
['add-row', 'req-row', 'res-data'],
|
|
96
|
+
['add-file', 'req-add-file', 'res-data'],
|
|
97
|
+
['add-handle', 'req-row', 'res-handle'],
|
|
98
|
+
['set', 'req-row', 'res-data'],
|
|
99
|
+
['get', 'req-query', 'res-rows'],
|
|
100
|
+
['get-one', 'req-id', 'res-data'],
|
|
101
|
+
['del', 'req-id', 'res-ok'],
|
|
102
|
+
['watch', 'req-query', 'res-rows', true],
|
|
103
|
+
['call', 'req-call', 'res-data'],
|
|
104
|
+
['invite', 'req-invite', 'res-invite'],
|
|
105
|
+
['revoke', 'req-revoke', 'res-ok'],
|
|
106
|
+
['join', 'req-join', 'res-handle'],
|
|
107
|
+
['open-handle', 'req-open', 'res-handle'],
|
|
108
|
+
['close-handle', 'req-handle', 'res-ok'],
|
|
109
|
+
['leave', 'req-handle', 'res-ok'],
|
|
110
|
+
['changes', 'req-query', 'res-changes', true],
|
|
111
|
+
['rotate', 'req-handle', 'res-epoch'],
|
|
112
|
+
['set-active', 'req-set-active', 'res-ok'],
|
|
113
|
+
['suspend', 'req-empty', 'res-ok'],
|
|
114
|
+
['resume', 'req-empty', 'res-ok']
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
export function commands(ns) {
|
|
118
|
+
return COMMANDS.map(([name, req, res, stream]) => ({
|
|
119
|
+
name,
|
|
120
|
+
request: { name: `@${ns}/${req}` },
|
|
121
|
+
response: { name: `@${ns}/${res}`, ...(stream && { stream }) }
|
|
122
|
+
}))
|
|
123
|
+
}
|
package/src/build/schemas.js
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
//
|
|
2
|
-
// the build wiring (builtins.js) so apps can extend them with `t.extend`.
|
|
3
|
-
// Field order is the wire order; do not reorder.
|
|
1
|
+
// field order is the wire format: append only, never reorder
|
|
4
2
|
import { t } from '../lib/spec.js'
|
|
5
3
|
|
|
6
4
|
const { string, bytes, int, uint, bool, required } = t
|
|
@@ -15,11 +13,7 @@ export const main = {
|
|
|
15
13
|
sig: required(bytes),
|
|
16
14
|
isIndexer: bool,
|
|
17
15
|
ts: int,
|
|
18
|
-
//
|
|
19
|
-
// the same transaction, and a refused grant discards this admission with
|
|
20
|
-
// it — so `role` is unread since 77e5ed0 and stays only for older peers.
|
|
21
|
-
// Absent on a device you add for yourself (recovery), where the device
|
|
22
|
-
// binds to the master identity.
|
|
16
|
+
// the paired add-member decides the rank in the same transaction; `role` here is unread
|
|
23
17
|
memberId: string,
|
|
24
18
|
role: string
|
|
25
19
|
},
|
|
@@ -165,6 +159,10 @@ export const rpc = {
|
|
|
165
159
|
'req-handle': {
|
|
166
160
|
handle: required(string)
|
|
167
161
|
},
|
|
162
|
+
'req-set-active': {
|
|
163
|
+
handle: required(string),
|
|
164
|
+
active: bool
|
|
165
|
+
},
|
|
168
166
|
'res-data': {
|
|
169
167
|
data: bytes
|
|
170
168
|
},
|
|
@@ -177,9 +175,6 @@ export const rpc = {
|
|
|
177
175
|
changes: required(bytes),
|
|
178
176
|
reset: bool
|
|
179
177
|
},
|
|
180
|
-
'res-count': {
|
|
181
|
-
count: required(int)
|
|
182
|
-
},
|
|
183
178
|
'res-invite': {
|
|
184
179
|
invite: required(string)
|
|
185
180
|
},
|
|
@@ -2,26 +2,18 @@ import { t } from '../lib/spec.js'
|
|
|
2
2
|
import { get, set, watch } from '../lib/operators.js'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* Mirror a child handle's `profile` (name + avatar) onto its row in the parent's
|
|
6
|
-
*
|
|
7
|
-
* one. Adds the synced `fields` to the `handle` builtin.
|
|
8
|
-
*
|
|
9
|
-
* On create it seeds the handle's `profile.name` from the open `{ name }`, then
|
|
10
|
-
* reflects the (app-owned) `profile` onto the row. Requires your handle types to
|
|
11
|
-
* declare a `profile` single — handles without one are left untouched.
|
|
5
|
+
* Mirror a child handle's `profile` (name + avatar) onto its row in the parent's `handles`
|
|
6
|
+
* list — so a handle list shows names + avatars without opening each one.
|
|
12
7
|
*
|
|
13
8
|
* @param {{ fields?: Record<string, any> }} [opts]
|
|
14
9
|
*/
|
|
15
10
|
export function handleSync({ fields = { avatar: t.string } } = {}) {
|
|
16
11
|
return {
|
|
17
12
|
name: 'handle-sync',
|
|
18
|
-
bundled: true,
|
|
19
13
|
schema: { handles: t.extend(fields) },
|
|
20
14
|
setup(me) {
|
|
21
15
|
const keys = ['name', ...Object.keys(fields)]
|
|
22
|
-
//
|
|
23
|
-
// re-emits the watch snapshot, and an unconditional set is a new op in
|
|
24
|
-
// the replicated log forever
|
|
16
|
+
// an unconditional set on every open is a new op in the log forever
|
|
25
17
|
const reflect = async (child, data) => {
|
|
26
18
|
child.name = data.name
|
|
27
19
|
const { data: row } = await get(me.handles, child.id)
|
package/src/extensions/index.js
CHANGED
|
@@ -1,2 +1,88 @@
|
|
|
1
|
+
import { profileSync } from './profile-sync.js'
|
|
2
|
+
import { handleSync } from './handle-sync.js'
|
|
3
|
+
import { t, schema } from '../lib/spec.js'
|
|
4
|
+
import * as operators from '../lib/operators.js'
|
|
5
|
+
|
|
1
6
|
export * from './profile-sync.js'
|
|
2
7
|
export * from './handle-sync.js'
|
|
8
|
+
|
|
9
|
+
// everything an extension module needs, light enough for the UI bundle the spec pulls it into
|
|
10
|
+
export { t, schema }
|
|
11
|
+
export const { put, set, get, del, watch, changes, call, open, rotate, before, after } = operators
|
|
12
|
+
export const cero = {
|
|
13
|
+
t,
|
|
14
|
+
schema,
|
|
15
|
+
put,
|
|
16
|
+
set,
|
|
17
|
+
get,
|
|
18
|
+
del,
|
|
19
|
+
watch,
|
|
20
|
+
changes,
|
|
21
|
+
call,
|
|
22
|
+
open,
|
|
23
|
+
rotate,
|
|
24
|
+
before,
|
|
25
|
+
after
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @typedef {object} Extension
|
|
30
|
+
* @property {Record<string, any>} [schema] Refs to add, or `t.extend` on a builtin, nested by handle type like the app schema.
|
|
31
|
+
* @property {(me: any) => any} [setup] Runs once the root is ready; a returned function runs on close.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/** The two every app gets unless its build names a list. */
|
|
35
|
+
export const bundled = [profileSync(), handleSync()]
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The extensions a spec carries, else the bundled two. A bare function is `{ setup }`.
|
|
39
|
+
*
|
|
40
|
+
* @param {any} spec
|
|
41
|
+
* @param {Array<any>} [override]
|
|
42
|
+
* @returns {Extension[]}
|
|
43
|
+
*/
|
|
44
|
+
export function extensionsOf(spec, override) {
|
|
45
|
+
const list = override || spec?.extensions || bundled
|
|
46
|
+
return list.map((e) => (typeof e === 'function' ? { setup: e } : e))
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The operators a spec carries: functions taking the handle first, keyed by namespace, a
|
|
51
|
+
* key naming a handle type holding that type's namespaces.
|
|
52
|
+
*
|
|
53
|
+
* @param {any} spec
|
|
54
|
+
* @param {Record<string, any>} [override]
|
|
55
|
+
* @returns {Record<string, any>}
|
|
56
|
+
*/
|
|
57
|
+
export function operatorsOf(spec, override) {
|
|
58
|
+
return override || spec?.operators || {}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// handle.ns.fn(args) calls fn(handle, args)
|
|
62
|
+
function attach(handle, ns, fns) {
|
|
63
|
+
const bound = {}
|
|
64
|
+
for (const key of Object.keys(fns)) {
|
|
65
|
+
if (typeof fns[key] === 'function') bound[key] = (...args) => fns[key](handle, ...args)
|
|
66
|
+
}
|
|
67
|
+
handle[ns] = bound
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Put the operators for `handle` on it: the root when `type` is null, else a child of `type`.
|
|
72
|
+
*
|
|
73
|
+
* @param {any} handle
|
|
74
|
+
* @param {string | null} type
|
|
75
|
+
* @param {Record<string, any>} operators
|
|
76
|
+
* @returns {any} handle
|
|
77
|
+
*/
|
|
78
|
+
export function bind(handle, type, operators) {
|
|
79
|
+
const handles = handle.spec?.meta?.handles || {}
|
|
80
|
+
for (const [ns, fns] of Object.entries(operators)) {
|
|
81
|
+
if (type === null) {
|
|
82
|
+
if (!(ns in handles)) attach(handle, ns, fns)
|
|
83
|
+
} else if (ns === type) {
|
|
84
|
+
for (const [k, group] of Object.entries(fns)) attach(handle, k, group)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return handle
|
|
88
|
+
}
|
|
@@ -1,44 +1,43 @@
|
|
|
1
1
|
import { t } from '../lib/spec.js'
|
|
2
|
-
import { get, set,
|
|
2
|
+
import { get, set, changes } from '../lib/operators.js'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Mirror your `profile` onto your `member` row in every handle you're in.
|
|
6
|
-
* Declares a `profile` single (`name`, `avatar`, plus any extra `fields`) and
|
|
7
|
-
* mirrors them onto the `member` builtin, then publishes when you open/join a
|
|
8
|
-
* handle and whenever your profile changes. An app may declare its own richer
|
|
9
|
-
* `profile` instead — the app schema wins.
|
|
10
6
|
*
|
|
11
7
|
* @param {{ fields?: Record<string, any> }} [opts]
|
|
12
8
|
*/
|
|
13
9
|
export function profileSync({ fields = { avatar: t.string } } = {}) {
|
|
14
10
|
return {
|
|
15
11
|
name: 'profile-sync',
|
|
16
|
-
bundled: true,
|
|
17
12
|
schema: {
|
|
18
13
|
profile: t.single({ name: t.string, ...fields }),
|
|
19
14
|
members: t.extend(fields)
|
|
20
15
|
},
|
|
21
16
|
setup(me) {
|
|
22
17
|
const keys = ['name', ...Object.keys(fields)]
|
|
23
|
-
const publish = (child, profile) =>
|
|
24
|
-
set(child.members, { id: me.identity.id, ...profile }, { upsert: false }).catch(me._onerror)
|
|
25
18
|
|
|
26
|
-
// skip the broadcast when the member row already mirrors the profile —
|
|
27
19
|
// an unconditional set on every open is a room-wide op forever
|
|
28
|
-
const
|
|
29
|
-
const { data: profile } = await get(me.profile)
|
|
30
|
-
if (!profile) return
|
|
20
|
+
const publish = async (child, profile) => {
|
|
31
21
|
const { data: member } = await get(child.members, me.identity.id)
|
|
32
22
|
if (member && keys.every((k) => member[k] === profile[k])) return
|
|
33
|
-
|
|
23
|
+
await set(child.members, { id: me.identity.id, ...profile }, { upsert: false })
|
|
34
24
|
}
|
|
35
25
|
|
|
36
|
-
const
|
|
37
|
-
|
|
26
|
+
const onHandle = async (child) => {
|
|
27
|
+
const { data: profile } = await get(me.profile)
|
|
28
|
+
if (profile) await publish(child, profile)
|
|
38
29
|
}
|
|
39
30
|
|
|
40
31
|
me.on('handle', (child) => onHandle(child).catch(me._onerror), { signal: me.signal })
|
|
41
|
-
|
|
32
|
+
|
|
33
|
+
// a local edit and one replicated from another device both land here
|
|
34
|
+
const stream = changes(me.profile, {}, { signal: me.signal })
|
|
35
|
+
stream.on('error', me._onerror)
|
|
36
|
+
stream.on('data', ({ changes: batch }) => {
|
|
37
|
+
for (const { next } of batch) {
|
|
38
|
+
if (next) me.children.forEach((child) => publish(child, next).catch(me._onerror))
|
|
39
|
+
}
|
|
40
|
+
})
|
|
42
41
|
}
|
|
43
42
|
}
|
|
44
43
|
}
|