datagrok-tools 6.5.6 → 6.5.8
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/.devcontainer/docker-compose.yaml +68 -24
- package/CHANGELOG.md +41 -6
- package/CLAUDE.md +35 -10
- package/Core.json +1027 -0
- package/GROK_S.md +563 -27
- package/bin/commands/api.js +121 -70
- package/bin/commands/help.js +3 -65
- package/bin/commands/server-domains.js +468 -0
- package/bin/commands/server-migrate.js +392 -0
- package/bin/commands/server.js +455 -92
- package/bin/grok.js +16 -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 +980 -0
- package/bin/utils/migrate/pusher.ts +829 -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 +787 -141
- package/bin/utils/playwright-runner.js +55 -39
- 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
package/bin/grok.js
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
const argv = require('minimist')(process.argv.slice(2), {
|
|
3
3
|
alias: {k: 'key', h: 'help', r: 'recursive'},
|
|
4
4
|
boolean: ['dartium'],
|
|
5
|
+
// keep versions verbatim — minimist would coerce '1.10' to the number 1.1
|
|
6
|
+
string: ['version'],
|
|
5
7
|
});
|
|
6
8
|
// minimist maps `--no-retry` to `{retry: false}`, so the `args['no-retry']` checks in
|
|
7
9
|
// test.ts / playwright-runner.ts never fired and `--no-retry` was silently ignored
|
|
@@ -34,6 +36,14 @@ const commands = {
|
|
|
34
36
|
|
|
35
37
|
const onPackageCommandNames = ['api', 'check', 'link', 'publish', 'test'];
|
|
36
38
|
|
|
39
|
+
// A machine-readable run prints its error as JSON on stderr (server.ts) and nothing else:
|
|
40
|
+
// a usage dump on stdout would corrupt the output the caller parses.
|
|
41
|
+
const outputFormat = argv.output ?? argv.o;
|
|
42
|
+
function printUsage(command) {
|
|
43
|
+
if (outputFormat !== 'json')
|
|
44
|
+
process.stderr.write(`${help[command]}\n`);
|
|
45
|
+
}
|
|
46
|
+
|
|
37
47
|
const command = argv['_'][0];
|
|
38
48
|
if (command !== 'test' && command !== 'stresstest')
|
|
39
49
|
delete argv.dartium;
|
|
@@ -50,23 +60,23 @@ if (command in commands) {
|
|
|
50
60
|
if (result && typeof result.then === 'function') {
|
|
51
61
|
result.then((ok) => {
|
|
52
62
|
if (!ok) {
|
|
53
|
-
|
|
63
|
+
printUsage(command);
|
|
54
64
|
exitWithCode(1);
|
|
55
65
|
}
|
|
56
66
|
}).catch((err) => {
|
|
57
67
|
console.error(err);
|
|
58
|
-
|
|
68
|
+
printUsage(command);
|
|
59
69
|
exitWithCode(255);
|
|
60
70
|
});
|
|
61
71
|
}
|
|
62
72
|
else if (!result) {
|
|
63
|
-
|
|
73
|
+
printUsage(command);
|
|
64
74
|
exitWithCode(1);
|
|
65
75
|
}
|
|
66
76
|
}
|
|
67
77
|
} catch (err) {
|
|
68
78
|
console.error(err);
|
|
69
|
-
|
|
79
|
+
printUsage(command);
|
|
70
80
|
exitWithCode(255);
|
|
71
81
|
}
|
|
72
82
|
} else
|
|
@@ -74,6 +84,7 @@ if (command in commands) {
|
|
|
74
84
|
|
|
75
85
|
|
|
76
86
|
function exitWithCode(code) {
|
|
77
|
-
|
|
87
|
+
if (outputFormat !== 'json')
|
|
88
|
+
console.log(`Exiting with code ${code}`);
|
|
78
89
|
process.exit(code);
|
|
79
90
|
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, "__esModule", {
|
|
4
|
+
value: true
|
|
5
|
+
});
|
|
6
|
+
exports.bytesPath = bytesPath;
|
|
7
|
+
exports.hashOf = hashOf;
|
|
8
|
+
exports.hashView = hashView;
|
|
9
|
+
exports.list = list;
|
|
10
|
+
exports.listShares = listShares;
|
|
11
|
+
exports.normalize = normalize;
|
|
12
|
+
exports.read = read;
|
|
13
|
+
exports.sharePath = sharePath;
|
|
14
|
+
exports.stripPrivate = stripPrivate;
|
|
15
|
+
exports.write = write;
|
|
16
|
+
exports.writeIdmap = writeIdmap;
|
|
17
|
+
exports.writeShares = writeShares;
|
|
18
|
+
var fs = _interopRequireWildcard(require("fs"));
|
|
19
|
+
var path = _interopRequireWildcard(require("path"));
|
|
20
|
+
var _crypto = require("crypto");
|
|
21
|
+
var _registry = require("./registry");
|
|
22
|
+
function _interopRequireWildcard(e, t) { if ("function" == typeof WeakMap) var r = new WeakMap(), n = new WeakMap(); return (_interopRequireWildcard = function (e, t) { if (!t && e && e.__esModule) return e; var o, i, f = { __proto__: null, default: e }; if (null === e || "object" != typeof e && "function" != typeof e) return f; if (o = t ? n : r) { if (o.has(e)) return o.get(e); o.set(e, f); } for (const t in e) "default" !== t && {}.hasOwnProperty.call(e, t) && ((i = (o = Object.defineProperty) && Object.getOwnPropertyDescriptor(e, t)) && (i.get || i.set) ? o(f, t, i) : f[t] = e[t]); return f; })(e, t); }
|
|
23
|
+
/// Docs: [Entity export / import](/docs/features/grok-tool/export-import/DESIGN.md)
|
|
24
|
+
|
|
25
|
+
// `entityTags` rows carry their own primary keys and come back in an unstable order:
|
|
26
|
+
// pushing them makes the hash flap AND re-points the source's tag rows at the copy.
|
|
27
|
+
const VOLATILE = ['createdOn', 'updatedOn', 'author', 'pictureId', 'encryptedParametersId', 'keyKid', 'isAvailable', 'entityTags'];
|
|
28
|
+
function normalize(type, json) {
|
|
29
|
+
const copy = JSON.parse(JSON.stringify(json));
|
|
30
|
+
// Tag rows are volatile, but the tag names are worth migrating: keep them as a
|
|
31
|
+
// bundle-only list the pusher replays through `POST /entities/tag`.
|
|
32
|
+
const tags = [...new Set((copy.entityTags ?? []).map(t => t?.tag).filter(Boolean))].sort();
|
|
33
|
+
if (tags.length) copy._tags = tags;
|
|
34
|
+
for (const k of VOLATILE) delete copy[k];
|
|
35
|
+
if (copy.package) copy.package = {
|
|
36
|
+
id: copy.package.id
|
|
37
|
+
};
|
|
38
|
+
// The stamp is written by the pusher, so it must not make an unchanged entity look different.
|
|
39
|
+
if (copy.metaParams) delete copy.metaParams.sync_id;
|
|
40
|
+
_registry.TYPES[type]?.strip?.(copy);
|
|
41
|
+
return sortKeys(copy);
|
|
42
|
+
}
|
|
43
|
+
function sortKeys(v) {
|
|
44
|
+
if (Array.isArray(v)) return v.map(sortKeys);
|
|
45
|
+
if (v === null || typeof v !== 'object') return v;
|
|
46
|
+
const out = {};
|
|
47
|
+
for (const k of Object.keys(v).sort()) out[k] = sortKeys(v[k]);
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Bundle-only keys (`_credentials`, `_grants`, `_members`) never travel to the server. */
|
|
52
|
+
function stripPrivate(json) {
|
|
53
|
+
for (const k of Object.keys(json)) if (k.startsWith('_')) delete json[k];
|
|
54
|
+
return json;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* What a comparison sees: the payload without the bundle-only keys and without the
|
|
59
|
+
* namespace, which the server computes from ownership (`Askalkin:` on the source,
|
|
60
|
+
* `Admin:` on the target) and would otherwise make every cross-instance push a rewrite.
|
|
61
|
+
* The bundle file keeps it — `findByNqName` resolves the twin by it.
|
|
62
|
+
*/
|
|
63
|
+
function hashView(type, json) {
|
|
64
|
+
const view = stripPrivate(normalize(type, json));
|
|
65
|
+
delete view.namespace;
|
|
66
|
+
// Relations have a pass of their own, which only ever adds: their row ids belong to the
|
|
67
|
+
// target, and a target that links more than the bundle is not stale. The pusher compares
|
|
68
|
+
// them by coverage instead.
|
|
69
|
+
delete view.relations;
|
|
70
|
+
return view;
|
|
71
|
+
}
|
|
72
|
+
function hashOf(type, json) {
|
|
73
|
+
return (0, _crypto.createHash)('sha256').update(JSON.stringify(hashView(type, json))).digest('hex');
|
|
74
|
+
}
|
|
75
|
+
function bytesPath(dir, kind, id) {
|
|
76
|
+
return path.join(dir, kind, kind === 'tables' ? `${id}.d42` : id);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* A file a datasync table reads is stored under its full share path, flattened into one
|
|
81
|
+
* file name so the bundle stays a flat directory per kind and the path survives a round trip.
|
|
82
|
+
*/
|
|
83
|
+
function sharePath(dir, remotePath) {
|
|
84
|
+
return path.join(dir, 'shares', encodeURIComponent(remotePath));
|
|
85
|
+
}
|
|
86
|
+
function writeShares(dir, files) {
|
|
87
|
+
if (!files.size) return;
|
|
88
|
+
fs.mkdirSync(path.join(dir, 'shares'), {
|
|
89
|
+
recursive: true
|
|
90
|
+
});
|
|
91
|
+
for (const [remote, buf] of files) fs.writeFileSync(sharePath(dir, remote), buf);
|
|
92
|
+
}
|
|
93
|
+
function listShares(dir) {
|
|
94
|
+
const at = path.join(dir, 'shares');
|
|
95
|
+
return fs.existsSync(at) ? fs.readdirSync(at).map(decodeURIComponent) : [];
|
|
96
|
+
}
|
|
97
|
+
const EMPTY = {
|
|
98
|
+
formatVersion: 1,
|
|
99
|
+
source: {
|
|
100
|
+
url: '',
|
|
101
|
+
version: '',
|
|
102
|
+
userNamespace: ''
|
|
103
|
+
},
|
|
104
|
+
pulls: [],
|
|
105
|
+
order: [],
|
|
106
|
+
packages: []
|
|
107
|
+
};
|
|
108
|
+
function readManifest(dir) {
|
|
109
|
+
const file = path.join(dir, 'manifest.json');
|
|
110
|
+
if (!fs.existsSync(file)) return JSON.parse(JSON.stringify(EMPTY));
|
|
111
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
112
|
+
}
|
|
113
|
+
function write(dir, entities, meta, opts, bytes = new Map()) {
|
|
114
|
+
if (opts.replace) fs.rmSync(dir, {
|
|
115
|
+
recursive: true,
|
|
116
|
+
force: true
|
|
117
|
+
});
|
|
118
|
+
const manifest = readManifest(dir);
|
|
119
|
+
if (!manifest.source.url) manifest.source = meta.source;else if (manifest.source.url !== meta.source.url) throw new Error(`${dir} was pulled from ${manifest.source.url}; ` + `use --replace to start a new bundle from ${meta.source.url}`);
|
|
120
|
+
fs.mkdirSync(dir, {
|
|
121
|
+
recursive: true
|
|
122
|
+
});
|
|
123
|
+
const byId = new Map(manifest.order.map(e => [e.id, e]));
|
|
124
|
+
const takenBy = new Map([...byId.values()].map(e => [e.file, e.id]));
|
|
125
|
+
const ids = [];
|
|
126
|
+
for (const [id, {
|
|
127
|
+
type,
|
|
128
|
+
json
|
|
129
|
+
}] of entities) {
|
|
130
|
+
const base = `${type}/${(0, _registry.fileNameFor)(json)}`;
|
|
131
|
+
// Names are not unique across a bundle (two files of the same name in different shares);
|
|
132
|
+
// whoever claimed the plain name keeps it, the rest are suffixed by their id.
|
|
133
|
+
const owner = takenBy.get(`${base}.json`);
|
|
134
|
+
const file = !owner || owner === id ? `${base}.json` : `${base}-${id.slice(0, 8)}.json`;
|
|
135
|
+
const previous = byId.get(id);
|
|
136
|
+
if (previous && previous.file !== file) {
|
|
137
|
+
fs.rmSync(path.join(dir, previous.file), {
|
|
138
|
+
force: true
|
|
139
|
+
});
|
|
140
|
+
takenBy.delete(previous.file);
|
|
141
|
+
}
|
|
142
|
+
takenBy.set(file, id);
|
|
143
|
+
fs.mkdirSync(path.join(dir, type), {
|
|
144
|
+
recursive: true
|
|
145
|
+
});
|
|
146
|
+
fs.writeFileSync(path.join(dir, file), JSON.stringify(normalize(type, json), null, 2));
|
|
147
|
+
byId.set(id, {
|
|
148
|
+
type,
|
|
149
|
+
id,
|
|
150
|
+
file
|
|
151
|
+
});
|
|
152
|
+
ids.push(id);
|
|
153
|
+
}
|
|
154
|
+
for (const [id, buf] of bytes) {
|
|
155
|
+
const kind = _registry.TYPES[entities.get(id).type].bytes.kind;
|
|
156
|
+
fs.mkdirSync(path.join(dir, kind), {
|
|
157
|
+
recursive: true
|
|
158
|
+
});
|
|
159
|
+
fs.writeFileSync(bytesPath(dir, kind, id), buf);
|
|
160
|
+
}
|
|
161
|
+
manifest.formatVersion = 1;
|
|
162
|
+
manifest.pulls.push({
|
|
163
|
+
at: new Date().toISOString(),
|
|
164
|
+
args: meta.args,
|
|
165
|
+
ids
|
|
166
|
+
});
|
|
167
|
+
manifest.packages = [...new Set([...manifest.packages, ...meta.packages])].sort();
|
|
168
|
+
if (meta.externals?.length) {
|
|
169
|
+
const byId = new Map((manifest.externals ?? []).map(e => [e.id, e]));
|
|
170
|
+
for (const e of meta.externals) byId.set(e.id, e);
|
|
171
|
+
manifest.externals = [...byId.values()].sort((a, b) => a.nqName.localeCompare(b.nqName));
|
|
172
|
+
}
|
|
173
|
+
if (meta.dangling?.length) manifest.dangling = [...new Set([...(manifest.dangling ?? []), ...meta.dangling])].sort();
|
|
174
|
+
const pullOf = new Map();
|
|
175
|
+
manifest.pulls.forEach((p, i) => p.ids.forEach(id => pullOf.has(id) || pullOf.set(id, i)));
|
|
176
|
+
manifest.order = [...byId.values()].sort((a, b) => (0, _registry.rankOf)(a.type) - (0, _registry.rankOf)(b.type) || pullOf.get(a.id) - pullOf.get(b.id) || a.file.localeCompare(b.file));
|
|
177
|
+
fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2));
|
|
178
|
+
return manifest;
|
|
179
|
+
}
|
|
180
|
+
function read(dir) {
|
|
181
|
+
if (!fs.existsSync(path.join(dir, 'manifest.json'))) throw new Error(`Not a bundle directory (no manifest.json): ${dir}`);
|
|
182
|
+
const manifest = readManifest(dir);
|
|
183
|
+
const entities = new Map();
|
|
184
|
+
for (const e of manifest.order) {
|
|
185
|
+
const file = path.join(dir, e.file);
|
|
186
|
+
if (!fs.existsSync(file)) throw new Error(`Bundle is missing ${e.file} listed in manifest.json`);
|
|
187
|
+
entities.set(e.id, {
|
|
188
|
+
type: e.type,
|
|
189
|
+
json: JSON.parse(fs.readFileSync(file, 'utf8')),
|
|
190
|
+
file: e.file
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
const idmapFile = path.join(dir, 'idmap.json');
|
|
194
|
+
const idmap = fs.existsSync(idmapFile) ? JSON.parse(fs.readFileSync(idmapFile, 'utf8')) : {};
|
|
195
|
+
return {
|
|
196
|
+
dir,
|
|
197
|
+
manifest,
|
|
198
|
+
entities,
|
|
199
|
+
idmap
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Adopted `sourceId → targetId` pairs, so the next push of this bundle is stable. */
|
|
204
|
+
function writeIdmap(dir, idmap) {
|
|
205
|
+
fs.writeFileSync(path.join(dir, 'idmap.json'), JSON.stringify(idmap, null, 2));
|
|
206
|
+
}
|
|
207
|
+
function list(dir) {
|
|
208
|
+
const {
|
|
209
|
+
manifest,
|
|
210
|
+
entities
|
|
211
|
+
} = read(dir);
|
|
212
|
+
return manifest.order.map(e => {
|
|
213
|
+
const pull = manifest.pulls.findIndex(p => p.ids.includes(e.id));
|
|
214
|
+
const json = entities.get(e.id).json;
|
|
215
|
+
return {
|
|
216
|
+
type: e.type,
|
|
217
|
+
nqName: (0, _registry.nqNameOf)(json),
|
|
218
|
+
file: e.file,
|
|
219
|
+
pulledOn: manifest.pulls[pull]?.at ?? '',
|
|
220
|
+
pull: pull + 1
|
|
221
|
+
};
|
|
222
|
+
});
|
|
223
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/// Docs: [Entity export / import](/docs/features/grok-tool/export-import/DESIGN.md)
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import {createHash} from 'crypto';
|
|
5
|
+
import {BytesKind, TYPES, fileNameFor, nqNameOf, rankOf} from './registry';
|
|
6
|
+
|
|
7
|
+
export interface ManifestEntry {type: string; id: string; file: string}
|
|
8
|
+
|
|
9
|
+
export interface Manifest {
|
|
10
|
+
formatVersion: 1;
|
|
11
|
+
source: {url: string; version: string; commit?: string; userNamespace: string};
|
|
12
|
+
pulls: {at: string; args: string[]; ids: string[]}[];
|
|
13
|
+
order: ManifestEntry[];
|
|
14
|
+
packages: string[];
|
|
15
|
+
/** Referenced entities the source keeps; the push re-finds them by name. */
|
|
16
|
+
externals?: {id: string; type: string; nqName: string}[];
|
|
17
|
+
/** Ids the bundle references that nothing on the source answers to — dead there, unfixable here. */
|
|
18
|
+
dangling?: string[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface BundleEntity {type: string; json: any; file?: string}
|
|
22
|
+
|
|
23
|
+
export interface Bundle {
|
|
24
|
+
dir: string;
|
|
25
|
+
manifest: Manifest;
|
|
26
|
+
entities: Map<string, BundleEntity>;
|
|
27
|
+
idmap: Record<string, string>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// `entityTags` rows carry their own primary keys and come back in an unstable order:
|
|
31
|
+
// pushing them makes the hash flap AND re-points the source's tag rows at the copy.
|
|
32
|
+
const VOLATILE = ['createdOn', 'updatedOn', 'author', 'pictureId', 'encryptedParametersId',
|
|
33
|
+
'keyKid', 'isAvailable', 'entityTags'];
|
|
34
|
+
|
|
35
|
+
export function normalize(type: string, json: any): any {
|
|
36
|
+
const copy = JSON.parse(JSON.stringify(json));
|
|
37
|
+
// Tag rows are volatile, but the tag names are worth migrating: keep them as a
|
|
38
|
+
// bundle-only list the pusher replays through `POST /entities/tag`.
|
|
39
|
+
const tags = [...new Set((copy.entityTags ?? []).map((t: any) => t?.tag).filter(Boolean))].sort();
|
|
40
|
+
if (tags.length)
|
|
41
|
+
copy._tags = tags;
|
|
42
|
+
for (const k of VOLATILE)
|
|
43
|
+
delete copy[k];
|
|
44
|
+
if (copy.package)
|
|
45
|
+
copy.package = {id: copy.package.id};
|
|
46
|
+
// The stamp is written by the pusher, so it must not make an unchanged entity look different.
|
|
47
|
+
if (copy.metaParams)
|
|
48
|
+
delete copy.metaParams.sync_id;
|
|
49
|
+
TYPES[type]?.strip?.(copy);
|
|
50
|
+
return sortKeys(copy);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function sortKeys(v: any): any {
|
|
54
|
+
if (Array.isArray(v)) return v.map(sortKeys);
|
|
55
|
+
if (v === null || typeof v !== 'object') return v;
|
|
56
|
+
const out: any = {};
|
|
57
|
+
for (const k of Object.keys(v).sort())
|
|
58
|
+
out[k] = sortKeys(v[k]);
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Bundle-only keys (`_credentials`, `_grants`, `_members`) never travel to the server. */
|
|
63
|
+
export function stripPrivate(json: any): any {
|
|
64
|
+
for (const k of Object.keys(json))
|
|
65
|
+
if (k.startsWith('_'))
|
|
66
|
+
delete json[k];
|
|
67
|
+
return json;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* What a comparison sees: the payload without the bundle-only keys and without the
|
|
72
|
+
* namespace, which the server computes from ownership (`Askalkin:` on the source,
|
|
73
|
+
* `Admin:` on the target) and would otherwise make every cross-instance push a rewrite.
|
|
74
|
+
* The bundle file keeps it — `findByNqName` resolves the twin by it.
|
|
75
|
+
*/
|
|
76
|
+
export function hashView(type: string, json: any): any {
|
|
77
|
+
const view = stripPrivate(normalize(type, json));
|
|
78
|
+
delete view.namespace;
|
|
79
|
+
// Relations have a pass of their own, which only ever adds: their row ids belong to the
|
|
80
|
+
// target, and a target that links more than the bundle is not stale. The pusher compares
|
|
81
|
+
// them by coverage instead.
|
|
82
|
+
delete view.relations;
|
|
83
|
+
return view;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function hashOf(type: string, json: any): string {
|
|
87
|
+
return createHash('sha256').update(JSON.stringify(hashView(type, json))).digest('hex');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function bytesPath(dir: string, kind: BytesKind, id: string): string {
|
|
91
|
+
return path.join(dir, kind, kind === 'tables' ? `${id}.d42` : id);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* A file a datasync table reads is stored under its full share path, flattened into one
|
|
96
|
+
* file name so the bundle stays a flat directory per kind and the path survives a round trip.
|
|
97
|
+
*/
|
|
98
|
+
export function sharePath(dir: string, remotePath: string): string {
|
|
99
|
+
return path.join(dir, 'shares', encodeURIComponent(remotePath));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function writeShares(dir: string, files: Map<string, Buffer>): void {
|
|
103
|
+
if (!files.size) return;
|
|
104
|
+
fs.mkdirSync(path.join(dir, 'shares'), {recursive: true});
|
|
105
|
+
for (const [remote, buf] of files)
|
|
106
|
+
fs.writeFileSync(sharePath(dir, remote), buf);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function listShares(dir: string): string[] {
|
|
110
|
+
const at = path.join(dir, 'shares');
|
|
111
|
+
return fs.existsSync(at) ? fs.readdirSync(at).map(decodeURIComponent) : [];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const EMPTY: Manifest = {
|
|
115
|
+
formatVersion: 1,
|
|
116
|
+
source: {url: '', version: '', userNamespace: ''},
|
|
117
|
+
pulls: [],
|
|
118
|
+
order: [],
|
|
119
|
+
packages: [],
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
function readManifest(dir: string): Manifest {
|
|
123
|
+
const file = path.join(dir, 'manifest.json');
|
|
124
|
+
if (!fs.existsSync(file)) return JSON.parse(JSON.stringify(EMPTY));
|
|
125
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function write(dir: string, entities: Map<string, BundleEntity>,
|
|
129
|
+
meta: {source: Manifest['source']; args: string[]; packages: string[];
|
|
130
|
+
externals?: {id: string; type: string; nqName: string}[]; dangling?: string[]},
|
|
131
|
+
opts: {replace?: boolean},
|
|
132
|
+
bytes: Map<string, Buffer> = new Map()): Manifest {
|
|
133
|
+
if (opts.replace)
|
|
134
|
+
fs.rmSync(dir, {recursive: true, force: true});
|
|
135
|
+
const manifest = readManifest(dir);
|
|
136
|
+
if (!manifest.source.url)
|
|
137
|
+
manifest.source = meta.source;
|
|
138
|
+
else if (manifest.source.url !== meta.source.url)
|
|
139
|
+
throw new Error(`${dir} was pulled from ${manifest.source.url}; ` +
|
|
140
|
+
`use --replace to start a new bundle from ${meta.source.url}`);
|
|
141
|
+
fs.mkdirSync(dir, {recursive: true});
|
|
142
|
+
|
|
143
|
+
const byId = new Map<string, ManifestEntry>(manifest.order.map((e) => [e.id, e]));
|
|
144
|
+
const takenBy = new Map<string, string>([...byId.values()].map((e) => [e.file, e.id]));
|
|
145
|
+
const ids: string[] = [];
|
|
146
|
+
for (const [id, {type, json}] of entities) {
|
|
147
|
+
const base = `${type}/${fileNameFor(json)}`;
|
|
148
|
+
// Names are not unique across a bundle (two files of the same name in different shares);
|
|
149
|
+
// whoever claimed the plain name keeps it, the rest are suffixed by their id.
|
|
150
|
+
const owner = takenBy.get(`${base}.json`);
|
|
151
|
+
const file = !owner || owner === id ? `${base}.json` : `${base}-${id.slice(0, 8)}.json`;
|
|
152
|
+
const previous = byId.get(id);
|
|
153
|
+
if (previous && previous.file !== file) {
|
|
154
|
+
fs.rmSync(path.join(dir, previous.file), {force: true});
|
|
155
|
+
takenBy.delete(previous.file);
|
|
156
|
+
}
|
|
157
|
+
takenBy.set(file, id);
|
|
158
|
+
fs.mkdirSync(path.join(dir, type), {recursive: true});
|
|
159
|
+
fs.writeFileSync(path.join(dir, file), JSON.stringify(normalize(type, json), null, 2));
|
|
160
|
+
byId.set(id, {type, id, file});
|
|
161
|
+
ids.push(id);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
for (const [id, buf] of bytes) {
|
|
165
|
+
const kind = TYPES[entities.get(id)!.type].bytes!.kind;
|
|
166
|
+
fs.mkdirSync(path.join(dir, kind), {recursive: true});
|
|
167
|
+
fs.writeFileSync(bytesPath(dir, kind, id), buf);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
manifest.formatVersion = 1;
|
|
171
|
+
manifest.pulls.push({at: new Date().toISOString(), args: meta.args, ids});
|
|
172
|
+
manifest.packages = [...new Set([...manifest.packages, ...meta.packages])].sort();
|
|
173
|
+
if (meta.externals?.length) {
|
|
174
|
+
const byId = new Map((manifest.externals ?? []).map((e) => [e.id, e]));
|
|
175
|
+
for (const e of meta.externals) byId.set(e.id, e);
|
|
176
|
+
manifest.externals = [...byId.values()].sort((a, b) => a.nqName.localeCompare(b.nqName));
|
|
177
|
+
}
|
|
178
|
+
if (meta.dangling?.length)
|
|
179
|
+
manifest.dangling = [...new Set([...(manifest.dangling ?? []), ...meta.dangling])].sort();
|
|
180
|
+
const pullOf = new Map<string, number>();
|
|
181
|
+
manifest.pulls.forEach((p, i) => p.ids.forEach((id) => pullOf.has(id) || pullOf.set(id, i)));
|
|
182
|
+
manifest.order = [...byId.values()].sort((a, b) =>
|
|
183
|
+
rankOf(a.type) - rankOf(b.type) || pullOf.get(a.id)! - pullOf.get(b.id)! || a.file.localeCompare(b.file));
|
|
184
|
+
fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2));
|
|
185
|
+
return manifest;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function read(dir: string): Bundle {
|
|
189
|
+
if (!fs.existsSync(path.join(dir, 'manifest.json')))
|
|
190
|
+
throw new Error(`Not a bundle directory (no manifest.json): ${dir}`);
|
|
191
|
+
const manifest = readManifest(dir);
|
|
192
|
+
const entities = new Map<string, BundleEntity>();
|
|
193
|
+
for (const e of manifest.order) {
|
|
194
|
+
const file = path.join(dir, e.file);
|
|
195
|
+
if (!fs.existsSync(file))
|
|
196
|
+
throw new Error(`Bundle is missing ${e.file} listed in manifest.json`);
|
|
197
|
+
entities.set(e.id, {type: e.type, json: JSON.parse(fs.readFileSync(file, 'utf8')), file: e.file});
|
|
198
|
+
}
|
|
199
|
+
const idmapFile = path.join(dir, 'idmap.json');
|
|
200
|
+
const idmap = fs.existsSync(idmapFile) ? JSON.parse(fs.readFileSync(idmapFile, 'utf8')) : {};
|
|
201
|
+
return {dir, manifest, entities, idmap};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Adopted `sourceId → targetId` pairs, so the next push of this bundle is stable. */
|
|
205
|
+
export function writeIdmap(dir: string, idmap: Record<string, string>): void {
|
|
206
|
+
fs.writeFileSync(path.join(dir, 'idmap.json'), JSON.stringify(idmap, null, 2));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function list(dir: string): any[] {
|
|
210
|
+
const {manifest, entities} = read(dir);
|
|
211
|
+
return manifest.order.map((e) => {
|
|
212
|
+
const pull = manifest.pulls.findIndex((p) => p.ids.includes(e.id));
|
|
213
|
+
const json = entities.get(e.id)!.json;
|
|
214
|
+
return {
|
|
215
|
+
type: e.type,
|
|
216
|
+
nqName: nqNameOf(json),
|
|
217
|
+
file: e.file,
|
|
218
|
+
pulledOn: manifest.pulls[pull]?.at ?? '',
|
|
219
|
+
pull: pull + 1,
|
|
220
|
+
};
|
|
221
|
+
});
|
|
222
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, "__esModule", {
|
|
4
|
+
value: true
|
|
5
|
+
});
|
|
6
|
+
exports.SWEEP = void 0;
|
|
7
|
+
exports.missingPackages = missingPackages;
|
|
8
|
+
exports.missingUsers = missingUsers;
|
|
9
|
+
exports.namespacesOf = namespacesOf;
|
|
10
|
+
exports.plannedParts = plannedParts;
|
|
11
|
+
exports.readState = readState;
|
|
12
|
+
exports.writeState = writeState;
|
|
13
|
+
var fs = _interopRequireWildcard(require("fs"));
|
|
14
|
+
var path = _interopRequireWildcard(require("path"));
|
|
15
|
+
function _interopRequireWildcard(e, t) { if ("function" == typeof WeakMap) var r = new WeakMap(), n = new WeakMap(); return (_interopRequireWildcard = function (e, t) { if (!t && e && e.__esModule) return e; var o, i, f = { __proto__: null, default: e }; if (null === e || "object" != typeof e && "function" != typeof e) return f; if (o = t ? n : r) { if (o.has(e)) return o.get(e); o.set(e, f); } for (const t in e) "default" !== t && {}.hasOwnProperty.call(e, t) && ((i = (o = Object.defineProperty) && Object.getOwnPropertyDescriptor(e, t)) && (i.get || i.set) ? o(f, t, i) : f[t] = e[t]); return f; })(e, t); }
|
|
16
|
+
/// Docs: [Entity export / import](/docs/features/grok-tool/export-import/DESIGN.md)
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Every space worth migrating on its own: the personal space of each user, and each team space.
|
|
20
|
+
* A whole instance moved in one bundle has every project competing for the same entities, and
|
|
21
|
+
* placement is exclusive on the server — scoping a run to one space keeps that contention inside
|
|
22
|
+
* it, which is the difference between a run that finishes and one that does not.
|
|
23
|
+
*/
|
|
24
|
+
async function namespacesOf(dapi) {
|
|
25
|
+
const names = new Set();
|
|
26
|
+
for (const p of await dapi.internal('/projects').listAll({
|
|
27
|
+
includeRoot: 'true'
|
|
28
|
+
})) {
|
|
29
|
+
if (p?.isEntity || p?.isPackage) continue;
|
|
30
|
+
// The platform's own space: its connections are already refused as `platform_connection`, the
|
|
31
|
+
// target builds its own, and listing what is under it does not answer.
|
|
32
|
+
if (String(p.namespace ?? '').startsWith('System:') || p.name === 'System') continue;
|
|
33
|
+
const own = String(p.namespace ?? '').split(':')[0];
|
|
34
|
+
if (own) names.add(own);else if (p.isRoot && p.name) names.add(String(p.name));
|
|
35
|
+
}
|
|
36
|
+
return [...names].sort();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Users the source has and the target does not: their content lands under the pusher instead. */
|
|
40
|
+
async function missingUsers(from, to) {
|
|
41
|
+
const here = new Set((await to.internal('/users').listAll({})).map(u => u.login));
|
|
42
|
+
return (await from.internal('/users').listAll({})).map(u => u.login).filter(login => login && !here.has(login)).sort();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Packages the source has and the target does not: their functions and connections cannot resolve. */
|
|
46
|
+
async function missingPackages(from, to) {
|
|
47
|
+
const here = new Set((await to.internal('/packages').listAll({})).map(p => p.name));
|
|
48
|
+
return (await from.internal('/packages').listAll({})).map(p => p.name).filter(name => name && !here.has(name)).sort();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A run is resumable: what finished is remembered, so a repeat does not redo it. */
|
|
52
|
+
function readState(file) {
|
|
53
|
+
try {
|
|
54
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
55
|
+
} catch {
|
|
56
|
+
return {};
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function writeState(file, state) {
|
|
60
|
+
fs.mkdirSync(path.dirname(file), {
|
|
61
|
+
recursive: true
|
|
62
|
+
});
|
|
63
|
+
fs.writeFileSync(file, JSON.stringify(state, null, 2));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The last part: whatever no space owns, which would otherwise never travel. */
|
|
67
|
+
const SWEEP = exports.SWEEP = '(unowned)';
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Which parts a run still has to do. A name that matches no space is a typo, not an empty
|
|
71
|
+
* selection — silently migrating nothing is the one outcome worse than refusing.
|
|
72
|
+
*/
|
|
73
|
+
function plannedParts(all, opts) {
|
|
74
|
+
const only = opts.only ?? [];
|
|
75
|
+
const skip = new Set(opts.skip ?? []);
|
|
76
|
+
const unknown = [...only, ...(opts.skip ?? [])].filter(n => !all.includes(n));
|
|
77
|
+
if (unknown.length) throw new Error(`No such space: ${unknown.join(', ')}`);
|
|
78
|
+
const chosen = all.filter(n => (!only.length || only.includes(n)) && !skip.has(n));
|
|
79
|
+
if (opts.sweep) chosen.push(SWEEP);
|
|
80
|
+
// A part that failed, or that reported failures, is not finished.
|
|
81
|
+
const done = opts.state ?? {};
|
|
82
|
+
return chosen.filter(n => !done[n] || done[n].error || done[n].failed);
|
|
83
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/// Docs: [Entity export / import](/docs/features/grok-tool/export-import/DESIGN.md)
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import {NodeDapi} from '../node-dapi';
|
|
5
|
+
|
|
6
|
+
export interface Part {name: string; entities?: number; failed?: number; seconds?: number; error?: string}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Every space worth migrating on its own: the personal space of each user, and each team space.
|
|
10
|
+
* A whole instance moved in one bundle has every project competing for the same entities, and
|
|
11
|
+
* placement is exclusive on the server — scoping a run to one space keeps that contention inside
|
|
12
|
+
* it, which is the difference between a run that finishes and one that does not.
|
|
13
|
+
*/
|
|
14
|
+
export async function namespacesOf(dapi: NodeDapi): Promise<string[]> {
|
|
15
|
+
const names = new Set<string>();
|
|
16
|
+
for (const p of await dapi.internal('/projects').listAll({includeRoot: 'true'})) {
|
|
17
|
+
if (p?.isEntity || p?.isPackage) continue;
|
|
18
|
+
// The platform's own space: its connections are already refused as `platform_connection`, the
|
|
19
|
+
// target builds its own, and listing what is under it does not answer.
|
|
20
|
+
if (String(p.namespace ?? '').startsWith('System:') || p.name === 'System') continue;
|
|
21
|
+
const own = String(p.namespace ?? '').split(':')[0];
|
|
22
|
+
if (own) names.add(own);
|
|
23
|
+
else if (p.isRoot && p.name) names.add(String(p.name));
|
|
24
|
+
}
|
|
25
|
+
return [...names].sort();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Users the source has and the target does not: their content lands under the pusher instead. */
|
|
29
|
+
export async function missingUsers(from: NodeDapi, to: NodeDapi): Promise<string[]> {
|
|
30
|
+
const here = new Set((await to.internal('/users').listAll({})).map((u: any) => u.login));
|
|
31
|
+
return (await from.internal('/users').listAll({}))
|
|
32
|
+
.map((u: any) => u.login).filter((login: string) => login && !here.has(login)).sort();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Packages the source has and the target does not: their functions and connections cannot resolve. */
|
|
36
|
+
export async function missingPackages(from: NodeDapi, to: NodeDapi): Promise<string[]> {
|
|
37
|
+
const here = new Set((await to.internal('/packages').listAll({})).map((p: any) => p.name));
|
|
38
|
+
return (await from.internal('/packages').listAll({}))
|
|
39
|
+
.map((p: any) => p.name).filter((name: string) => name && !here.has(name)).sort();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A run is resumable: what finished is remembered, so a repeat does not redo it. */
|
|
43
|
+
export function readState(file: string): Record<string, Part> {
|
|
44
|
+
try { return JSON.parse(fs.readFileSync(file, 'utf8')); }
|
|
45
|
+
catch { return {}; }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function writeState(file: string, state: Record<string, Part>): void {
|
|
49
|
+
fs.mkdirSync(path.dirname(file), {recursive: true});
|
|
50
|
+
fs.writeFileSync(file, JSON.stringify(state, null, 2));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The last part: whatever no space owns, which would otherwise never travel. */
|
|
54
|
+
export const SWEEP = '(unowned)';
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Which parts a run still has to do. A name that matches no space is a typo, not an empty
|
|
58
|
+
* selection — silently migrating nothing is the one outcome worse than refusing.
|
|
59
|
+
*/
|
|
60
|
+
export function plannedParts(all: string[], opts: {only?: string[]; skip?: string[];
|
|
61
|
+
state?: Record<string, Part>; sweep?: boolean}): string[] {
|
|
62
|
+
const only = opts.only ?? [];
|
|
63
|
+
const skip = new Set(opts.skip ?? []);
|
|
64
|
+
const unknown = [...only, ...(opts.skip ?? [])].filter((n) => !all.includes(n));
|
|
65
|
+
if (unknown.length)
|
|
66
|
+
throw new Error(`No such space: ${unknown.join(', ')}`);
|
|
67
|
+
const chosen = all.filter((n) => (!only.length || only.includes(n)) && !skip.has(n));
|
|
68
|
+
if (opts.sweep) chosen.push(SWEEP);
|
|
69
|
+
// A part that failed, or that reported failures, is not finished.
|
|
70
|
+
const done = opts.state ?? {};
|
|
71
|
+
return chosen.filter((n) => !done[n] || done[n].error || done[n].failed);
|
|
72
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, "__esModule", {
|
|
4
|
+
value: true
|
|
5
|
+
});
|
|
6
|
+
exports.pool = pool;
|
|
7
|
+
/// Docs: [Entity export / import](/docs/features/grok-tool/export-import/DESIGN.md)
|
|
8
|
+
|
|
9
|
+
/** Runs `work` over `items` with at most `concurrency` in flight, in the order given. */
|
|
10
|
+
async function pool(items, concurrency, work) {
|
|
11
|
+
const queue = items.slice();
|
|
12
|
+
const workers = [];
|
|
13
|
+
for (let i = 0; i < Math.min(concurrency, queue.length); i++) workers.push((async () => {
|
|
14
|
+
while (queue.length) await work(queue.shift());
|
|
15
|
+
})());
|
|
16
|
+
await Promise.all(workers);
|
|
17
|
+
}
|