@cero-base/cero 0.8.10 → 1.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.
@@ -1,28 +1,54 @@
1
1
  import { Readable } from 'streamx'
2
2
 
3
- import { CeroError } from '@cero-base/core/errors'
4
-
3
+ import { encodeId, decodeId } from '@cero-base/core/blobs/codec'
5
4
  import { onAbort } from './utils.js'
6
5
 
7
- // `peek` (below) lazy-imports hypercore-storage / corestore / local so this
8
- // module — which the lightweight RPC client re-exports — stays free of native
9
- // deps. A client never calls `peek`, so the heavy imports load only on the core.
10
-
11
6
  /**
12
7
  * @typedef {import('./utils.js').Ref} Ref
8
+ * @typedef {import('../handle/index.js').CeroHandle} CeroHandle
13
9
  * @typedef {{ data: any }} SingleResult
14
10
  * @typedef {{ data: any[], total: number, size: number }} ListResult
15
11
  * @typedef {{ data: any | null }} GetByIdResult
16
12
  */
17
13
 
18
14
  /**
19
- * Insert (or overwrite by id) a row on `ref`.
15
+ * Resolve a durable file id (+ optional name) to the read shape returned
16
+ * everywhere: `{ id, name?, type, size, url }`.
17
+ *
18
+ * @param {object} handle
19
+ * @param {string} id
20
+ * @param {string} [name]
21
+ * @returns {{ id: string, type: string, size: number, url: string, name?: string }}
22
+ */
23
+ export function resolveFile(handle, id, name) {
24
+ const { type, blobId } = decodeId(id)
25
+ const file = { id, type, size: blobId.byteLength, url: handle.getLink(id) }
26
+ if (name != null) file.name = name
27
+ return file
28
+ }
29
+
30
+ /**
31
+ * Insert (or overwrite by id) a row on `ref`. The `files` builtin is special:
32
+ * `put(handle.files, { data, type, name? })` uploads the bytes to this handle's
33
+ * blob store, records `{ id, name }`, and returns the resolved file.
20
34
  *
21
35
  * @param {Ref} ref
22
36
  * @param {Record<string, any>} row
23
- * @returns {Promise<SingleResult>}
37
+ * @returns {Promise<SingleResult | { id: string, name?: string, type: string, size: number, url: string }>}
24
38
  */
25
- export const put = (ref, row) => ref.handle.store.put(ref.name, row)
39
+ export const put = (ref, row) =>
40
+ ref.name === 'files' ? putFile(ref, row) : ref.handle.store.put(ref.name, row)
41
+
42
+ async function putFile(ref, row) {
43
+ const handle = ref.handle
44
+ if (handle.rpc) return handle.put('files', row)
45
+ const { data, type, name = null } = row
46
+ await handle.blobs.ready()
47
+ const blobId = await handle.blobs.put(data)
48
+ const id = encodeId(handle.blobs.key, blobId, type)
49
+ await handle.store.call('add-file', { id, name })
50
+ return resolveFile(handle, id, name)
51
+ }
26
52
 
