@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.
- package/README.md +64 -14
- package/package.json +20 -10
- package/src/build/builtins.js +10 -3
- package/src/build/index.js +24 -4
- package/src/build/schemas.js +20 -1
- package/src/handle/index.js +178 -69
- package/src/index.js +85 -64
- package/src/lib/constants.js +2 -1
- package/src/lib/operators.js +114 -53
- package/src/lib/peek.js +42 -0
- package/src/lib/utils.js +16 -3
- package/src/rpc/client.js +128 -14
- package/src/rpc/server.js +54 -7
- package/types/build/builtins.d.ts +5 -1
- package/types/build/index.d.ts +4 -21
- package/types/build/schemas.d.ts +19 -0
- package/types/handle/index.d.ts +49 -8
- package/types/index.d.ts +10 -5
- package/types/lib/constants.d.ts +1 -0
- package/types/lib/operators.d.ts +30 -10
- package/types/lib/peek.d.ts +10 -0
- package/types/lib/utils.d.ts +6 -3
- package/types/rpc/client.d.ts +3 -1
- package/types/rpc/server.d.ts +6 -5
package/src/lib/operators.js
CHANGED
|
@@ -1,28 +1,54 @@
|
|
|
1
1
|
import { Readable } from 'streamx'
|
|
2
2
|
|
|
3
|
-
import {
|
|
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
|
-
*
|
|
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) =>
|
|
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
|
-
|
|
86
|
-
|
|
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
|
-
|
|
106
|
-
|
|
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
|
|
132
|
-
|
|
133
|
-
|
|
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')
|
|
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<
|
|
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`
|
package/src/lib/peek.js
ADDED
|
@@ -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}
|
|
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
|
-
|
|
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
|
|
123
|
-
|
|
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
|
|
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
|
-
|
|
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: () =>
|
|
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,
|
|
408
|
+
const { id, deviceId, fileBase, fileToken } = await this.rpc.init({})
|
|
299
409
|
this.id = id
|
|
300
410
|
this.deviceId = deviceId || null
|
|
301
|
-
this.
|
|
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(
|
|
432
|
+
data: this.spec.codec.encodeCreate(wire)
|
|
319
433
|
})
|
|
320
434
|
return new Handle(this, stub.id, stub.type, stub.name || null)
|
|
321
435
|
}
|