datagrok-tools 6.5.7 → 6.5.9

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.
@@ -0,0 +1,509 @@
1
+ /// Docs: [Entity export / import](/docs/features/grok-tool/export-import/DESIGN.md)
2
+ import {NodeDapi} from '../node-dapi';
3
+ import {BytesKind, TYPES, Ref, TypeOptions, UUID_RE, datasyncFilePaths, inNamespace, isBuiltinGroup, nqNameOf, untransferableReason} from './registry';
4
+ import {BundleEntity, normalize} from './bundle';
5
+ import {nestedIds, rewrite} from './rewriter';
6
+ import {pool} from './pool';
7
+
8
+ export interface Selection {
9
+ types: string[];
10
+ names: string[];
11
+ name?: string;
12
+ namespace?: string;
13
+ space?: string;
14
+ author?: string;
15
+ tag?: string;
16
+ since?: string;
17
+ filter?: string;
18
+ /** Per-type listing rules of the `--type` aliases (`space`, `dashboard`). */
19
+ typeOptions?: Record<string, TypeOptions>;
20
+ }
21
+
22
+ export type Note = (row: {name: string; entityType: string; action: 'warn' | 'info'; reason: string; detail?: string}) => void;
23
+
24
+ export type Progress = (stage: string, done?: number, total?: number) => void;
25
+ const noProgress: Progress = () => {};
26
+
27
+ /** An entity that stays on the source but is still referenced by what travels. */
28
+ export interface External {id: string; type: string; nqName: string}
29
+
30
+ /** What the bundle points at but does not carry: resolvable on the source, or dead there too. */
31
+ export interface Outside {externals: External[]; dangling: string[]}
32
+
33
+ const ALL_USERS = 'a4b45840-9a50-11e6-9cc9-8546b8bf62e6';
34
+
35
+ /** One round trip per entity, tens of thousands per stand — modest, since the server is shared. */
36
+ const WALK_CONCURRENCY = 12;
37
+
38
+ const quoted = (v: string): string => `"${v.replace(/"/g, '\\"')}"`;
39
+
40
+ /**
41
+ * Compiles the selection flags into one smart-filter expression. A structured `name`
42
+ * clause is unreliable — the server rewrites `name` to `friendlyName`
43
+ * (`repository_query.dart` whereSmart) and `/projects` never resolves it — so `--name`
44
+ * becomes a free-text search, narrowed client-side by `matchesName`. Free text is not
45
+ * part of the grammar, so it is sent only when it is the whole filter.
46
+ */
47
+ export function compileFilter(sel: Selection, type?: string): string {
48
+ const clauses: string[] = [];
49
+ const typeClause = type ? sel.typeOptions?.[type]?.clause : undefined;
50
+ const namespace = sel.namespace ?? sel.space;
51
+ if (namespace)
52
+ clauses.push(`namespace starts ${quoted(`${namespace.replace(/:$/, '')}:`)}`);
53
+ if (sel.author)
54
+ clauses.push(`author = ${quoted(sel.author)}`);
55
+ if (sel.since)
56
+ clauses.push(`updatedOn > ${sel.since}`);
57
+ if (sel.filter)
58
+ clauses.push(`(${sel.filter})`);
59
+ if (sel.name && !clauses.length && !typeClause)
60
+ return sel.name.replace(/[*?]/g, ' ').trim();
61
+ if (typeClause)
62
+ clauses.push(typeClause);
63
+ return clauses.join(' and ');
64
+ }
65
+
66
+ /** `--name` glob, matched against both the internal name and the user-visible one. */
67
+ export function matchesName(glob: string, json: any): boolean {
68
+ const pattern = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.');
69
+ const re = new RegExp(`^${pattern}$`, 'i');
70
+ return re.test(json?.name ?? '') || re.test(json?.friendlyName ?? '');
71
+ }
72
+
73
+ /**
74
+ * minimist swallows `--since -2w` (the value parses as a flag), so `2w` means `-2w`.
75
+ * An absolute date has to reach the filter quoted — `updatedOn > 2020-01-01` parses
76
+ * but matches nothing.
77
+ */
78
+ export function normalizeSince(value: any): string | undefined {
79
+ if (value === undefined || value === null) return undefined;
80
+ if (value === true || value === '')
81
+ throw new Error('`--since` needs a value: `--since=-2w` or `--since 2w`');
82
+ const text = String(value).trim();
83
+ if (/^-?\d+[dwmy]$/.test(text))
84
+ return text.startsWith('-') ? text : `-${text}`;
85
+ if (/^\d{4}-\d{2}-\d{2}([T ][\d:.]+Z?)?$/.test(text))
86
+ return quoted(text);
87
+ throw new Error(`\`--since\` takes a timespan ('2w', '-30d') or an ISO date ('2026-08-01'), got '${text}'`);
88
+ }
89
+
90
+ /** `GET /entities/{id}` answers with an array holding one typed entity. */
91
+ export async function findEntity(dapi: NodeDapi, id: string): Promise<any> {
92
+ const found = await dapi.internal('/entities').find(id);
93
+ return (Array.isArray(found) ? found[0] : found) ?? null;
94
+ }
95
+
96
+ /** One unreachable entity is a warning, not the end of the run. */
97
+ async function tryFind(dapi: NodeDapi, type: string, id: string, name: string, note: Note): Promise<any> {
98
+ try {
99
+ return await dapi.internal(TYPES[type].route).find(id);
100
+ } catch (err: any) {
101
+ note({name, entityType: type, action: 'warn', reason: 'fetch_failed', detail: err?.message ?? String(err)});
102
+ return null;
103
+ }
104
+ }
105
+
106
+ /** UUID, or `namespace:name` (`name` alone for entities in the root namespace). */
107
+ export async function resolveEntity(dapi: NodeDapi, token: string, type?: string): Promise<any> {
108
+ if (UUID_RE.test(token)) {
109
+ const one = await findEntity(dapi, token);
110
+ if (!one) throw new Error(`No entity with id '${token}'`);
111
+ return one;
112
+ }
113
+ const cut = token.lastIndexOf(':');
114
+ const namespace = cut === -1 ? '' : token.slice(0, cut + 1);
115
+ const name = token.slice(cut + 1);
116
+ // The namespace is sent even when empty — omitting it matches the name in every namespace.
117
+ const matches = (await dapi.internal('/entities').list({namespace, name}))
118
+ .filter((m: any) => inNamespace(m, namespace) && (!type || m['#type'] === type));
119
+ if (!matches.length)
120
+ throw new Error(`No ${type ?? 'entity'} named '${token}'` +
121
+ (cut === -1 ? ` in the root namespace — qualify it, e.g. Admin:${name}` : ''));
122
+ if (matches.length > 1)
123
+ throw new Error(`'${token}' matches ${matches.length} entities: ${matches.map((m: any) => `${m['#type']} ${m.id}`).join(', ')}`);
124
+ return matches[0];
125
+ }
126
+
127
+ /**
128
+ * What never travels, however the entity was chosen — `--no-deps` skips the walk, not these
129
+ * rules. Returns true when the entity was dropped (and noted).
130
+ */
131
+ async function untransferable(dapi: NodeDapi, type: string, json: any, note: Note,
132
+ packageNames: Map<string, string>, externals?: External[]): Promise<boolean> {
133
+ const drop = (action: 'warn' | 'info', reason: string, detail?: string) => {
134
+ note({name: nqNameOf(json), entityType: type, action, reason, detail});
135
+ // What stays behind is still referenced by what travels. The id is the instance's own, so
136
+ // the push has to find the target's equivalent by name — record what to look for.
137
+ if (externals && json?.id)
138
+ externals.push({id: json.id, type, nqName: nqNameOf(json)});
139
+ return true;
140
+ };
141
+ if (json.package)
142
+ return drop('warn', 'package_entity', await packageName(dapi, json.package.id, packageNames));
143
+ const refuse = untransferableReason(type, json);
144
+ if (refuse)
145
+ return drop(refuse.action, refuse.reason);
146
+ // Only a blob of its own migrates: a file inside a share is a row the target's own
147
+ // share re-creates, and its connection never travels (same rule as the 1.28 executor).
148
+ if (type === 'FileInfo' && json.connection?.id)
149
+ return drop('info', 'file_in_share_not_migratable');
150
+ return false;
151
+ }
152
+
153
+ export async function select(dapi: NodeDapi, sel: Selection, note: Note,
154
+ progress: Progress = noProgress): Promise<Map<string, BundleEntity>> {
155
+ const picked = new Map<string, BundleEntity>();
156
+ const packageNames = new Map<string, string>();
157
+ const add = async (lite: any) => {
158
+ const type = lite?.['#type'];
159
+ if (!TYPES[type]) {
160
+ note({name: lite?.name ?? lite?.id ?? '', entityType: type ?? '?', action: 'warn', reason: 'type_unsupported'});
161
+ return;
162
+ }
163
+ if (picked.has(lite.id)) return;
164
+ // The listing already carries what decides this, and the entities it rules out are the
165
+ // expensive ones: `GET /projects/{id}` on a package namespace holds tens of thousands of
166
+ // children and never answers, so fetching one only to discard it costs the request deadline.
167
+ const listed = untransferableReason(type, lite);
168
+ if (listed) {
169
+ note({name: nqNameOf(lite), entityType: type, action: listed.action, reason: listed.reason});
170
+ return;
171
+ }
172
+ const json = await tryFind(dapi, type, lite.id, nqNameOf(lite), note);
173
+ if (json && !await untransferable(dapi, type, json, note, packageNames))
174
+ picked.set(lite.id, {type, json});
175
+ };
176
+
177
+ if (sel.space)
178
+ await add(await resolveEntity(dapi, sel.space));
179
+ // `--namespace X` matches what is *inside* X, never X itself: a personal root or a space is
180
+ // named `X` in the root namespace. Without it nothing selected has anywhere to be placed, and
181
+ // the whole selection lands under the pushing account.
182
+ if (sel.namespace) {
183
+ const owner = await resolveEntity(dapi, sel.namespace.replace(/:$/, ''), 'Project').catch(() => null);
184
+ if (owner)
185
+ await add(owner);
186
+ else
187
+ note({name: sel.namespace, entityType: 'Project', action: 'warn', reason: 'owning_space_not_found',
188
+ detail: 'nothing selected can be placed under it — the content will land under the pushing account'});
189
+ }
190
+ for (const n of sel.names)
191
+ await add(await resolveEntity(dapi, n));
192
+
193
+ if (!sel.names.length || compileFilter(sel) || sel.tag) {
194
+ for (const type of sel.types) {
195
+ const spec = TYPES[type];
196
+ if (sel.tag && !spec.tags) {
197
+ note({name: '', entityType: type, action: 'warn', reason: 'tag_unsupported'});
198
+ continue;
199
+ }
200
+ // Types without a list route of their own are listed polymorphically by type id.
201
+ const route = spec.listVia === 'entities' ? '/entities' : spec.route;
202
+ const params = {
203
+ text: compileFilter(sel, type) || undefined,
204
+ tags: sel.tag,
205
+ typeId: spec.typeId,
206
+ ...(sel.typeOptions?.[type]?.params ?? {}),
207
+ };
208
+ const listed = await dapi.internal(route).listAll(params);
209
+ const wanted = listed.filter((lite: any) => !sel.name || matchesName(sel.name, lite));
210
+ let done = 0;
211
+ await pool(wanted, WALK_CONCURRENCY, async (lite: any) => {
212
+ await add(lite);
213
+ progress(`selecting ${type}`, ++done, wanted.length);
214
+ });
215
+ }
216
+ }
217
+ return picked;
218
+ }
219
+
220
+ export async function expand(dapi: NodeDapi, selected: Map<string, BundleEntity>, note: Note,
221
+ externals?: External[], progress: Progress = noProgress): Promise<Map<string, BundleEntity>> {
222
+ const out = new Map<string, BundleEntity>();
223
+ const seen = new Set<string>();
224
+ const queue: {type: string; id: string; json?: any}[] = [];
225
+ const packageNames = new Map<string, string>();
226
+ // Datasync scripts name the same query over and over; the promise is cached so concurrent
227
+ // walkers share one lookup rather than each starting their own.
228
+ const resolved = new Map<string, Promise<any>>();
229
+
230
+ const enqueue = (type: string, id: string, json?: any) => {
231
+ if (!id || seen.has(id) || !TYPES[type]) return;
232
+ seen.add(id);
233
+ queue.push({type, id, json});
234
+ };
235
+
236
+ for (const [id, e] of selected)
237
+ enqueue(e.type, id, e.json);
238
+
239
+ // A generation at a time: what a batch discovers is queued for the next, so the walk stays
240
+ // breadth-first while the round trips overlap.
241
+ while (queue.length) {
242
+ const batch = queue.splice(0, WALK_CONCURRENCY);
243
+ await pool(batch, WALK_CONCURRENCY, async ({type, id, json: known}) => {
244
+ const json = known ?? await tryFind(dapi, type, id, id, note);
245
+ if (!json) return;
246
+
247
+ if (await untransferable(dapi, type, json, note, packageNames, externals)) return;
248
+ out.set(id, {type, json});
249
+ // No honest total: the queue grows as dependencies are discovered and shrinks as they
250
+ // are taken, so a denominator here would move backwards.
251
+ progress('walking dependencies', out.size);
252
+
253
+ if (type === 'Project') {
254
+ for (const r of await projectRelations(dapi, id, json, note))
255
+ enqueue(r?.entity?.['#type'], r?.entity?.id);
256
+ for (const route of ['/views', '/layouts'])
257
+ for (const v of await dapi.internal(route).listAll({projectId: id}))
258
+ enqueue(v['#type'], v.id);
259
+ }
260
+
261
+ for (const dep of TYPES[type].deps?.(json) ?? []) {
262
+ if (dep.id || !dep.nqName) {
263
+ enqueue(dep.type ?? '', dep.id ?? '');
264
+ continue;
265
+ }
266
+ const key = `${dep.type ?? ''}|${dep.nqName}`;
267
+ if (!resolved.has(key))
268
+ resolved.set(key, resolveDep(dapi, dep, note));
269
+ const found = await resolved.get(key);
270
+ if (found)
271
+ enqueue(found['#type'], found.id);
272
+ }
273
+ });
274
+ }
275
+ await expandGrants(dapi, out, note, progress);
276
+ progress('resolving personal spaces');
277
+ await markPersonalProjects(dapi, out);
278
+ return out;
279
+ }
280
+
281
+ /**
282
+ * A user's personal root project is created with the user and cannot be pushed — whichever order
283
+ * you try, the name collides. Marking whose it is lets the push point at the target's own copy
284
+ * instead, so everything that lives under it keeps its place.
285
+ */
286
+ async function markPersonalProjects(dapi: NodeDapi, out: Map<string, BundleEntity>): Promise<void> {
287
+ if (![...out.values()].some((e) => e.type === 'Project')) return;
288
+ const owner = new Map<string, string>();
289
+ for (const user of await dapi.internal('/users').listAll({limit: 500}))
290
+ if (user?.project?.id && user.login)
291
+ owner.set(user.project.id, user.login);
292
+ for (const [id, e] of out)
293
+ if (e.type === 'Project' && owner.has(id))
294
+ e.json._personalOf = owner.get(id);
295
+ }
296
+
297
+ /**
298
+ * `GET /projects/relations?include=entity` fails on a project that links domain-table rows
299
+ * (`projects_service.dart:425`), so the project's own `relations[]` stands in — typed as
300
+ * well, just fetched together with the project.
301
+ */
302
+ async function projectRelations(dapi: NodeDapi, id: string, json: any, note: Note): Promise<any[]> {
303
+ try {
304
+ return await dapi.internal('/projects/relations').listAll({projectId: id, include: 'entity'});
305
+ } catch (err: any) {
306
+ note({name: nqNameOf(json), entityType: 'Project', action: 'warn', reason: 'relations_degraded',
307
+ detail: err?.message ?? String(err)});
308
+ return json.relations ?? [];
309
+ }
310
+ }
311
+
312
+ async function resolveDep(dapi: NodeDapi, dep: Ref, note: Note): Promise<any> {
313
+ try {
314
+ return await resolveEntity(dapi, dep.nqName!, dep.type);
315
+ } catch (err: any) {
316
+ note({name: dep.nqName ?? '', entityType: dep.type ?? 'Entity', action: 'warn', reason: 'dependency_not_found', detail: err?.message});
317
+ return null;
318
+ }
319
+ }
320
+
321
+ /** Entities carry the id of the PUBLISHED package, which only `/packages/published` resolves. */
322
+ async function packageName(dapi: NodeDapi, id: string, cache: Map<string, string>): Promise<string> {
323
+ if (!cache.has(id))
324
+ cache.set(id, (await dapi.internal('/packages/published').find(id))?.name ?? '');
325
+ return cache.get(id)!;
326
+ }
327
+
328
+ export const visibleGroup = (g: any): boolean => !!g?.id && g.personal !== true && g.hidden !== true;
329
+
330
+ /** One `GET /groups/{id}` per group per run, shared by everything that resolves grants. */
331
+ export function groupCache(dapi: NodeDapi): (id: string) => Promise<any> {
332
+ const fetched = new Map<string, any>();
333
+ return async (id: string) => {
334
+ if (!fetched.has(id))
335
+ fetched.set(id, await dapi.internal('/groups').find(id));
336
+ return fetched.get(id);
337
+ };
338
+ }
339
+
340
+ /**
341
+ * Grants held on the entity itself by groups a human can belong to. `all=true` is the
342
+ * only listing that hydrates `permission`; it also returns rows inherited from containing
343
+ * entities, which are granted on those entities, not here.
344
+ */
345
+ export async function grantsOf(dapi: NodeDapi, id: string, group: (gid: string) => Promise<any> = groupCache(dapi),
346
+ ): Promise<{groupId: string; group: string; permission: string}[]> {
347
+ const out: {groupId: string; group: string; permission: string}[] = [];
348
+ for (const perm of await dapi.internal('/privileges/permissions').list({entityId: id, all: 'true'})) {
349
+ if (perm.entityId !== id || !perm.permission?.name || !perm.userGroup?.id) continue;
350
+ const g = await group(perm.userGroup.id);
351
+ if (!visibleGroup(g)) continue;
352
+ out.push({groupId: g.id, group: g.friendlyName ?? g.name, permission: perm.permission.name});
353
+ }
354
+ return out;
355
+ }
356
+
357
+ /**
358
+ * Grant-holder groups, their parents and their members. Group and user ids are not
359
+ * portable, so grants and memberships are recorded under bundle-only `_grants` /
360
+ * `_members` keys and replayed by name and login on the target.
361
+ */
362
+ async function expandGrants(dapi: NodeDapi, out: Map<string, BundleEntity>, note: Note,
363
+ progress: Progress = noProgress): Promise<void> {
364
+ const group = groupCache(dapi);
365
+ const queue: any[] = [];
366
+ const entities = [...out].filter(([, e]) => e.type !== 'UserGroup');
367
+ for (const [, e] of out)
368
+ if (e.type === 'UserGroup') queue.push(e.json);
369
+ let seen = 0;
370
+ await pool(entities, WALK_CONCURRENCY, async ([id, e]) => {
371
+ progress('reading grants', ++seen, entities.length);
372
+ const grants = await grantsOf(dapi, id, group);
373
+ if (!grants.length) return;
374
+ e.json._grants = grants.map((g) => ({group: g.group, permission: g.permission}));
375
+ for (const g of grants)
376
+ queue.push(await group(g.groupId));
377
+ });
378
+
379
+ const done = new Set<string>();
380
+ while (queue.length) {
381
+ const g = queue.shift()!;
382
+ if (done.has(g.id)) continue;
383
+ done.add(g.id);
384
+ // The grant still travels by name; the group itself belongs to the target instance.
385
+ if (isBuiltinGroup(g)) {
386
+ note({name: g.friendlyName ?? g.name, entityType: 'UserGroup', action: 'info', reason: 'platform_group'});
387
+ continue;
388
+ }
389
+ if (!out.has(g.id))
390
+ out.set(g.id, {type: 'UserGroup', json: g});
391
+ const json = out.get(g.id)!.json;
392
+ const members = await groupMembers(dapi, json, group, queue, note);
393
+ if (members.length)
394
+ json._members = members;
395
+ for (const rel of json.parents ?? []) {
396
+ const parent = rel.parent?.id ? await group(rel.parent.id) : null;
397
+ if (visibleGroup(parent) && parent.id !== ALL_USERS)
398
+ queue.push(parent);
399
+ }
400
+ }
401
+ }
402
+
403
+ /** Member groups are bundled too — replaying a membership by name needs the group on the target. */
404
+ async function groupMembers(dapi: NodeDapi, g: any, group: (id: string) => Promise<any>, queue: any[], note: Note): Promise<any[]> {
405
+ const members: any[] = [];
406
+ for (const rel of g.children ?? []) {
407
+ const child = rel.child?.id ? await group(rel.child.id) : null;
408
+ if (!child) continue;
409
+ const isAdmin = rel.isAdmin === true;
410
+ if (child.personal !== true) {
411
+ if (!visibleGroup(child)) continue;
412
+ members.push({kind: 'group', name: child.friendlyName ?? child.name, isAdmin});
413
+ queue.push(child);
414
+ continue;
415
+ }
416
+ const user = await dapi.internal(`/groups/${child.id}`).find('user');
417
+ if (user?.login)
418
+ members.push({kind: 'user', login: user.login, email: user.email, isAdmin});
419
+ else
420
+ note({name: g.friendlyName ?? g.name, entityType: 'UserGroup', action: 'warn',
421
+ reason: 'member_unresolved', detail: child.friendlyName ?? child.id});
422
+ }
423
+ return members;
424
+ }
425
+
426
+ /**
427
+ * A datasync table carries no data of its own — it rebuilds itself from a file on open. The
428
+ * table travels, the file does not, so the dashboard lands empty unless the file comes too.
429
+ */
430
+ /**
431
+ * Anything the bundle points at but does not carry. Most of it belongs to the instance rather
432
+ * than to the content — a package's own rows, a platform share, a personal space — and every
433
+ * instance mints its own id for those, so the only thing that can survive the trip is the name.
434
+ */
435
+ export async function collectExternals(dapi: NodeDapi, entities: Map<string, BundleEntity>, note: Note,
436
+ progress: Progress = noProgress): Promise<Outside> {
437
+ const mine = new Set<string>();
438
+ const referenced = new Set<string>();
439
+ // The bundle stores the normalized form, and normalizing drops the embedded copies that make an
440
+ // outside entity look like a nested row — scanning the live JSON would call those ids our own.
441
+ for (const [id, {type, json}] of entities) {
442
+ const stored = normalize(type, json);
443
+ mine.add(id);
444
+ for (const nested of nestedIds(stored)) mine.add(nested);
445
+ rewrite(stored, {}, referenced);
446
+ }
447
+ const externals: External[] = [];
448
+ const dangling: string[] = [];
449
+ const outside = [...referenced].filter((id) => !mine.has(id));
450
+ let seen = 0;
451
+ await pool(outside, WALK_CONCURRENCY, async (id) => {
452
+ const found = await findEntity(dapi, id).catch(() => undefined);
453
+ if (found === undefined) {
454
+ note({name: id, entityType: 'Entity', action: 'warn', reason: 'reference_unresolved'});
455
+ return;
456
+ }
457
+ // Nothing on the source answers to it either: the reference died here, and no target can
458
+ // satisfy it. Recording that is what lets the push tell broken source data from a real failure.
459
+ // Only a type this tool migrates is worth recording: the push re-finds an external by name, and
460
+ // one it can never carry — a report, which belongs to the stand it was raised on — would just
461
+ // become a warning nobody can act on (900 of them on one real bundle).
462
+ if (found && TYPES[found['#type']])
463
+ externals.push({id, type: found['#type'], nqName: nqNameOf(found)});
464
+ else if (!found)
465
+ dangling.push(id);
466
+ progress('resolving outside references', ++seen, outside.length);
467
+ });
468
+ externals.sort((a, b) => a.id.localeCompare(b.id));
469
+ dangling.sort();
470
+ return {externals, dangling};
471
+ }
472
+
473
+ export async function pullShares(dapi: NodeDapi, entities: Map<string, BundleEntity>,
474
+ note: Note, progress: Progress = noProgress): Promise<Map<string, Buffer>> {
475
+ const wanted = new Set<string>();
476
+ for (const [, {type, json}] of entities)
477
+ if (type === 'TableInfo')
478
+ for (const p of datasyncFilePaths(json)) wanted.add(p);
479
+
480
+ const files = new Map<string, Buffer>();
481
+ let done = 0;
482
+ for (const remote of wanted) {
483
+ progress('fetching share files', ++done, wanted.size);
484
+ try {
485
+ files.set(remote, await dapi.files.readBytes(remote));
486
+ } catch (err: any) {
487
+ note({name: remote, entityType: 'File', action: 'warn', reason: 'share_file_unreadable',
488
+ detail: err?.message ?? String(err)});
489
+ }
490
+ }
491
+ return files;
492
+ }
493
+
494
+ export async function pullBytes(dapi: NodeDapi, entities: Map<string, BundleEntity>, note: Note,
495
+ kinds: BytesKind[], progress: Progress = noProgress): Promise<Map<string, Buffer>> {
496
+ const bytes = new Map<string, Buffer>();
497
+ const withBytes = [...entities].filter(([, e]) => TYPES[e.type].bytes && kinds.includes(TYPES[e.type].bytes!.kind));
498
+ let done = 0;
499
+ for (const [id, {type, json}] of withBytes) {
500
+ const spec = TYPES[type];
501
+ progress('fetching data', ++done, withBytes.length);
502
+ try {
503
+ bytes.set(id, await dapi.client.getBytes(spec.bytes!.get(id)));
504
+ } catch (err: any) {
505
+ note({name: json.name ?? id, entityType: type, action: 'warn', reason: 'no_data', detail: err?.message});
506
+ }
507
+ }
508
+ return bytes;
509
+ }