27
53
  /**
28
54
  * Upsert a row on `ref` — merges with the existing row and preserves
@@ -82,8 +108,12 @@ export const before = (ref, fn, opts) => {
82
108
  const offs = ops.map((op) =>
83
109
  db.before(op, (ctx) => (ctx.name === ref.name ? fn(ctx) : undefined))
84
110
  )
85
- const off = () => offs.forEach((unsub) => unsub())
86
- onAbort(opts?.signal, off)
111
+ let stopAbort
112
+ const off = () => {
113
+ offs.forEach((unsub) => unsub())
114
+ stopAbort?.()
115
+ }
116
+ stopAbort = onAbort(opts?.signal, off)
87
117
  return off
88
118
  }
89
119
 
@@ -102,8 +132,12 @@ export const after = (ref, fn, opts) => {
102
132
  const ops = WRITES[ref.kind] || ['set']
103
133
  const handler = (ctx) => ctx.name === ref.name && fn(ctx)
104
134
  for (const op of ops) db.on(`after:${op}`, handler)
105
- const off = () => ops.forEach((op) => db.off(`after:${op}`, handler))
106
- onAbort(opts?.signal, off)
135
+ let stopAbort
136
+ const off = () => {
137
+ ops.forEach((op) => db.off(`after:${op}`, handler))
138
+ stopAbort?.()
139
+ }
140
+ stopAbort = onAbort(opts?.signal, off)
107
141
  return off
108
142
  }
109
143
 
@@ -118,6 +152,49 @@ const normalize = (rows, name) => {
118
152
  return { data, total: data.length, size: data.length }
119
153
  }
120
154
 
155
+ function resolveResult(ref, res) {
156
+ if (res == null || res.data == null) return res
157
+ const data = Array.isArray(res.data)
158
+ ? res.data.map((row) => resolveRow(ref, row))
159
+ : resolveRow(ref, res.data)
160
+ return { ...res, data }
161
+ }
162
+
163
+ function resolveRow(ref, row) {
164
+ if (!row || typeof row !== 'object') return row
165
+ if (ref.handle.rpc) return ref.handle._resolveRow(ref.name, ref.handle._refInfo(ref.name), row)
166
+ const handle = ref.handle
167
+ const resolve = (id, name) => {
168
+ registerBlobCore(handle, id)
169
+ return resolveFile(handle, id, name)
170
+ }
171
+ if (ref.name === 'files') {
172
+ return { ...row, ...resolve(row.id, row.name) }
173
+ }
174
+ const fields = handle.store.refs?.[ref.name]?.files
175
+ if (!fields || !fields.length) return row
176
+ const out = { ...row }
177
+ for (const f of fields) {
178
+ const v = out[f]
179
+ if (v == null) continue
180
+ out[f] = resolve(v)
181
+ }
182
+ return out
183
+ }
184
+
185
+ function registerBlobCore(handle, id) {
186
+ if (!id || !handle.root?._coreKeys) return
187
+ try {
188
+ const { coreKey } = decodeId(id)
189
+ const hex = Buffer.from(coreKey).toString('hex')
190
+ if (!handle.root._coreKeys.has(hex)) {
191
+ handle.root._coreKeys.set(hex, handle.root.store.encryptionKey)
192
+ }
193
+ } catch {
194
+ // ignore invalid ids
195
+ }
196
+ }
197
+
121
198
  /**
122
199
  * Read from `ref`. For data refs, dispatches to the underlying store. For
123
200
  * `handle`-kind refs, lists existing child handles of that type from the
@@ -128,16 +205,22 @@ const normalize = (rows, name) => {
128
205
  * @returns {Promise<SingleResult | ListResult | GetByIdResult>}
129
206
  */
130
207
  export const get = async (ref, q) => {
131
- if (ref.kind !== 'handle') return ref.handle.store.get(ref.name, q)
132
- const { data: all } = await parentStore(ref).get('handles', q)
133
- return normalize(all, ref.name)
208
+ if (ref.kind === 'handle') {
209
+ const { data } = await parentStore(ref).get('handles', q)
210
+ return normalize(data, ref.name)
211
+ }
212
+ const res = await ref.handle.store.get(ref.name, q)
213
+ return resolveResult(ref, res)
134
214
  }
135
215
 
136
216
  // Tie a fresh watch stream to its handle's lifecycle (destroyed on close) and
137
217
  // to an optional `{ signal }` (destroyed on abort). Local refs have no
138
218
  // close-cascade, so they keep managing their own streams.
139
219
  const bindStream = (owner, stream, opts) => {
140
- onAbort(opts?.signal, () => stream.destroy())
220
+ const stopAbort = onAbort(opts?.signal, () => stream.destroy())
221
+ // drop the abort listener once the stream ends, so it doesn't linger on a
222
+ // long-lived signal after the stream is gone
223
+ if (stopAbort) stream.once('close', stopAbort)
141
224
  return owner.own ? owner.own(stream) : stream
142
225
  }
143
226
 
