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.
- package/CHANGELOG.md +35 -0
- package/CLAUDE.md +25 -10
- package/Core.json +1027 -0
- package/GROK_S.md +511 -27
- package/bin/commands/api.js +121 -70
- package/bin/commands/help.js +3 -75
- package/bin/commands/server-domains.js +468 -0
- package/bin/commands/server-migrate.js +392 -0
- package/bin/commands/server.js +208 -72
- package/bin/grok.js +14 -5
- package/bin/utils/migrate/bundle.js +223 -0
- package/bin/utils/migrate/bundle.ts +222 -0
- package/bin/utils/migrate/parts.js +83 -0
- package/bin/utils/migrate/parts.ts +72 -0
- package/bin/utils/migrate/pool.js +17 -0
- package/bin/utils/migrate/pool.ts +13 -0
- package/bin/utils/migrate/pusher.js +981 -0
- package/bin/utils/migrate/pusher.ts +830 -0
- package/bin/utils/migrate/registry.js +349 -0
- package/bin/utils/migrate/registry.ts +255 -0
- package/bin/utils/migrate/rewriter.js +59 -0
- package/bin/utils/migrate/rewriter.ts +59 -0
- package/bin/utils/migrate/walker.js +571 -0
- package/bin/utils/migrate/walker.ts +509 -0
- package/bin/utils/node-dapi.js +692 -141
- package/bin/utils/playwright-runner.js +3 -1
- package/bin/utils/server-client.js +15 -2
- package/bin/utils/server-output.js +65 -4
- package/bin/utils/test-utils.js +1 -1
- package/domain-schema.schema.json +57 -6
- package/package.json +6 -1
- /package/{vitest.config.ts → vitest.config.mts} +0 -0
|
@@ -0,0 +1,830 @@
|
|
|
1
|
+
/// Docs: [Entity export / import](/docs/features/grok-tool/export-import/DESIGN.md)
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import {randomUUID} from 'crypto';
|
|
4
|
+
import {NodeDapi} from '../node-dapi';
|
|
5
|
+
import {TYPES, inNamespace, nqNameOf, rankOf, untransferableReason} from './registry';
|
|
6
|
+
import {Bundle, BundleEntity, bytesPath, hashOf, hashView, listShares, sharePath, stripPrivate, writeIdmap} from './bundle';
|
|
7
|
+
import {pool} from './pool';
|
|
8
|
+
import {nestedIds, rewrite} from './rewriter';
|
|
9
|
+
import {Progress, findEntity, grantsOf, groupCache} from './walker';
|
|
10
|
+
|
|
11
|
+
/** A relation set is written whole; a project holding thousands of them is slow, not stuck. */
|
|
12
|
+
const BULK_WRITE_MS = Number(process.env['GROK_HTTP_BULK_TIMEOUT'] ?? 600000);
|
|
13
|
+
const RELATION_REFUSED_RE = /entity\s+([0-9a-f-]{36})/i;
|
|
14
|
+
const WRITE_CONCURRENCY = 6;
|
|
15
|
+
|
|
16
|
+
export type Action = 'create' | 'update' | 'identical' | 'skip' | 'failed' | 'warn' | 'info' | 'needs-credentials';
|
|
17
|
+
export type ConflictPolicy = 'fail' | 'skip' | 'duplicate' | 'adopt';
|
|
18
|
+
|
|
19
|
+
export interface Row {name: string; entityType: string; action: Action; reason: string; detail?: string}
|
|
20
|
+
|
|
21
|
+
export interface Op {id: string; type: string; json: any; row: Row; creds?: Record<string, any>;
|
|
22
|
+
expectedNamespace?: string; dangling?: string[]}
|
|
23
|
+
|
|
24
|
+
/** What a save could not judge on its own, because placement had not happened yet. */
|
|
25
|
+
interface Deferred {renamed: {op: Op; row: Row}[]; misplaced: Op[]}
|
|
26
|
+
|
|
27
|
+
export interface PushOptions {
|
|
28
|
+
dryRun?: boolean;
|
|
29
|
+
onConflict?: ConflictPolicy;
|
|
30
|
+
concurrency?: number;
|
|
31
|
+
creds?: Record<string, any>;
|
|
32
|
+
progress?: Progress;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface PushResult {items: Row[]; counts: Record<string, number>; status: string; remoteUrl: string}
|
|
36
|
+
|
|
37
|
+
export async function plan(dapi: NodeDapi, bundle: Bundle,
|
|
38
|
+
opts: {onConflict: ConflictPolicy; creds?: Record<string, any>; idmap?: Record<string, string>},
|
|
39
|
+
): Promise<{rows: Row[]; ops: Op[]; planned: Map<string, Row>; effective: Map<string, BundleEntity>; missing: Set<string>}> {
|
|
40
|
+
const rows: Row[] = [];
|
|
41
|
+
const ops: Op[] = [];
|
|
42
|
+
const planned = new Map<string, Row>();
|
|
43
|
+
const effective = new Map<string, BundleEntity>();
|
|
44
|
+
const conflicts: string[] = [];
|
|
45
|
+
const idmap = opts.idmap ?? {};
|
|
46
|
+
const orphans = new Set<string>();
|
|
47
|
+
const missing = new Set<string>();
|
|
48
|
+
const referrers = new Map<string, string[]>();
|
|
49
|
+
const onTarget = new Set<string>();
|
|
50
|
+
const pusherNamespace = await currentNamespace(dapi);
|
|
51
|
+
|
|
52
|
+
// A personal root project exists on the target already, under the target's own id, because it
|
|
53
|
+
// was created with the user. Pointing at that one before anything is rewritten is what keeps
|
|
54
|
+
// the content under it in its owner's namespace instead of the pusher's.
|
|
55
|
+
const owners = bundle.manifest.order
|
|
56
|
+
.map((entry) => bundle.entities.get(entry.id)!.json._personalOf).filter(Boolean);
|
|
57
|
+
if (owners.length) {
|
|
58
|
+
const personal = await personalProjects(dapi);
|
|
59
|
+
for (const entry of bundle.manifest.order) {
|
|
60
|
+
const owner = bundle.entities.get(entry.id)!.json._personalOf;
|
|
61
|
+
if (owner && personal.has(owner))
|
|
62
|
+
idmap[entry.id] = personal.get(owner)!;
|
|
63
|
+
}
|
|
64
|
+
const absent = [...new Set(owners)].filter((login) => !personal.has(login)).sort();
|
|
65
|
+
if (absent.length)
|
|
66
|
+
rows.push({name: absent.join(', '), entityType: 'User', action: 'warn', reason: 'user_missing',
|
|
67
|
+
detail: `create ${absent.length === 1 ? 'this user' : 'these users'} on the target first, ` +
|
|
68
|
+
'or their content lands under the pushing account'});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// A package entity gets a fresh id on every instance, so it only resolves by name.
|
|
72
|
+
await pool(bundle.manifest.externals ?? [], WRITE_CONCURRENCY, async (external) => {
|
|
73
|
+
// A mapping the bundle carries was learned from whichever target it was last pushed at, so it
|
|
74
|
+
// is only reusable while this target still answers to it.
|
|
75
|
+
const mapped = idmap[external.id];
|
|
76
|
+
const spec = TYPES[external.type];
|
|
77
|
+
if (mapped && spec && await dapi.internal(spec.route).find(mapped).catch(() => null)) return;
|
|
78
|
+
const twin = await resolveByNqName(dapi, external.type, external.nqName);
|
|
79
|
+
if (!twin) {
|
|
80
|
+
delete idmap[external.id];
|
|
81
|
+
rows.push({name: external.nqName, entityType: external.type, action: 'warn', reason: 'external_missing',
|
|
82
|
+
detail: 'stayed on the source and the target has nothing by that name — install or publish what owns it'});
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (twin.id !== external.id)
|
|
86
|
+
idmap[external.id] = twin.id;
|
|
87
|
+
else
|
|
88
|
+
delete idmap[external.id];
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// Every twin is resolved before the first payload is rewritten: an adoption discovered
|
|
92
|
+
// halfway through would leave the references of everything rewritten before it stale.
|
|
93
|
+
const resolved = new Map<string, {target: any; twin: any}>();
|
|
94
|
+
for (const entry of bundle.manifest.order) {
|
|
95
|
+
const {type, json: source} = bundle.entities.get(entry.id)!;
|
|
96
|
+
const target = await dapi.internal(TYPES[type].route).find(idmap[entry.id] ?? source.id);
|
|
97
|
+
const twin = target ? null : await findByNqName(dapi, bundle, type, source, pusherNamespace);
|
|
98
|
+
resolved.set(entry.id, {target, twin});
|
|
99
|
+
if (!twin || opts.onConflict !== 'adopt') continue;
|
|
100
|
+
// The twin's own nested rows must not be hijacked by the bundle's row ids; the fresh
|
|
101
|
+
// ones go into the idmap so the next push produces the same payload.
|
|
102
|
+
idmap[entry.id] = twin.id;
|
|
103
|
+
for (const nested of nestedIds(source))
|
|
104
|
+
idmap[nested] = randomUUID();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
for (const entry of bundle.manifest.order) {
|
|
108
|
+
const {type, json: source} = bundle.entities.get(entry.id)!;
|
|
109
|
+
const mine = new Set<string>();
|
|
110
|
+
const json = rewrite(source, idmap, mine);
|
|
111
|
+
for (const orphan of mine) {
|
|
112
|
+
orphans.add(orphan);
|
|
113
|
+
referrers.set(orphan, [...(referrers.get(orphan) ?? []), nqNameOf(json)]);
|
|
114
|
+
}
|
|
115
|
+
const row: Row = {name: nqNameOf(json), entityType: type, action: 'create', reason: ''};
|
|
116
|
+
rows.push(row);
|
|
117
|
+
planned.set(entry.id, row);
|
|
118
|
+
|
|
119
|
+
const {target, twin} = resolved.get(entry.id)!;
|
|
120
|
+
if (json._personalOf) {
|
|
121
|
+
row.action = 'skip';
|
|
122
|
+
row.reason = 'personal_project';
|
|
123
|
+
row.detail = `the target keeps ${json._personalOf}'s own`;
|
|
124
|
+
onTarget.add(json.id);
|
|
125
|
+
effective.set(entry.id, {type, json});
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
// A hand-edited bundle must not be able to overwrite what the target owns itself.
|
|
129
|
+
const refuse = untransferableReason(type, json) ?? bytesMissing(bundle, type, json);
|
|
130
|
+
if (refuse) {
|
|
131
|
+
row.action = 'skip';
|
|
132
|
+
row.reason = refuse.reason;
|
|
133
|
+
row.detail = refuse.detail;
|
|
134
|
+
if (target) onTarget.add(json.id);
|
|
135
|
+
effective.set(entry.id, {type, json});
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (target) {
|
|
139
|
+
onTarget.add(json.id);
|
|
140
|
+
compare(rows, row, type, target, json, '');
|
|
141
|
+
} else if (twin) {
|
|
142
|
+
row.reason = `name taken by ${twin.id}`;
|
|
143
|
+
if (opts.onConflict === 'fail') {
|
|
144
|
+
conflicts.push(`${type} ${row.name} → ${twin.id}`);
|
|
145
|
+
row.action = 'failed';
|
|
146
|
+
} else if (opts.onConflict === 'skip')
|
|
147
|
+
row.action = 'skip';
|
|
148
|
+
else if (opts.onConflict === 'adopt')
|
|
149
|
+
compare(rows, row, type, twin, json, `adopted ${twin.id}`);
|
|
150
|
+
}
|
|
151
|
+
effective.set(entry.id, {type, json});
|
|
152
|
+
|
|
153
|
+
const creds = type === 'DataConnection' ? credentialsFor(opts.creds, json, pusherNamespace) : undefined;
|
|
154
|
+
// A new secret is invisible in the payload, so a covered connection is written anyway.
|
|
155
|
+
if (creds && row.action === 'identical') {
|
|
156
|
+
row.action = 'update';
|
|
157
|
+
row.reason = 'credentials';
|
|
158
|
+
}
|
|
159
|
+
if (!['create', 'update'].includes(row.action)) continue;
|
|
160
|
+
if (type === 'DataConnection' && !planCredentials(json, row, rows, creds)) continue;
|
|
161
|
+
ops.push({id: entry.id, type, json, row, creds, expectedNamespace: expectedNamespace(bundle, json),
|
|
162
|
+
dangling: [...mine]});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (conflicts.length)
|
|
166
|
+
throw new Error(`Name conflicts on the target (use --on-conflict skip|duplicate|adopt):\n ${conflicts.join('\n ')}`);
|
|
167
|
+
|
|
168
|
+
failDependants(effective, planned, onTarget);
|
|
169
|
+
|
|
170
|
+
// Nested row ids belong to the bundle as much as entity ids do — only a reference to
|
|
171
|
+
// something that was never pulled is an orphan.
|
|
172
|
+
const known = new Set<string>(Object.keys(idmap));
|
|
173
|
+
for (const [id, e] of bundle.entities) {
|
|
174
|
+
known.add(id);
|
|
175
|
+
for (const nested of nestedIds(e.json))
|
|
176
|
+
known.add(nested);
|
|
177
|
+
}
|
|
178
|
+
// An id the bundle points at but does not carry is only harmless if the target happens to hold
|
|
179
|
+
// it too (a platform row keeps its id everywhere). One that exists on neither side is dangling
|
|
180
|
+
// on the source, and whatever needs it will be refused there with the server's own error.
|
|
181
|
+
for (const orphan of orphans) {
|
|
182
|
+
if (known.has(orphan)) continue;
|
|
183
|
+
const by = [...new Set(referrers.get(orphan) ?? [])].join(', ');
|
|
184
|
+
if (await findEntity(dapi, orphan)) {
|
|
185
|
+
rows.push({name: orphan, entityType: 'Entity', action: 'info', reason: 'orphan_ref',
|
|
186
|
+
detail: `not in the bundle, but the target has this id — referenced by ${by}`});
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
missing.add(orphan);
|
|
190
|
+
rows.push(bundle.manifest.dangling?.includes(orphan)
|
|
191
|
+
? {name: orphan, entityType: 'Entity', action: 'info', reason: 'dead_on_source',
|
|
192
|
+
detail: `nothing on the source answers to it either — ${by} cannot be migrated intact`}
|
|
193
|
+
: {name: orphan, entityType: 'Entity', action: 'warn', reason: 'dependency_missing',
|
|
194
|
+
detail: `not in the bundle and not on the target — install the package that owns it, ` +
|
|
195
|
+
`or pull it; the save of ${by} may be refused`});
|
|
196
|
+
}
|
|
197
|
+
return {rows, ops: ops.filter((o) => ['create', 'update'].includes(o.row.action)), planned, effective, missing};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* A file with no share is stored as a blob under its own id (`files_service.dart`:
|
|
202
|
+
* `addToUserProject: f.connection == null`), so without its bytes there is nothing to create.
|
|
203
|
+
*/
|
|
204
|
+
function bytesMissing(bundle: Bundle, type: string, json: any): {reason: string; detail: string} | null {
|
|
205
|
+
const bytes = TYPES[type].bytes;
|
|
206
|
+
if (type !== 'FileInfo' || json?.connection?.id || !bytes) return null;
|
|
207
|
+
return fs.existsSync(bytesPath(bundle.dir, bytes.kind, json.id)) ? null
|
|
208
|
+
: {reason: 'file_bytes_missing', detail: 'a file with no share is its bytes — pull with --include-files'};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Relations are outside the hash: what matters is that the target links everything the bundle does. */
|
|
212
|
+
function relationsCovered(target: any, json: any): boolean {
|
|
213
|
+
const have = new Set((target.relations ?? []).map((r: any) => r?.entity?.id));
|
|
214
|
+
return (json.relations ?? []).every((r: any) => have.has(r.entity?.id));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function compare(rows: Row[], row: Row, type: string, target: any, json: any, reason: string): void {
|
|
218
|
+
const covered = relationsCovered(target, json);
|
|
219
|
+
const sameHash = hashOf(type, target) === hashOf(type, json);
|
|
220
|
+
const same = covered && sameHash;
|
|
221
|
+
row.action = same ? 'identical' : 'update';
|
|
222
|
+
row.reason = same ? reason : reason || (sameHash ? 'relations missing on target' : 'hash differs');
|
|
223
|
+
row.detail = same ? undefined : [...changedKeys(type, target, json), ...(covered ? [] : ['relations'])].join(', ');
|
|
224
|
+
|
|
225
|
+
// Coverage semantics: relations are only ever added, so a link dropped from the bundle stays.
|
|
226
|
+
const wanted = new Set((json.relations ?? []).map((r: any) => r?.entity?.id));
|
|
227
|
+
const kept = (target.relations ?? [])
|
|
228
|
+
.filter((r: any) => r?.entity?.id && !wanted.has(r.entity.id) && r.entity.parameters?.isProject !== true)
|
|
229
|
+
.map((r: any) => r.entity.id);
|
|
230
|
+
if (kept.length)
|
|
231
|
+
rows.push({name: row.name, entityType: type, action: 'info', reason: 'relation_not_removed', detail: kept.join(', ')});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** What has to be on the target before this entity can land: bundle-internal references. */
|
|
235
|
+
function referencedIds(json: any, groupIds: Map<string, string>, type: string): string[] {
|
|
236
|
+
const ids: string[] = (TYPES[type].deps?.(json) ?? []).map((r) => r.id).filter(Boolean) as string[];
|
|
237
|
+
for (const rel of json.relations ?? [])
|
|
238
|
+
if (rel?.entity?.id)
|
|
239
|
+
ids.push(rel.entity.id);
|
|
240
|
+
for (const grant of json._grants ?? [])
|
|
241
|
+
if (groupIds.has(grant.group))
|
|
242
|
+
ids.push(groupIds.get(grant.group)!);
|
|
243
|
+
return ids;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* A skipped entity is not on the target, so anything pointing at it would be rejected
|
|
248
|
+
* there — report that here instead of letting the server fail the save.
|
|
249
|
+
*/
|
|
250
|
+
function failDependants(effective: Map<string, BundleEntity>, planned: Map<string, Row>, onTarget: Set<string>): void {
|
|
251
|
+
const blocked = new Map<string, Row>();
|
|
252
|
+
const groupIds = new Map<string, string>();
|
|
253
|
+
for (const [id, {type, json}] of effective) {
|
|
254
|
+
if (type === 'UserGroup')
|
|
255
|
+
groupIds.set(json.friendlyName ?? json.name, json.id);
|
|
256
|
+
// A connection skipped because every parameter was masked is still on the target. A platform
|
|
257
|
+
// group is too, under whatever id that instance gave it — grants name it, so it never blocks.
|
|
258
|
+
if (planned.get(id)?.action === 'skip' && !onTarget.has(json.id) && planned.get(id)!.reason !== 'platform_group')
|
|
259
|
+
blocked.set(json.id, planned.get(id)!);
|
|
260
|
+
}
|
|
261
|
+
for (let changed = true; changed;) {
|
|
262
|
+
changed = false;
|
|
263
|
+
for (const [id, {type, json}] of effective) {
|
|
264
|
+
const row = planned.get(id)!;
|
|
265
|
+
if (!['create', 'update'].includes(row.action)) continue;
|
|
266
|
+
const on = referencedIds(json, groupIds, type).find((ref) => blocked.has(ref));
|
|
267
|
+
if (!on) continue;
|
|
268
|
+
row.action = 'failed';
|
|
269
|
+
row.reason = 'dependency_skipped';
|
|
270
|
+
row.detail = blocked.get(on)!.name;
|
|
271
|
+
blocked.set(json.id, row);
|
|
272
|
+
changed = true;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Where the entity should end up, or undefined where the target legitimately decides: a personal
|
|
279
|
+
* namespace becomes the pusher's, and a root space has none to keep.
|
|
280
|
+
*/
|
|
281
|
+
function expectedNamespace(bundle: Bundle, json: any): string | undefined {
|
|
282
|
+
const source: string = json.namespace ?? '';
|
|
283
|
+
return !source || source === bundle.manifest.source.userNamespace ? undefined : source;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* The `--creds` file is authored for the target, so its keys are the connection nqName as
|
|
288
|
+
* the bundle spells it, or as the target does once a personal namespace is remapped (R11).
|
|
289
|
+
*/
|
|
290
|
+
function credentialsFor(creds: Record<string, any> | undefined, json: any, pusherNamespace: string): Record<string, any> | undefined {
|
|
291
|
+
return creds?.[nqNameOf(json)] ?? creds?.[`${pusherNamespace}${json.name ?? ''}`];
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Secrets never travel in a bundle: `_credentials` lists the parameters the source masked.
|
|
296
|
+
* When they were the only parameters there is nothing left worth pushing.
|
|
297
|
+
*/
|
|
298
|
+
function planCredentials(json: any, row: Row, rows: Row[], creds?: Record<string, any>): boolean {
|
|
299
|
+
const masked: string[] = json._credentials ?? [];
|
|
300
|
+
if (masked.length && !creds && !Object.keys(json.parameters ?? {}).length) {
|
|
301
|
+
row.action = 'skip';
|
|
302
|
+
row.reason = 'every parameter was masked on the source';
|
|
303
|
+
return false;
|
|
304
|
+
}
|
|
305
|
+
if (!creds)
|
|
306
|
+
rows.push({name: row.name, entityType: 'DataConnection', action: 'needs-credentials', reason: '',
|
|
307
|
+
detail: masked.length ? masked.join(', ') : 'credentials are not migrated'});
|
|
308
|
+
return true;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export async function push(dapi: NodeDapi, bundle: Bundle, opts: PushOptions, log: (rows: Row[]) => void): Promise<PushResult> {
|
|
312
|
+
const onConflict = opts.onConflict ?? 'fail';
|
|
313
|
+
const extra: Row[] = [];
|
|
314
|
+
|
|
315
|
+
const target = await dapi.serverInfo();
|
|
316
|
+
const source = bundle.manifest.source;
|
|
317
|
+
if (minor(source.version) !== minor(target.version))
|
|
318
|
+
extra.push({
|
|
319
|
+
name: dapi.client.baseUrl, entityType: 'Server', action: 'warn', reason: 'version_mismatch',
|
|
320
|
+
detail: `${source.version} → ${target.version}; unknown fields are ignored by the server, failures below may be version-related`,
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
const installed = new Set((await dapi.packages.listFull()).map((p: any) => String(p.name).toLowerCase()));
|
|
324
|
+
for (const name of bundle.manifest.packages)
|
|
325
|
+
if (!installed.has(name.toLowerCase()))
|
|
326
|
+
extra.push({name, entityType: 'Package', action: 'warn', reason: 'package_not_installed'});
|
|
327
|
+
|
|
328
|
+
const idmap = {...bundle.idmap};
|
|
329
|
+
const {rows, ops, planned, effective, missing} = await plan(dapi, bundle, {onConflict, creds: opts.creds, idmap});
|
|
330
|
+
const items = [...extra, ...rows];
|
|
331
|
+
log(items);
|
|
332
|
+
if (opts.dryRun)
|
|
333
|
+
return summarize(items, dapi, 'dry-run');
|
|
334
|
+
|
|
335
|
+
const deferred: Deferred = {renamed: [], misplaced: []};
|
|
336
|
+
const progress = opts.progress ?? (() => {});
|
|
337
|
+
let saved = 0;
|
|
338
|
+
for (const rank of [...new Set(ops.map((o) => rankOf(o.type)))].sort((a, b) => a - b))
|
|
339
|
+
await pool(ops.filter((o) => rankOf(o.type) === rank), opts.concurrency ?? WRITE_CONCURRENCY,
|
|
340
|
+
async (op) => {
|
|
341
|
+
await saveOne(dapi, bundle, op, items, idmap, deferred, missing);
|
|
342
|
+
progress('saving', ++saved, ops.length);
|
|
343
|
+
});
|
|
344
|
+
// A FileInfo save can answer with an existing row's id: recording it keeps the next push
|
|
345
|
+
// stable, and the passes below have to point at the id the entity actually landed under.
|
|
346
|
+
// Comparing sizes would miss a run that dropped one stale mapping and learned another.
|
|
347
|
+
if (JSON.stringify(idmap) !== JSON.stringify(bundle.idmap)) {
|
|
348
|
+
writeIdmap(bundle.dir, idmap);
|
|
349
|
+
for (const [id, e] of effective)
|
|
350
|
+
effective.set(id, {type: e.type, json: rewrite(e.json, idmap)});
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
await pushShares(dapi, bundle, items, onConflict);
|
|
354
|
+
await pushRelations(dapi, effective, planned, items, progress);
|
|
355
|
+
progress('restoring names');
|
|
356
|
+
const renamed = await restoreNames(dapi, deferred.renamed, idmap);
|
|
357
|
+
// A save re-homes the entity into the pusher's own root (`repository_query.dart`:
|
|
358
|
+
// `addToUserProject`), undoing the placement the namespace is derived from.
|
|
359
|
+
if (renamed)
|
|
360
|
+
await pushRelations(dapi, effective, planned, items, progress);
|
|
361
|
+
progress('checking placement');
|
|
362
|
+
await reportPlacement(dapi, deferred.misplaced, items, idmap);
|
|
363
|
+
progress('tags and memberships');
|
|
364
|
+
await pushTags(dapi, effective, planned, items);
|
|
365
|
+
await pushMemberships(dapi, effective, planned, items);
|
|
366
|
+
progress('sharing');
|
|
367
|
+
const {visible, projects} = await pushGrants(dapi, effective, planned, items);
|
|
368
|
+
const written = items.filter((r) => r.action === 'create' || r.action === 'update').length;
|
|
369
|
+
if (!visible && projects && written)
|
|
370
|
+
items.push({name: '', entityType: 'Project', action: 'warn', reason: 'not_visible',
|
|
371
|
+
detail: 'Pushed but not visible to any user on the remote — share it with a group first'});
|
|
372
|
+
|
|
373
|
+
return summarize(items, dapi, items.some((r) => r.action === 'failed') ? 'failed' : 'ok');
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async function saveOne(dapi: NodeDapi, bundle: Bundle, op: Op, rows: Row[], idmap: Record<string, string>,
|
|
377
|
+
deferred: Deferred, missing: Set<string>): Promise<void> {
|
|
378
|
+
const spec = TYPES[op.type];
|
|
379
|
+
const payload = stripPrivate(JSON.parse(JSON.stringify(op.json)));
|
|
380
|
+
const targetId = payload.id ?? op.id;
|
|
381
|
+
if (payload.metaParams && typeof payload.metaParams === 'object')
|
|
382
|
+
payload.metaParams.sync_id = op.id;
|
|
383
|
+
|
|
384
|
+
// The server encrypts and masks password-class parameters itself, so target-side
|
|
385
|
+
// secrets go in as plain parameters and never touch the bundle.
|
|
386
|
+
if (op.creds)
|
|
387
|
+
payload.parameters = {...payload.parameters, ...op.creds};
|
|
388
|
+
|
|
389
|
+
try {
|
|
390
|
+
if (spec.bytes && !spec.bytes.afterSave)
|
|
391
|
+
await pushBytes(dapi, bundle, op.type, op.id, targetId);
|
|
392
|
+
|
|
393
|
+
const saved = await dapi.internal(spec.saveRoute ?? spec.route).save(payload);
|
|
394
|
+
const peerId = saved?.id ?? targetId;
|
|
395
|
+
if (peerId !== targetId)
|
|
396
|
+
idmap[op.id] = peerId;
|
|
397
|
+
if (spec.bytes && spec.bytes.afterSave)
|
|
398
|
+
await pushBytes(dapi, bundle, op.type, op.id, peerId);
|
|
399
|
+
|
|
400
|
+
const verified = await dapi.internal(spec.route).find(peerId);
|
|
401
|
+
if (!verified) {
|
|
402
|
+
op.row.action = 'failed';
|
|
403
|
+
op.row.reason = 'Save reported success but not on target';
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
// Names and namespaces are settled by placement, so a clash here is only a candidate.
|
|
407
|
+
if (verified.name !== op.json.name) {
|
|
408
|
+
const row: Row = {name: op.row.name, entityType: op.type, action: 'warn', reason: 'renamed',
|
|
409
|
+
detail: `${op.json.name} → ${verified.name}`};
|
|
410
|
+
rows.push(row);
|
|
411
|
+
deferred.renamed.push({op, row});
|
|
412
|
+
}
|
|
413
|
+
if (op.expectedNamespace !== undefined && (verified.namespace ?? '') !== op.expectedNamespace)
|
|
414
|
+
deferred.misplaced.push(op);
|
|
415
|
+
if (op.type === 'PredictiveModelInfo')
|
|
416
|
+
rows.push({name: op.row.name, entityType: op.type, action: 'info', reason: 'model_blob_skipped',
|
|
417
|
+
detail: 'the trained model itself stays on the source — retrain or copy it separately'});
|
|
418
|
+
} catch (err: any) {
|
|
419
|
+
// The server reports its own refusal, not what caused it; a reference that exists on
|
|
420
|
+
// neither instance is the cause often enough to be worth naming here.
|
|
421
|
+
const cause = (op.dangling ?? []).filter((id) => missing.has(id));
|
|
422
|
+
const dead = cause.filter((id) => bundle.manifest.dangling?.includes(id));
|
|
423
|
+
const refusal = err?.message ?? String(err);
|
|
424
|
+
// The server names its own refusal, not what caused it, so a dead reference is inferred — but
|
|
425
|
+
// only from a refusal. A request that timed out or lost its connection says nothing about the
|
|
426
|
+
// entity, and calling that "nothing to migrate" would hide a transport problem as clean data.
|
|
427
|
+
const transport = /no answer in|deadlock|40P01|ECONN|socket|fetch failed/i.test(refusal);
|
|
428
|
+
const named = transport ? [] : dead;
|
|
429
|
+
op.row.action = named.length ? 'skip' : 'failed';
|
|
430
|
+
op.row.reason = named.length ? 'dead_on_source' : refusal;
|
|
431
|
+
if (named.length)
|
|
432
|
+
op.row.detail = `references ${named.join(', ')}, which the source no longer has either — ` +
|
|
433
|
+
`nothing to migrate, delete it there or ignore (${refusal})`;
|
|
434
|
+
else if (cause.length)
|
|
435
|
+
op.row.detail = `references ${cause.join(', ')}, which the target does not have — ` +
|
|
436
|
+
'a package connection is the usual cause, install the package first';
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** Bytes live under the bundle id and land under the id the target ended up using. */
|
|
441
|
+
async function pushBytes(dapi: NodeDapi, bundle: Bundle, type: string, bundleId: string, targetId: string): Promise<void> {
|
|
442
|
+
const bytes = TYPES[type].bytes!;
|
|
443
|
+
const file = bytesPath(bundle.dir, bytes.kind, bundleId);
|
|
444
|
+
if (fs.existsSync(file))
|
|
445
|
+
await dapi.client.putBytes(bytes.put(targetId), fs.readFileSync(file));
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Relations need every member to exist, so they are re-attached once everything is saved.
|
|
450
|
+
* `saveRelations=true` replaces the project's whole relation set (`projects_repository.dart`
|
|
451
|
+
* `_deleteRelations`), so the payload starts from what the target already links and the
|
|
452
|
+
* bundle only ever adds to it.
|
|
453
|
+
*/
|
|
454
|
+
async function pushRelations(dapi: NodeDapi, effective: Map<string, BundleEntity>, planned: Map<string, Row>, rows: Row[],
|
|
455
|
+
progress: Progress = () => {}): Promise<void> {
|
|
456
|
+
const projects = dapi.internal('/projects');
|
|
457
|
+
const relations = dapi.internal('/projects/relations');
|
|
458
|
+
// Anything this push did not write has to prove it exists before being offered as a relation.
|
|
459
|
+
const landed = new Set<string>();
|
|
460
|
+
for (const [id, {json}] of effective)
|
|
461
|
+
if (['create', 'update', 'identical'].includes(planned.get(id)?.action ?? '')) landed.add(json.id);
|
|
462
|
+
// Containment is a tree: each entity goes to the deepest project claiming it, so a space and a
|
|
463
|
+
// dashboard listing the same table do not erase each other.
|
|
464
|
+
const claimants = new Map<string, string[]>();
|
|
465
|
+
for (const [, {type, json}] of effective) {
|
|
466
|
+
if (type !== 'Project') continue;
|
|
467
|
+
for (const rel of json.relations ?? [])
|
|
468
|
+
if (rel?.entity?.id) claimants.set(rel.entity.id, [...(claimants.get(rel.entity.id) ?? []), json.id]);
|
|
469
|
+
}
|
|
470
|
+
const depthOf = (id: string): number => {
|
|
471
|
+
let depth = 0;
|
|
472
|
+
for (let at = (claimants.get(id) ?? [])[0]; at && depth < 64; at = (claimants.get(at) ?? [])[0]) depth++;
|
|
473
|
+
return depth;
|
|
474
|
+
};
|
|
475
|
+
const ownerOf = (entityId: string): string | undefined => {
|
|
476
|
+
const holders = claimants.get(entityId) ?? [];
|
|
477
|
+
return holders.length < 2 ? holders[0]
|
|
478
|
+
: holders.reduce((deepest, id) => depthOf(id) > depthOf(deepest) ? id : deepest, holders[0]);
|
|
479
|
+
};
|
|
480
|
+
const order = [...effective].filter(([, e]) => e.type === 'Project' && e.json.relations?.length)
|
|
481
|
+
.sort((a, b) => depthOf(a[1].json.id) - depthOf(b[1].json.id));
|
|
482
|
+
|
|
483
|
+
const tally = {wanted: 0, notWritable: 0, absent: 0, nothingToAdd: 0, written: 0, reasserted: 0};
|
|
484
|
+
const written: typeof order = [];
|
|
485
|
+
for (const [id, {type, json}] of order) {
|
|
486
|
+
progress('placing', ++tally.wanted, order.length);
|
|
487
|
+
// Every project the bundle places is re-asserted, not only the ones whose own row was
|
|
488
|
+
// written: one project's write takes contained entities from every other project holding them.
|
|
489
|
+
const row = planned.get(id);
|
|
490
|
+
if (['skip', 'failed'].includes(row?.action ?? '') && row?.reason !== 'personal_project') {
|
|
491
|
+
tally.notWritable++;
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
// Reading the relation rows on their own, rather than the whole project, is what keeps this
|
|
495
|
+
// stage usable: nearly every project already holds what the bundle wants, and fetching each
|
|
496
|
+
// one in full to discover that costs hours on a stand-sized push.
|
|
497
|
+
// `GET /projects/relations` fails on a project that links domain-table rows, the same way it
|
|
498
|
+
// does on the pull side — and reading "holds nothing" would strip every relation the target
|
|
499
|
+
// has, because the write replaces the whole set. Fall back to the project's own copy.
|
|
500
|
+
const held = await relations.listAll({projectId: json.id, include: 'entity'}).catch(() => null)
|
|
501
|
+
?? (await projects.find(json.id).catch(() => null))?.relations;
|
|
502
|
+
if (!held) {
|
|
503
|
+
tally.absent++;
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
// `projects_repository.dart`: a non-link relation deletes the entity's other non-link rows.
|
|
507
|
+
const contains = (entityId: string): boolean => ownerOf(entityId) === json.id;
|
|
508
|
+
const claimed = new Set<string>((json.relations ?? []).map((r: any) => r?.entity?.id).filter(Boolean));
|
|
509
|
+
// The server derives `is_link` — it keeps one container per entity and marks every other holder
|
|
510
|
+
// a link — so only a claim is worth writing for. Writing the release direction is ignored and
|
|
511
|
+
// recomputed, which would rewrite the project on every push, and a relation write costs tens of
|
|
512
|
+
// seconds on a stand-sized target.
|
|
513
|
+
let corrected = 0;
|
|
514
|
+
const wanted: any[] = [];
|
|
515
|
+
for (const r of held) {
|
|
516
|
+
if (!r?.entity?.id) continue;
|
|
517
|
+
const isLink = claimed.has(r.entity.id) ? !contains(r.entity.id) : (r.isLink ?? false);
|
|
518
|
+
if ((r.isLink ?? false) && !isLink) corrected++;
|
|
519
|
+
wanted.push({id: r.id, entity: {'#type': 'EntityRecord', id: r.entity.id}, isLink});
|
|
520
|
+
}
|
|
521
|
+
const linked = new Set<string>(wanted.map((r) => r.entity.id));
|
|
522
|
+
let added = 0;
|
|
523
|
+
for (const rel of json.relations) {
|
|
524
|
+
if (linked.has(rel.entity.id)) continue;
|
|
525
|
+
// Placement is exclusive, so claiming an entity the walk refused takes it away from
|
|
526
|
+
// whatever holds it on the target: a space's own Files connection, or `System:DemoFiles`,
|
|
527
|
+
// which moved into a migrated space and broke every reference to it by name.
|
|
528
|
+
if (!landed.has(rel.entity.id)) {
|
|
529
|
+
const existing = await findEntity(dapi, rel.entity.id);
|
|
530
|
+
if (!existing || untransferableReason(existing['#type'], existing)) continue;
|
|
531
|
+
}
|
|
532
|
+
wanted.push(contains(rel.entity.id) ? rel : {...rel, isLink: true});
|
|
533
|
+
linked.add(rel.entity.id);
|
|
534
|
+
added++;
|
|
535
|
+
}
|
|
536
|
+
if (!added && !corrected) {
|
|
537
|
+
tally.nothingToAdd++;
|
|
538
|
+
continue;
|
|
539
|
+
}
|
|
540
|
+
// Only now is the project itself needed: the write posts it back whole.
|
|
541
|
+
const target = await projects.find(json.id).catch(() => null);
|
|
542
|
+
if (!target) {
|
|
543
|
+
tally.absent++;
|
|
544
|
+
continue;
|
|
545
|
+
}
|
|
546
|
+
tally.written++;
|
|
547
|
+
// Persistently non-zero means the server is not keeping the flag as sent.
|
|
548
|
+
if (!added) tally.reasserted++;
|
|
549
|
+
delete target.storage;
|
|
550
|
+
target.relations = wanted;
|
|
551
|
+
|
|
552
|
+
const refused: string[] = [];
|
|
553
|
+
for (let attempt = 0; ;) {
|
|
554
|
+
try {
|
|
555
|
+
await dapi.client.post('/projects?saveRelations=true', stripPrivate(JSON.parse(JSON.stringify(target))), BULK_WRITE_MS);
|
|
556
|
+
break;
|
|
557
|
+
} catch (err: any) {
|
|
558
|
+
const text = String(err?.message ?? err);
|
|
559
|
+
// The write is all-or-nothing, so a single entity the target refuses to link would cost
|
|
560
|
+
// the whole space its placement. Drop the one it named and write the rest.
|
|
561
|
+
// `projects_repository.dart` refuses a relation for want of a permission in several
|
|
562
|
+
// wordings; dropping the entity then reports "the target would not link it" for what is
|
|
563
|
+
// really the pusher's own access, and quietly migrates less.
|
|
564
|
+
const denied = /privileges|you do not have/i.test(text);
|
|
565
|
+
const bad = denied ? undefined : RELATION_REFUSED_RE.exec(text)?.[1];
|
|
566
|
+
if (bad && refused.length < 3 && target.relations.some((r: any) => r.entity?.id === bad)) {
|
|
567
|
+
target.relations = target.relations.filter((r: any) => r.entity?.id !== bad);
|
|
568
|
+
refused.push(bad);
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
571
|
+
if (attempt++ >= 2 || !(text.includes('40P01') || text.toLowerCase().includes('deadlock'))) {
|
|
572
|
+
rows.push({name: nqNameOf(json), entityType: 'Project', action: 'failed', reason: 'relations', detail: text});
|
|
573
|
+
break;
|
|
574
|
+
}
|
|
575
|
+
await new Promise((r) => setTimeout(r, 250 * attempt));
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
if (refused.length)
|
|
579
|
+
rows.push({name: nqNameOf(json), entityType: 'Project', action: 'warn', reason: 'relations_refused',
|
|
580
|
+
detail: `${refused.length} left out because the target would not link them: ${refused.slice(0, 3).join(', ')}`});
|
|
581
|
+
written.push([id, {type, json}]);
|
|
582
|
+
}
|
|
583
|
+
const short: string[] = [];
|
|
584
|
+
for (const [, entity] of written) {
|
|
585
|
+
const back = await projects.find(entity.json.id).catch(() => null);
|
|
586
|
+
const kept = new Set<string>((back?.relations ?? []).map((r: any) => r?.entity?.id));
|
|
587
|
+
if ((entity.json.relations ?? []).some((r: any) => r?.entity?.id && !kept.has(r.entity.id)))
|
|
588
|
+
short.push(nqNameOf(entity.json));
|
|
589
|
+
}
|
|
590
|
+
tally.written -= short.length;
|
|
591
|
+
if (short.length)
|
|
592
|
+
rows.push({name: short.slice(0, 3).join(', '), entityType: 'Project',
|
|
593
|
+
action: 'warn', reason: 'relations_not_kept',
|
|
594
|
+
detail: `${short.length} project(s) lost relations the target dropped after writing them`});
|
|
595
|
+
// Placement is what makes a dashboard openable, and it is invisible in a per-entity report.
|
|
596
|
+
if (tally.wanted)
|
|
597
|
+
rows.push({name: '', entityType: 'Project', action: 'info', reason: 'relations_written',
|
|
598
|
+
detail: `${tally.written} of ${tally.wanted} placed` +
|
|
599
|
+
(tally.reasserted ? ` (${tally.reasserted} only re-stating ownership)` : '') +
|
|
600
|
+
(tally.notWritable ? `; ${tally.notWritable} not written` : '') +
|
|
601
|
+
(tally.absent ? `; ${tally.absent} missing on target` : '') +
|
|
602
|
+
(tally.nothingToAdd ? `; ${tally.nothingToAdd} already linked or unresolvable` : '')});
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Files a datasync table reads, written back into the share of the same name. A personal
|
|
607
|
+
* `Home` share resolves to the target's own connection for that user, so each owner's files
|
|
608
|
+
* land in their own share rather than the pusher's.
|
|
609
|
+
*/
|
|
610
|
+
async function pushShares(dapi: NodeDapi, bundle: Bundle, rows: Row[], onConflict: ConflictPolicy): Promise<void> {
|
|
611
|
+
const conflicts: string[] = [];
|
|
612
|
+
for (const remote of listShares(bundle.dir)) {
|
|
613
|
+
try {
|
|
614
|
+
// The only write that replaces a file the target already has, so it answers to the same
|
|
615
|
+
// policy as everything else rather than overwriting whatever is there.
|
|
616
|
+
const already = await dapi.files.readBytes(remote).then(() => true).catch(() => false);
|
|
617
|
+
if (already && onConflict === 'skip') {
|
|
618
|
+
rows.push({name: remote, entityType: 'File', action: 'skip', reason: 'share_file_exists'});
|
|
619
|
+
continue;
|
|
620
|
+
}
|
|
621
|
+
if (already && onConflict === 'fail')
|
|
622
|
+
conflicts.push(remote);
|
|
623
|
+
await dapi.files.writeBytes(remote, fs.readFileSync(sharePath(bundle.dir, remote)));
|
|
624
|
+
rows.push({name: remote, entityType: 'File', action: already ? 'update' : 'create', reason: 'share_file'});
|
|
625
|
+
} catch (err: any) {
|
|
626
|
+
rows.push({name: remote, entityType: 'File', action: 'warn', reason: 'share_file_not_written',
|
|
627
|
+
detail: err?.message ?? String(err)});
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
if (conflicts.length)
|
|
631
|
+
throw new Error('The target already has these share files (use --on-conflict skip|adopt): ' +
|
|
632
|
+
conflicts.join(', '));
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* The server keeps a name unique within the namespace the entity is in, and an entity created
|
|
637
|
+
* by a push is in the pusher's namespace until its space's relations are written. A name taken
|
|
638
|
+
* there is usually free once the entity is placed. Returns true when anything was written back,
|
|
639
|
+
* since a save re-homes the entity and the placement has to be asserted again.
|
|
640
|
+
*/
|
|
641
|
+
async function restoreNames(dapi: NodeDapi, renamed: {op: Op; row: Row}[],
|
|
642
|
+
idmap: Record<string, string>): Promise<boolean> {
|
|
643
|
+
let saved = false;
|
|
644
|
+
for (const {op, row} of renamed) {
|
|
645
|
+
const spec = TYPES[op.type];
|
|
646
|
+
const current = await dapi.internal(spec.route).find(idmap[op.id] ?? op.json.id).catch(() => null);
|
|
647
|
+
if (!current || current.name === op.json.name) continue;
|
|
648
|
+
current.name = op.json.name;
|
|
649
|
+
delete current.storage;
|
|
650
|
+
const restored = await dapi.internal(spec.saveRoute ?? spec.route)
|
|
651
|
+
.save(stripPrivate(JSON.parse(JSON.stringify(current)))).catch(() => null);
|
|
652
|
+
if (!restored) continue;
|
|
653
|
+
// The entity was written either way, so placement has to be re-asserted either way.
|
|
654
|
+
saved = true;
|
|
655
|
+
if (restored.name !== op.json.name) continue;
|
|
656
|
+
row.action = 'info';
|
|
657
|
+
row.reason = 'name_restored';
|
|
658
|
+
row.detail = `${op.json.name} was taken until the entity was placed`;
|
|
659
|
+
}
|
|
660
|
+
return saved;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* A namespace is a label derived from the owning space, so it is only worth judging once
|
|
665
|
+
* relations and names are settled.
|
|
666
|
+
*/
|
|
667
|
+
async function reportPlacement(dapi: NodeDapi, misplaced: Op[], rows: Row[],
|
|
668
|
+
idmap: Record<string, string>): Promise<void> {
|
|
669
|
+
for (const op of misplaced) {
|
|
670
|
+
const now = await dapi.internal(TYPES[op.type].route).find(idmap[op.id] ?? op.json.id).catch(() => null);
|
|
671
|
+
if (!now || (now.namespace ?? '') === op.expectedNamespace) continue;
|
|
672
|
+
rows.push({name: op.row.name, entityType: op.type, action: 'warn', reason: 'namespace_not_preserved',
|
|
673
|
+
detail: `${op.expectedNamespace} → ${now.namespace ?? ''}; pull the owning space so the entity travels in its relations`});
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* Tag rows are server-managed and duplicate on every POST, so only the tags the
|
|
679
|
+
* target is missing are replayed.
|
|
680
|
+
*/
|
|
681
|
+
async function pushTags(dapi: NodeDapi, effective: Map<string, BundleEntity>, planned: Map<string, Row>, rows: Row[]): Promise<void> {
|
|
682
|
+
const missing = new Map<string, string[]>();
|
|
683
|
+
try {
|
|
684
|
+
for (const [id, {type, json}] of effective) {
|
|
685
|
+
const tags: string[] = json._tags ?? [];
|
|
686
|
+
if (!tags.length || ['skip', 'failed'].includes(planned.get(id)?.action ?? '')) continue;
|
|
687
|
+
const target = await dapi.internal(TYPES[type].route).find(json.id).catch(() => null);
|
|
688
|
+
if (!target) continue;
|
|
689
|
+
const have = new Set((target.entityTags ?? []).map((t: any) => t?.tag));
|
|
690
|
+
for (const tag of tags)
|
|
691
|
+
if (!have.has(tag))
|
|
692
|
+
missing.set(tag, [...(missing.get(tag) ?? []), json.id]);
|
|
693
|
+
}
|
|
694
|
+
for (const [tag, ids] of missing)
|
|
695
|
+
await dapi.client.post(`/entities/tag?tag=${encodeURIComponent(tag)}`, ids);
|
|
696
|
+
} catch (err: any) {
|
|
697
|
+
rows.push({name: [...missing.keys()].join(', '), entityType: 'Entity', action: 'warn', reason: 'tags',
|
|
698
|
+
detail: err?.message ?? String(err)});
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/** Groups travel bare, so their members are re-attached by login and by group name. */
|
|
703
|
+
async function pushMemberships(dapi: NodeDapi, effective: Map<string, BundleEntity>, planned: Map<string, Row>, rows: Row[]): Promise<void> {
|
|
704
|
+
for (const [id, {type, json}] of effective) {
|
|
705
|
+
const members: any[] = json._members ?? [];
|
|
706
|
+
if (type !== 'UserGroup' || !members.length) continue;
|
|
707
|
+
if (['skip', 'failed'].includes(planned.get(id)?.action ?? '')) continue;
|
|
708
|
+
|
|
709
|
+
let matched = 0;
|
|
710
|
+
let missing = 0;
|
|
711
|
+
try {
|
|
712
|
+
for (const kind of ['user', 'group'])
|
|
713
|
+
for (const isAdmin of [false, true]) {
|
|
714
|
+
const wanted = members.filter((m) => m.kind === kind && (m.isAdmin === true) === isAdmin);
|
|
715
|
+
if (!wanted.length) continue;
|
|
716
|
+
const names = wanted.map((m) => (kind === 'user' ? m.login : m.name));
|
|
717
|
+
for (const result of await dapi.groups.addMembers(json.id, names, isAdmin, kind === 'user')) {
|
|
718
|
+
if (result.status !== 'error') { matched++; continue; }
|
|
719
|
+
missing++;
|
|
720
|
+
rows.push({name: nqNameOf(json), entityType: 'UserGroup', action: 'warn', reason: 'member_not_found',
|
|
721
|
+
detail: `${result.member}: ${result.error}`});
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
} catch (err: any) {
|
|
725
|
+
rows.push({name: nqNameOf(json), entityType: 'UserGroup', action: 'warn', reason: 'members',
|
|
726
|
+
detail: err?.message ?? String(err)});
|
|
727
|
+
continue;
|
|
728
|
+
}
|
|
729
|
+
const row = planned.get(id);
|
|
730
|
+
if (row)
|
|
731
|
+
row.detail = [row.detail, `members: ${matched} matched${missing ? `, ${missing} not on remote` : ''}`].filter(Boolean).join('; ');
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* Without a grant to a real group the pushed content is invisible on the target, so the
|
|
737
|
+
* source grants are replayed by group name. Returns whether anything is visible at all.
|
|
738
|
+
*/
|
|
739
|
+
async function pushGrants(dapi: NodeDapi, effective: Map<string, BundleEntity>, planned: Map<string, Row>, rows: Row[],
|
|
740
|
+
): Promise<{visible: boolean; projects: number}> {
|
|
741
|
+
let anyVisible = false;
|
|
742
|
+
let projects = 0;
|
|
743
|
+
const group = groupCache(dapi);
|
|
744
|
+
for (const [id, {type, json}] of effective) {
|
|
745
|
+
if (type !== 'Project' || ['skip', 'failed'].includes(planned.get(id)?.action ?? '')) continue;
|
|
746
|
+
projects++;
|
|
747
|
+
try {
|
|
748
|
+
if (!await dapi.internal('/projects').find(json.id)) continue;
|
|
749
|
+
const have = new Set<string>();
|
|
750
|
+
for (const perm of await grantsOf(dapi, json.id, group)) {
|
|
751
|
+
have.add(`${perm.group.toLowerCase()}|${perm.permission}`);
|
|
752
|
+
anyVisible = true;
|
|
753
|
+
}
|
|
754
|
+
for (const grant of json._grants ?? []) {
|
|
755
|
+
if (have.has(`${grant.group.toLowerCase()}|${grant.permission}`)) continue;
|
|
756
|
+
if (!['View', 'Edit'].includes(grant.permission)) {
|
|
757
|
+
rows.push({name: nqNameOf(json), entityType: type, action: 'info', reason: 'unsupported_grant',
|
|
758
|
+
detail: `${grant.group}: ${grant.permission}`});
|
|
759
|
+
continue;
|
|
760
|
+
}
|
|
761
|
+
const peer = (await dapi.groups.lookup(grant.group))
|
|
762
|
+
.find((g: any) => g?.personal !== true && (g.friendlyName ?? g.name) === grant.group);
|
|
763
|
+
if (!peer) {
|
|
764
|
+
rows.push({name: nqNameOf(json), entityType: type, action: 'warn', reason: 'group_not_found', detail: grant.group});
|
|
765
|
+
continue;
|
|
766
|
+
}
|
|
767
|
+
await dapi.shares.share(json.id, grant.group, grant.permission);
|
|
768
|
+
anyVisible = true;
|
|
769
|
+
}
|
|
770
|
+
} catch (err: any) {
|
|
771
|
+
rows.push({name: nqNameOf(json), entityType: type, action: 'warn', reason: 'grants',
|
|
772
|
+
detail: err?.message ?? String(err)});
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
return {visible: anyVisible, projects};
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
export function summarize(items: Row[], dapi: NodeDapi, status: string): PushResult {
|
|
779
|
+
const counts: Record<string, number> = {};
|
|
780
|
+
for (const r of items)
|
|
781
|
+
counts[r.action] = (counts[r.action] ?? 0) + 1;
|
|
782
|
+
return {items, counts, status, remoteUrl: dapi.client.baseUrl};
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/** Top-level keys whose normalized values differ — the same view of the JSON `hashOf` takes. */
|
|
786
|
+
export function changedKeys(type: string, a: any, b: any): string[] {
|
|
787
|
+
const na = hashView(type, a), nb = hashView(type, b);
|
|
788
|
+
return [...new Set([...Object.keys(na), ...Object.keys(nb)])]
|
|
789
|
+
.filter((k) => JSON.stringify(na[k]) !== JSON.stringify(nb[k]));
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
function minor(version: string): string {
|
|
793
|
+
return String(version ?? '').split('.').slice(0, 2).join('.');
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/** Login -> the id of that user's personal root project on this instance. */
|
|
797
|
+
async function personalProjects(dapi: NodeDapi): Promise<Map<string, string>> {
|
|
798
|
+
const byLogin = new Map<string, string>();
|
|
799
|
+
for (const user of await dapi.internal('/users').listAll({limit: 500}))
|
|
800
|
+
if (user?.login && user?.project?.id)
|
|
801
|
+
byLogin.set(user.login, user.project.id);
|
|
802
|
+
return byLogin;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
async function currentNamespace(dapi: NodeDapi): Promise<string> {
|
|
806
|
+
const user = await dapi.client.get('/users/current');
|
|
807
|
+
return user?.project?.name ? `${user.project.name}:` : '';
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/**
|
|
811
|
+
* An entity pulled from the source author's personal namespace lands under the pusher's
|
|
812
|
+
* own namespace on the target, so that is where a same-name twin would be.
|
|
813
|
+
*/
|
|
814
|
+
/** The target's own entity of that qualified name, whatever id it gave it. */
|
|
815
|
+
async function resolveByNqName(dapi: NodeDapi, type: string, nqName: string): Promise<any> {
|
|
816
|
+
const cut = nqName.lastIndexOf(':');
|
|
817
|
+
const namespace = cut === -1 ? '' : nqName.slice(0, cut + 1);
|
|
818
|
+
const name = nqName.slice(cut + 1);
|
|
819
|
+
if (!name) return null;
|
|
820
|
+
const matches = await dapi.internal('/entities').list({namespace, name}).catch(() => []);
|
|
821
|
+
return matches.find((m: any) => m['#type'] === type && inNamespace(m, namespace)) ?? null;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
async function findByNqName(dapi: NodeDapi, bundle: Bundle, type: string, json: any, pusherNamespace: string): Promise<any> {
|
|
825
|
+
// Not `expectedNamespace`: a twin for a namespace-less entity is looked up in the root.
|
|
826
|
+
const sourceNamespace: string = json.namespace ?? '';
|
|
827
|
+
const namespace = sourceNamespace === bundle.manifest.source.userNamespace ? pusherNamespace : sourceNamespace;
|
|
828
|
+
const matches = await dapi.internal('/entities').list({namespace, name: json.name});
|
|
829
|
+
return matches.find((m: any) => m['#type'] === type && inNamespace(m, namespace)) ?? null;
|
|
830
|
+
}
|