@@ -154,7 +237,19 @@ const bindStream = (owner, stream, opts) => {
154
237
  */
155
238
  export const watch = (ref, q, opts) => {
156
239
  const owner = ref.handle
157
- if (ref.kind !== 'handle') return bindStream(owner, owner.store.watch(ref.name, q), opts)
240
+ if (ref.kind !== 'handle') {
241
+ const src = owner.store.watch(ref.name, q)
242
+ const out = new Readable({
243
+ destroy(cb) {
244
+ src.destroy()
245
+ cb(null)
246
+ }
247
+ })
248
+ src.on('data', (res) => out.push(resolveResult(ref, res)))
249
+ src.on('end', () => out.push(null))
250
+ src.on('error', (err) => out.destroy(err))
251
+ return bindStream(owner, out, opts)
252
+ }
158
253
  const source = parentStore(ref).watch('handles', q)
159
254
  const out = new Readable({
160
255
  destroy(cb) {
@@ -184,7 +279,7 @@ export const watch = (ref, q, opts) => {
184
279
  *
185
280
  * @param {Ref} ref
186
281
  * @param {string | { invite?: string, id?: string, name?: string, routes?: any, role?: string, accept?: boolean } | undefined} [arg]
187
- * @returns {Promise<any>} The resolved child `Handle`.
282
+ * @returns {Promise<CeroHandle>} The resolved child handle.
188
283
  */
189
284
  export const open = (ref, arg) => {
190
285
  if (typeof arg === 'string') return ref.handle._join(arg, ref.name)
@@ -193,40 +288,6 @@ export const open = (ref, arg) => {
193
288
  return ref.handle._create(ref.name, arg)
194
289
  }
195
290
 
196
- /**
197
- * Quickly check whether the on-disk directory at `dir` already holds an
198
- * initialised cero identity (i.e. a stored master seed). Opens the local store
199
- * read-only and closes everything before returning.
200
- *
201
- * @param {string} dir Cero data directory.
202
- * @param {any} spec Built spec — same value passed to `cero(dir, spec)`.
203
- * @returns {Promise<boolean>} `true` if a master seed exists on disk.
204
- */
205
- export async function peek(dir, spec) {
206
- if (typeof dir !== 'string' || !dir) throw CeroError.INVALID('dir must be a non-empty string')
207
- if (!spec) throw CeroError.REQUIRED('spec')
208
-
209
- const { default: HypercoreStorage } = await import('hypercore-storage')
210
- const { default: Corestore } = await import('corestore')
211
- const { Local } = await import('../local/index.js')
212
-
213
- const root = new HypercoreStorage(`${dir}/main`)
214
- await root.ready()
215
- const store = new Corestore(root, { manifestVersion: 2 })
216
- await store.ready()
217
-
218
- const local = new Local(null, spec, { store })
219
- await local.ready()
220
- try {
221
- const { data } = await local.store.get('master')
222
- return !!data?.seed
223
- } finally {
224
- await local.close()
225
- await store.close()
226
- await root.close()
227
- }
228
- }
229
-
230
291
  // ─── custom operators ──────────────────────────────────────────────────────
231
292
  // App business logic lives as custom operators: pure functions whose first arg
232
293
  // is the handle they act on, composed from the operators above. `define`
@@ -0,0 +1,42 @@
1
+ import HypercoreStorage from 'hypercore-storage'
2
+ import Corestore from 'corestore'
3
+ import safetyCatch from 'safety-catch'
4
+
5
+ import { CeroError } from '@cero-base/core/errors'
6
+
7
+ import { Local } from '../local/index.js'
8
+
9
+ // Lives outside operators.js so the storage deps stay off the RPC client's
10
+ // module graph — bundlers follow even dynamic imports, and a browser build
11
+ // must never reach hypercore/sodium.
12
+
13
+ /**
14
+ * Quickly check whether the on-disk directory at `dir` already holds an
15
+ * initialised cero identity (i.e. a stored master seed). Opens the local store
16
+ * read-only and closes everything before returning.
17
+ *
18
+ * @param {string} dir Cero data directory.
19
+ * @param {any} spec Built spec — same value passed to `cero(dir, spec)`.
20
+ * @returns {Promise<boolean>} `true` if a master seed exists on disk.
21
+ */
22
+ export async function peek(dir, spec) {
23
+ if (typeof dir !== 'string' || !dir) throw CeroError.INVALID('dir must be a non-empty string')
24
+ if (!spec) throw CeroError.REQUIRED('spec')
25
+
26
+ // Construct first (cheap), ready inside the try — a corrupt dir that throws in
27
+ // any ready() must still close root + store + local, not leak the storage lock.
28
+ const root = new HypercoreStorage(`${dir}/main`)
29
+ const store = new Corestore(root, { manifestVersion: 2 })
30
+ const local = new Local(null, spec, { store })
31
+ try {
32
+ await root.ready()
33
+ await store.ready()
34
+ await local.ready()
35
+ const { data } = await local.store.get('master')
36
+ return !!data?.seed
37
+ } finally {
38
+ await local.close().catch(safetyCatch)
39
+ await store.close().catch(safetyCatch)
40
+ await root.close().catch(safetyCatch)
41
+ }
42
+ }
package/src/lib/utils.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { CeroError } from '@cero-base/core/errors'
2
+
1
3
  /**
2
4
  * @typedef {'collection' | 'single' | 'action' | 'handle'} RefKind
3
5
  * @typedef {{ kind?: string, schema?: string }} RefInfo
@@ -36,19 +38,30 @@ export class Ref {
36
38
  */
37
39
  export function attachRefs(target, refs) {
38
40
  for (const [name, info] of Object.entries(refs || {})) {
41
+ // Fail loud rather than silently overwrite a method/property (close, on,
42
+ // store, …) when a schema declares a ref named like a reserved member.
43
+ if (name in target) {
44
+ throw CeroError.INVALID(
45
+ `schema ref '${name}' collides with a reserved ${target.constructor?.name || 'handle'} member — rename it`
46
+ )
47
+ }
39
48
  target[name] = new Ref(target, name, info.kind, info.schema)
40
49
  }
41
50
  }
42
51
 
43
52
  /**
44
53
  * Run `cb` when `signal` aborts — or immediately if it already has. No-op
45
- * without a signal. The listener removes itself on fire.
54
+ * without a signal. The listener removes itself on fire. Returns a disposer that
55
+ * detaches the listener early, so a manual unsubscribe doesn't leave it lingering
56
+ * on a long-lived signal.
46
57
  *
47
- * @param {AbortSignal} [signal]
58
+ * @param {AbortSignal | undefined} signal
48
59
  * @param {() => void} cb
60
+ * @returns {(() => void) | undefined}
49
61
  */
50
62
  export function onAbort(signal, cb) {
51
63
  if (!signal) return
52
- if (signal.aborted) return cb()
64
+ if (signal.aborted) return void cb()
53
65
  signal.addEventListener('abort', cb, { once: true })
66
+ return () => signal.removeEventListener('abort', cb)
54
67
  }
package/src/rpc/client.js CHANGED
@@ -1,5 +1,8 @@
1
1
  import { Readable } from 'streamx'
2
2
  import { RPCClient, bindCodec } from '@cero-base/core/rpc'
3
+ import c from 'compact-encoding'
4
+ import z32 from 'z32'
5
+ import { decodeId } from '@cero-base/core/blobs/codec'
3
6
 
4
7
  import { attachRefs } from '../lib/utils.js'
5
8
  import { put, set, get, del, count, watch, call, open, bind, define } from '../lib/operators.js'
@@ -32,6 +35,30 @@ export { put, set, get, del, count, watch, call, open, bind, define, t, schema }
32
35
  * @property {string|null} name
33
36
  */
34
37
 
38
+ // Compact-encoding blobId struct matching hypercore-blob-server's wire format.
39
+ const blobIdEnc = {
40
+ preencode(state, b) {
41
+ c.uint.preencode(state, b.blockOffset)
42
+ c.uint.preencode(state, b.blockLength)
43
+ c.uint.preencode(state, b.byteOffset)
44
+ c.uint.preencode(state, b.byteLength)
45
+ },
46
+ encode(state, b) {
47
+ c.uint.encode(state, b.blockOffset)
48
+ c.uint.encode(state, b.blockLength)
49
+ c.uint.encode(state, b.byteOffset)
50
+ c.uint.encode(state, b.byteLength)
51
+ },
52
+ decode(state) {
53
+ return {
54
+ blockOffset: c.uint.decode(state),
55
+ blockLength: c.uint.decode(state),
56
+ byteOffset: c.uint.decode(state),
57
+ byteLength: c.uint.decode(state)
58
+ }
59
+ }
60
+ }
61
+
35
62
  // Mixin applied to Client, Handle and LocalRefs so they expose the same
36
63
  // row-ops surface as a local cero handle but routed over the wire. `_local`
37
64
  // selects the per-device store + JSON codec; main refs use the schema codec.
@@ -54,6 +81,67 @@ const operators = {
54
81
  return this._refInfo(name)?.schema
55
82
  },
56
83
 
84
+ /**
85
+ * Build a renderable URL for a file id using the base + token learned at init.
86
+ *
87
+ * @param {string} id
88
+ * @returns {string}
89
+ */
90
+ url(id) {
91
+ const root = this.parent || this
92
+ const base = root._fileBase || ''
93
+ const token = root._fileToken || ''
94
+ const { coreKey, blobId, type } = decodeId(id)
95
+ const key = z32.encode(coreKey)
96
+ const blob = z32.encode(c.encode(blobIdEnc, blobId))
97
+ const tp = type ? `&type=${encodeURIComponent(type)}` : ''
98
+ const tok = token ? `&token=${token}` : ''
99
+ return `${base}/?key=${key}&blob=${blob}${tp}${tok}`
100
+ },
101
+
102
+ /**
103
+ * Augment a decoded row (or array of rows) to resolve file-typed fields to
104
+ * `{ id, type, size, url }` objects. The `files` builtin's own `id` is the
105
+ * file id; other refs declare file fields in `meta.refs[name].files`.
106
+ *
107
+ * @param {string} name
108
+ * @param {any} data
109
+ * @returns {any}
110
+ */
111
+ _resolveFiles(name, data) {
112
+ if (data == null) return data
113
+ const info = this._refInfo(name)
114
+ if (Array.isArray(data)) return data.map((row) => this._resolveRow(name, info, row))
115
+ return this._resolveRow(name, info, data)
116
+ },
117
+
118
+ _resolveRow(name, info, row) {
119
+ if (!row || typeof row !== 'object') return row
120
+ if (info?.builtin && info?.verb === 'file') {
121
+ if (!row.id) return row
122
+ try {
123
+ const { type, blobId } = decodeId(row.id)
124
+ return { ...row, type, size: blobId.byteLength, url: this.url(row.id) }
125
+ } catch {
126
+ return row
127
+ }
128
+ }
129
+ const fileFields = info?.files
130
+ if (!fileFields || !fileFields.length) return row
131
+ const out = { ...row }
132
+ for (const f of fileFields) {
133
+ const v = out[f]
134
+ if (v == null) continue
135
+ try {
136
+ const { type, blobId } = decodeId(v)
137
+ out[f] = { id: v, type, size: blobId.byteLength, url: this.url(v) }
138
+ } catch {
139
+ // leave as-is if not a valid file id
140
+ }
141
+ }
142
+ return out
143
+ },
144
+
57
145
  /**
58
146
  * Insert a row over the wire.
59
147
  *
@@ -64,6 +152,16 @@ const operators = {
64
152
  async put(name, row) {
65
153
  const codec = this._codec()
66
154
  const schema = this.schemaOf(name)
155
+ if (row && row.data != null) {
156
+ const res = await this.rpc.addFile({
157
+ handle: this.id,
158
+ data: row.data,
159
+ name: row.name || '',
160
+ type: row.type || ''
161
+ })
162
+ const decoded = codec.decodeRow(schema, res.data)
163
+ return { data: this._resolveRow(name, this._refInfo(name), decoded) }
164
+ }
67
165
  const input = row?.id ? row : { id: '', ...row }
68
166
  const res = await this.rpc.addRow({
69
167
  handle: this.id,
@@ -75,20 +173,22 @@ const operators = {
75
173
  },
76
174
 
77
175
  /**
78
- * Upsert a row over the wire.
176
+ * Upsert a row over the wire. Pass `{ upsert: false }` to update-only.
79
177
  *
80
178
  * @param {string} name
81
179
  * @param {Record<string, any>} row
180
+ * @param {{ upsert?: boolean }} [opts]
82
181
  * @returns {Promise<SingleResult>}
83
182
  */
84
- async set(name, row) {
183
+ async set(name, row, opts) {
85
184
  const codec = this._codec()
86
185
  const schema = this.schemaOf(name)
87
186
  const res = await this.rpc.set({
88
187
  handle: this.id,
89
188
  ref: name,
90
189
  data: codec.encodeRow(schema, row),
91
- local: this._local
190
+ local: this._local,
191
+ noUpsert: opts?.upsert === false || undefined
92
192
  })
93
193
  return { data: codec.decodeRow(schema, res.data) }
94
194
  },
@@ -111,7 +211,8 @@ const operators = {
111
211
  id: query,
112
212
  local: this._local
113
213
  })
114
- return { data: res.data ? codec.decodeRow(schema, res.data) : null }
214
+ const decoded = res.data ? codec.decodeRow(schema, res.data) : null
215
+ return { data: this._resolveFiles(name, decoded) }
115
216
  }
116
217
  const res = await this.rpc.get({
117
218
  handle: this.id,
@@ -119,11 +220,12 @@ const operators = {
119
220
  query: codec.encodeQuery(query),
120
221
  local: this._local
121
222
  })
122
- const data =
123
- this._refInfo(name)?.kind === 'single'
223
+ const info = this._refInfo(name)
224
+ const raw =
225
+ info?.kind === 'single'
124
226
  ? codec.decodeRow(schema, res.data)
125
227
  : codec.decodeRows(schema, res.data)
126
- return { data, total: res.total, size: res.size }
228
+ return { data: this._resolveFiles(name, raw), total: res.total, size: res.size }
127
229
  },
128
230
 
129
231
  /**
@@ -179,13 +281,21 @@ const operators = {
179
281
  }
180
282
  })
181
283
  wire.on('data', (snap) => {
182
- const data =
284
+ const raw =
183
285
  refInfo.kind === 'single'
184
286
  ? (codec.decodeRow(schema, snap.data) ?? null)
185
287
  : codec.decodeRows(schema, snap.data)
186
- out.push({ data, total: snap.total, size: snap.size })
288
+ out.push({ data: this._resolveFiles(name, raw), total: snap.total, size: snap.size })
187
289
  })
188
- wire.on('end', () => out.push(null))
290
+ // end exactly once whether the wire ends or the server destroys it (handle close)
291
+ let ended = false
292
+ const end = () => {
293
+ if (ended || out.destroyed) return
294
+ ended = true
295
+ out.push(null)
296
+ }
297
+ wire.on('end', end)
298
+ wire.on('close', end)
189
299
  wire.on('error', (err) => out.destroy(err))
190
300
  return out
191
301
  },
@@ -239,7 +349,7 @@ export async function restore(me, phrase) {
239
349
  const res = await me.rpc.restore({ phrase })
240
350
  me.id = res.id
241
351
  me.deviceId = res.deviceId || null
242
- me.identity = { id: res.id, toPhrase: () => res.phrase || null }
352
+ me.identity = { id: res.id, toPhrase: async () => (await me.rpc.seed({})).phrase || null }
243
353
  return me
244
354
  }
245
355
 
@@ -295,10 +405,12 @@ export class Client extends RPCClient {
295
405
 
296
406
  async _open() {
297
407
  await super._open()
298
- const { id, deviceId, phrase } = await this.rpc.init({})
408
+ const { id, deviceId, fileBase, fileToken } = await this.rpc.init({})
299
409
  this.id = id
300
410
  this.deviceId = deviceId || null
301
- this.identity = { id, toPhrase: () => phrase || null }
411
+ this._fileBase = fileBase || ''
412
+ this._fileToken = fileToken || ''
413
+ this.identity = { id, toPhrase: async () => (await this.rpc.seed({})).phrase || null }
302
414
  attachRefs(this, /** @type {Spec} */ (this.spec).meta.refs)
303
415
  bind(this, null)
304
416
  if (/** @type {Spec} */ (this.spec).meta.local?.refs) this.local = new LocalRefs(this)
@@ -312,10 +424,12 @@ export class Client extends RPCClient {
312
424
  * @returns {Promise<Handle>}
313
425
  */
314
426
  async _create(type, opts = {}) {
427
+ // `routes` are functions and can't cross the wire; role/accept now do.
428
+ const wire = { ...opts, noAccept: opts.accept === false || undefined }
315
429
  const stub = await this.rpc.addHandle({
316
430
  ref: type,
317
431
  handle: this.id,
318
- data: this.spec.codec.encodeCreate(opts)
432
+ data: this.spec.codec.encodeCreate(wire)
319
433
  })
320
434
  return new Handle(this, stub.id, stub.type, stub.name || null)
321
435
  }