@gaia-ai/addon-name-refs 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +5 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.js +408 -0
- package/package.json +27 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 keytec GmbH
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# @gaia-ai/addon-name-refs
|
|
2
|
+
|
|
3
|
+
GAIA connection addon: resolve entity NAMES (--label_names, --environment_names) into the relationships that reference them, on the live write, via dropsh's extendOperationSchema and alterRequest hooks.
|
|
4
|
+
|
|
5
|
+
Part of the GAIA CLI. Install the meta package `@gaia-ai/gaia` to get the `gaia` CLI with all addons. Source: https://git.key-tec.de/keytec/gaia (gaia-cli/).
|
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
// GAIA-403: resolve entity NAMES into the relationship that references them, so
|
|
2
|
+
// a skill writes `--label_names work:code` or `--environment_names staging`
|
|
3
|
+
// instead of searching the collection, pulling a UUID out with jq, and
|
|
4
|
+
// hand-building the linkage.
|
|
5
|
+
//
|
|
6
|
+
// Two hooks, deliberately split by what they may do:
|
|
7
|
+
// extendOperationSchema — OFFLINE. It makes each synthetic field a settable
|
|
8
|
+
// field parameter. dropsh disk-caches the extended schema (keyed on the
|
|
9
|
+
// loaded hook plugins), so anything fetched here would go stale; that is why
|
|
10
|
+
// the synthetic fields carry no enum of names.
|
|
11
|
+
// alterRequest — the resolution, on the LIVE write only. `--dry-run` returns
|
|
12
|
+
// before the client, so a dry-run shows the synthetic field unresolved and a
|
|
13
|
+
// typo surfaces on the live call — atomically: a throw here becomes a
|
|
14
|
+
// PluginError (exit 6) and nothing is sent.
|
|
15
|
+
//
|
|
16
|
+
// WHERE THIS LIVES. Its own package, `@gaia-ai/addon-name-refs`, carried as a
|
|
17
|
+
// child of `@gaia-ai/addon-essentials` exactly as `@gaia-ai/addon-workspace` is
|
|
18
|
+
// — so no config names it and the single non-auth `plugins[]` entry every
|
|
19
|
+
// committed config already has is unchanged. It contributes no preset
|
|
20
|
+
// accumulator, so it is a plain sibling edge in `PERMITTED_SIBLINGS`.
|
|
21
|
+
//
|
|
22
|
+
// Adding a target is one RESOLVERS entry. The one thing that differs between
|
|
23
|
+
// them is whether a name is unique on its own: `gaia_term` names are, so a name
|
|
24
|
+
// identifies a term outright. `gaia_environment` names are unique only WITHIN A
|
|
25
|
+
// PROJECT (GaiaEnvironmentUniquePerProject), so the same name legitimately
|
|
26
|
+
// exists several times and picking one would be luck rather than resolution —
|
|
27
|
+
// hence `scopeField`, and the narrowing it drives.
|
|
28
|
+
const RESOLVERS = [
|
|
29
|
+
{
|
|
30
|
+
field: 'label_names',
|
|
31
|
+
noun: 'label',
|
|
32
|
+
relationship: 'labels',
|
|
33
|
+
type: 'gaia_term--gaia_labels',
|
|
34
|
+
path: 'gaia_term/gaia_labels',
|
|
35
|
+
title: 'Label names (comma-separated gaia_labels names; ' +
|
|
36
|
+
'resolved to labels on the live write)',
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
field: 'environment_names',
|
|
40
|
+
noun: 'environment',
|
|
41
|
+
relationship: 'environments',
|
|
42
|
+
type: 'gaia_environment--gaia_environment',
|
|
43
|
+
path: 'gaia_environment/gaia_environment',
|
|
44
|
+
title: 'Environment names (comma-separated; unique per project, so an ' +
|
|
45
|
+
'ambiguous name is narrowed by the project_id of the write — ' +
|
|
46
|
+
'resolved to environments on the live write)',
|
|
47
|
+
scopeField: 'project_id',
|
|
48
|
+
},
|
|
49
|
+
];
|
|
50
|
+
/**
|
|
51
|
+
* page[limit] bounds the QUERY, not the row count — links.next is the only end
|
|
52
|
+
* signal. Asking for more than Drupal's `OffsetPage::SIZE_MAX` is not an error,
|
|
53
|
+
* it is silently clamped to it, so requesting 200 bought nothing and made the
|
|
54
|
+
* ceiling below misreport itself by 4x. 50 is the number the server will
|
|
55
|
+
* actually serve.
|
|
56
|
+
*/
|
|
57
|
+
const PAGE_LIMIT = 50;
|
|
58
|
+
const MAX_PAGES = 10;
|
|
59
|
+
/** Rows this resolver will walk before it refuses to resolve at all. */
|
|
60
|
+
const MAX_ROWS = PAGE_LIMIT * MAX_PAGES;
|
|
61
|
+
/**
|
|
62
|
+
* How many known names an error may list. `gaia_labels` is small; a future
|
|
63
|
+
* target need not be, and an error nobody can read is an error nobody reads.
|
|
64
|
+
*/
|
|
65
|
+
const NAME_LIST_CAP = 20;
|
|
66
|
+
function isRecord(value) {
|
|
67
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
68
|
+
}
|
|
69
|
+
function at(node, ...path) {
|
|
70
|
+
let cursor = node;
|
|
71
|
+
for (const key of path) {
|
|
72
|
+
if (!isRecord(cursor))
|
|
73
|
+
return undefined;
|
|
74
|
+
cursor = cursor[key];
|
|
75
|
+
}
|
|
76
|
+
return cursor;
|
|
77
|
+
}
|
|
78
|
+
/** The id a relationship points at, for the single-valued relationships used as scopes. */
|
|
79
|
+
function relationshipId(node, field) {
|
|
80
|
+
const id = at(node, 'relationships', field, 'data', 'id');
|
|
81
|
+
return typeof id === 'string' ? id : undefined;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Does this operation schema declare this resolver's relationship over its
|
|
85
|
+
* target type? Only the multi-valued (`items`) shape is recognised: both
|
|
86
|
+
* targets are unlimited-cardinality, and the merge below writes an array, so
|
|
87
|
+
* matching a single-valued relationship here would produce an invalid payload.
|
|
88
|
+
*/
|
|
89
|
+
function declares(schema, ref) {
|
|
90
|
+
const targets = at(schema, 'properties', 'data', 'properties', 'relationships', 'properties', ref.relationship, 'properties', 'data', 'items', 'properties', 'type', 'enum');
|
|
91
|
+
return Array.isArray(targets) && targets.includes(ref.type);
|
|
92
|
+
}
|
|
93
|
+
/** `/jsonapi`, however the config spelled it — with, without, or with a trailing slash. */
|
|
94
|
+
function normalisePrefix(raw) {
|
|
95
|
+
const trimmed = (raw ?? '/jsonapi').replace(/^\/+/, '').replace(/\/+$/, '');
|
|
96
|
+
return trimmed === '' ? '' : `/${trimmed}`;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* GET one JSON:API document, or fail by name.
|
|
100
|
+
*
|
|
101
|
+
* This plugin issues its own reads through `ctx.http` / `ctx.auth`, which is the
|
|
102
|
+
* only seam the plugin API offers — and it is NOT the seam dropsh's JSON:API
|
|
103
|
+
* client uses. That client catches a 401 and calls `auth.renew()` once
|
|
104
|
+
* (`client.js:44-47`); nothing here can, because `AuthAdapter.apply` exposes no
|
|
105
|
+
* renewal. So an expired token cannot be refreshed transparently mid-write: it
|
|
106
|
+
* arrives here as a plain non-2xx, and the least this can do is say so instead
|
|
107
|
+
* of handing an HTML error page to `JSON.parse`.
|
|
108
|
+
*/
|
|
109
|
+
async function fetchDoc(ctx, url, what) {
|
|
110
|
+
const res = await ctx.http.send(await ctx.auth.apply({
|
|
111
|
+
method: 'GET',
|
|
112
|
+
url,
|
|
113
|
+
headers: { Accept: 'application/vnd.api+json' },
|
|
114
|
+
}));
|
|
115
|
+
if (res.status < 200 || res.status >= 300) {
|
|
116
|
+
const expiry = res.status === 401 || res.status === 403
|
|
117
|
+
? ' The access token may have expired: this plugin performs its own reads ' +
|
|
118
|
+
'and, unlike dropsh’s JSON:API client, cannot renew one, so an expired ' +
|
|
119
|
+
'token surfaces here instead of being refreshed. Re-authenticate and retry.'
|
|
120
|
+
: '';
|
|
121
|
+
throw new Error(`could not read ${what}: HTTP ${res.status} from ${url}.${expiry}`);
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
return JSON.parse(res.body);
|
|
125
|
+
}
|
|
126
|
+
catch (cause) {
|
|
127
|
+
throw new Error(`could not read ${what}: ${url} answered ${res.status} but not JSON (${String(cause)})`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* The whole collection as name → candidates. One round trip regardless of how
|
|
132
|
+
* many names were given, and it yields the known-names list the error needs.
|
|
133
|
+
*/
|
|
134
|
+
async function fetchIndex(ctx, ref) {
|
|
135
|
+
const prefix = normalisePrefix(ctx.jsonapiPrefix);
|
|
136
|
+
const fields = ref.scopeField === undefined ? 'name' : `name,${ref.scopeField}`;
|
|
137
|
+
let url = `${ctx.baseUrl.replace(/\/+$/, '')}${prefix}/${ref.path}` +
|
|
138
|
+
`?fields[${ref.type}]=${fields}&sort=name&page[limit]=${PAGE_LIMIT}`;
|
|
139
|
+
const byName = new Map();
|
|
140
|
+
for (let page = 0; page < MAX_PAGES; page += 1) {
|
|
141
|
+
const doc = await fetchDoc(ctx, url, `the ${ref.path} vocabulary`);
|
|
142
|
+
const rows = at(doc, 'data');
|
|
143
|
+
for (const row of Array.isArray(rows) ? rows : []) {
|
|
144
|
+
const name = at(row, 'attributes', 'name');
|
|
145
|
+
const id = isRecord(row) ? row.id : undefined;
|
|
146
|
+
if (typeof name !== 'string' || typeof id !== 'string')
|
|
147
|
+
continue;
|
|
148
|
+
const candidate = {
|
|
149
|
+
id,
|
|
150
|
+
scopeId: ref.scopeField === undefined
|
|
151
|
+
? undefined
|
|
152
|
+
: relationshipId(row, ref.scopeField),
|
|
153
|
+
};
|
|
154
|
+
const bucket = byName.get(name);
|
|
155
|
+
if (bucket === undefined)
|
|
156
|
+
byName.set(name, [candidate]);
|
|
157
|
+
else if (!bucket.some((c) => c.id === id))
|
|
158
|
+
bucket.push(candidate);
|
|
159
|
+
}
|
|
160
|
+
const next = at(doc, 'links', 'next', 'href');
|
|
161
|
+
if (typeof next !== 'string')
|
|
162
|
+
return byName;
|
|
163
|
+
url = next;
|
|
164
|
+
}
|
|
165
|
+
throw new Error(`the ${ref.path} collection did not end within ${MAX_PAGES} pages of ` +
|
|
166
|
+
`${PAGE_LIMIT} (Drupal clamps page[limit] to OffsetPage::SIZE_MAX = ` +
|
|
167
|
+
`${PAGE_LIMIT}, so the ceiling is ${MAX_ROWS} rows); refusing to resolve ` +
|
|
168
|
+
`against a truncated list`);
|
|
169
|
+
}
|
|
170
|
+
/** Read the scope off the entity being updated — the fallback when the PATCH omits it. */
|
|
171
|
+
async function fetchScope(req, ctx, scopeField) {
|
|
172
|
+
const doc = await fetchDoc(ctx, req.url, `the entity being updated`);
|
|
173
|
+
return relationshipId(at(doc, 'data'), scopeField);
|
|
174
|
+
}
|
|
175
|
+
/** The known-names list an error may print — bounded, and honest about the total. */
|
|
176
|
+
function knownNames(byName) {
|
|
177
|
+
const names = [...byName.keys()];
|
|
178
|
+
if (names.length === 0)
|
|
179
|
+
return '(none)';
|
|
180
|
+
if (names.length <= NAME_LIST_CAP)
|
|
181
|
+
return names.join(', ');
|
|
182
|
+
return `${names.slice(0, NAME_LIST_CAP).join(', ')}, … (${names.length} in total)`;
|
|
183
|
+
}
|
|
184
|
+
function describe(candidates) {
|
|
185
|
+
return candidates
|
|
186
|
+
.map((c) => (c.scopeId === undefined ? c.id : `${c.id} (${c.scopeId})`))
|
|
187
|
+
.join(', ');
|
|
188
|
+
}
|
|
189
|
+
export function nameRefsPlugin() {
|
|
190
|
+
// Per-instance, not module-level: one CLI invocation performs one write, so
|
|
191
|
+
// these guard against a second lookup, and they keep tests isolated.
|
|
192
|
+
const indexes = new Map();
|
|
193
|
+
const scopes = new Map();
|
|
194
|
+
/**
|
|
195
|
+
* Which synthetic fields each bundle was offered, recorded as
|
|
196
|
+
* `extendOperationSchema` runs. `alterRequest` gets no schema — `RequestContext`
|
|
197
|
+
* carries only the entity type, the bundle and the operation — so this is the
|
|
198
|
+
* only way it can know that a `label_names` reaching a `gaia_comment` write is
|
|
199
|
+
* not a field that bundle has.
|
|
200
|
+
*
|
|
201
|
+
* ABSENCE PROVES NOTHING. dropsh disk-caches the extended schema and skips the
|
|
202
|
+
* hook on a cache hit, so an empty record is the normal case for a repeat
|
|
203
|
+
* write, not evidence that the bundle declares nothing. The check below is
|
|
204
|
+
* therefore a NEGATIVE one only: refuse when this instance has positive
|
|
205
|
+
* evidence to the contrary, never merely because it has none.
|
|
206
|
+
*/
|
|
207
|
+
const offered = new Map();
|
|
208
|
+
const bundleKey = (entityType, bundle) => `${entityType ?? ''}/${bundle ?? ''}`;
|
|
209
|
+
return {
|
|
210
|
+
id: 'gaia-name-refs',
|
|
211
|
+
requiredModules: [],
|
|
212
|
+
async extendOperationSchema(entityType, bundle, _operation, schema) {
|
|
213
|
+
if (!isRecord(schema))
|
|
214
|
+
return schema;
|
|
215
|
+
const matching = RESOLVERS.filter((ref) => declares(schema, ref));
|
|
216
|
+
// Recorded even when nothing matched — "this bundle was offered nothing"
|
|
217
|
+
// is exactly the fact `alterRequest` needs.
|
|
218
|
+
offered.set(bundleKey(entityType, bundle), new Set(matching.map((ref) => ref.field)));
|
|
219
|
+
if (matching.length === 0)
|
|
220
|
+
return schema;
|
|
221
|
+
const data = at(schema, 'properties', 'data');
|
|
222
|
+
const attributes = at(data, 'properties', 'attributes');
|
|
223
|
+
if (!isRecord(data) || !isRecord(attributes))
|
|
224
|
+
return schema;
|
|
225
|
+
const synthetic = {};
|
|
226
|
+
for (const ref of matching) {
|
|
227
|
+
synthetic[ref.field] = { type: 'string', title: ref.title };
|
|
228
|
+
}
|
|
229
|
+
return {
|
|
230
|
+
...schema,
|
|
231
|
+
properties: {
|
|
232
|
+
...(isRecord(schema.properties) ? schema.properties : {}),
|
|
233
|
+
data: {
|
|
234
|
+
...data,
|
|
235
|
+
properties: {
|
|
236
|
+
...(isRecord(data.properties) ? data.properties : {}),
|
|
237
|
+
attributes: {
|
|
238
|
+
...attributes,
|
|
239
|
+
properties: {
|
|
240
|
+
...(isRecord(attributes.properties)
|
|
241
|
+
? attributes.properties
|
|
242
|
+
: {}),
|
|
243
|
+
...synthetic,
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
};
|
|
250
|
+
},
|
|
251
|
+
async alterRequest(req, ctx) {
|
|
252
|
+
if (ctx.operation !== 'create' && ctx.operation !== 'update')
|
|
253
|
+
return req;
|
|
254
|
+
if (typeof req.body !== 'string' || req.body === '')
|
|
255
|
+
return req;
|
|
256
|
+
let doc;
|
|
257
|
+
try {
|
|
258
|
+
doc = JSON.parse(req.body);
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
return req;
|
|
262
|
+
}
|
|
263
|
+
const data = at(doc, 'data');
|
|
264
|
+
const attributes = at(data, 'attributes');
|
|
265
|
+
if (!isRecord(doc) || !isRecord(data) || !isRecord(attributes))
|
|
266
|
+
return req;
|
|
267
|
+
const active = RESOLVERS.filter((ref) => attributes[ref.field] !== undefined);
|
|
268
|
+
if (active.length === 0)
|
|
269
|
+
return req;
|
|
270
|
+
const nextAttributes = { ...attributes };
|
|
271
|
+
const relationships = isRecord(data.relationships)
|
|
272
|
+
? { ...data.relationships }
|
|
273
|
+
: {};
|
|
274
|
+
const declaredHere = offered.get(bundleKey(ctx.entityType, ctx.bundle));
|
|
275
|
+
for (const ref of active) {
|
|
276
|
+
// A name field on a bundle that has no such relationship. Reachable by
|
|
277
|
+
// a hand-built body or `--no-validate`, since the flag itself only
|
|
278
|
+
// exists where the schema was extended. Inventing the relationship here
|
|
279
|
+
// would send a document the server can only answer 422 to, naming a
|
|
280
|
+
// field the caller never wrote.
|
|
281
|
+
if (declaredHere !== undefined && !declaredHere.has(ref.field)) {
|
|
282
|
+
throw new Error(`${ctx.entityType}/${ctx.bundle} declares no '${ref.relationship}' ` +
|
|
283
|
+
`relationship, so '${ref.field}' cannot be resolved on this write. ` +
|
|
284
|
+
`Remove it, or write to a bundle that has ${ref.relationship}.`);
|
|
285
|
+
}
|
|
286
|
+
delete nextAttributes[ref.field];
|
|
287
|
+
const names = String(attributes[ref.field])
|
|
288
|
+
.split(',')
|
|
289
|
+
.map((name) => name.trim())
|
|
290
|
+
.filter((name) => name.length > 0);
|
|
291
|
+
if (names.length === 0)
|
|
292
|
+
continue;
|
|
293
|
+
let index = indexes.get(ref.field);
|
|
294
|
+
if (index === undefined) {
|
|
295
|
+
index = fetchIndex(ctx, ref);
|
|
296
|
+
indexes.set(ref.field, index);
|
|
297
|
+
}
|
|
298
|
+
const byName = await index;
|
|
299
|
+
const scopeField = ref.scopeField;
|
|
300
|
+
// The scope the payload already carries. Free — no round trip — so it
|
|
301
|
+
// narrows EVERY name, including an unambiguous one: a name that is
|
|
302
|
+
// unique across the whole collection can still belong to a different
|
|
303
|
+
// parent than the entity being written, and attaching it silently would
|
|
304
|
+
// be worse than the round trip we are saving.
|
|
305
|
+
const payloadScope = scopeField === undefined
|
|
306
|
+
? undefined
|
|
307
|
+
: relationshipId(data, scopeField);
|
|
308
|
+
const resolved = [];
|
|
309
|
+
for (const name of names) {
|
|
310
|
+
const candidates = byName.get(name) ?? [];
|
|
311
|
+
const [only, ...rivals] = candidates;
|
|
312
|
+
if (only === undefined) {
|
|
313
|
+
throw new Error(`${ref.noun} name '${name}' does not exist in the ${ref.path} ` +
|
|
314
|
+
`vocabulary. Known names: ${knownNames(byName)}`);
|
|
315
|
+
}
|
|
316
|
+
if (scopeField === undefined) {
|
|
317
|
+
if (rivals.length > 0) {
|
|
318
|
+
throw new Error(`${ref.noun} name '${name}' is ambiguous — ${candidates.length} ` +
|
|
319
|
+
`entries in ${ref.path} share it. Candidates: ${describe(candidates)}. ` +
|
|
320
|
+
`Pass the uuid directly on the ${ref.relationship} relationship instead.`);
|
|
321
|
+
}
|
|
322
|
+
resolved.push(only.id);
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
// A scoped resolver ALWAYS establishes a scope — including for a name
|
|
326
|
+
// that is unique across the whole collection today. Uniqueness is a
|
|
327
|
+
// property of the current rows, not of the name: `staging` is unique
|
|
328
|
+
// right up to the moment a second project creates one, and until then
|
|
329
|
+
// an unscoped write would attach another project's environment and
|
|
330
|
+
// report success. So when the payload carries no scope, read it off
|
|
331
|
+
// the entity being updated (memoised: one read per write, however many
|
|
332
|
+
// names were given). A create has no entity yet, hence `undefined`.
|
|
333
|
+
let scopeId = payloadScope;
|
|
334
|
+
if (scopeId === undefined) {
|
|
335
|
+
let pending = scopes.get(scopeField);
|
|
336
|
+
if (pending === undefined) {
|
|
337
|
+
pending =
|
|
338
|
+
ctx.operation === 'update'
|
|
339
|
+
? fetchScope(req, ctx, scopeField)
|
|
340
|
+
: Promise.resolve(undefined);
|
|
341
|
+
scopes.set(scopeField, pending);
|
|
342
|
+
}
|
|
343
|
+
scopeId = await pending;
|
|
344
|
+
}
|
|
345
|
+
// No scope at all is a hard error, never a fallthrough to "the only
|
|
346
|
+
// one there is". Refusing costs a caller one explicit uuid; guessing
|
|
347
|
+
// costs them a cross-project attachment they never see.
|
|
348
|
+
if (scopeId === undefined) {
|
|
349
|
+
throw new Error(rivals.length > 0
|
|
350
|
+
? `${ref.noun} name '${name}' is ambiguous — ${candidates.length} ` +
|
|
351
|
+
`entries in ${ref.path} share it and the write carries no ` +
|
|
352
|
+
`${scopeField} to narrow them. Candidates: ${describe(candidates)}. ` +
|
|
353
|
+
`Pass the uuid directly on the ${ref.relationship} relationship instead.`
|
|
354
|
+
: `${ref.noun} name '${name}' cannot be resolved: ${ref.path} names are ` +
|
|
355
|
+
`unique only within ${scopeField}, and this write carries no ` +
|
|
356
|
+
`${scopeField} to narrow it — being the only '${name}' that exists ` +
|
|
357
|
+
`today does not make it this write's. Candidate: ${describe(candidates)}. ` +
|
|
358
|
+
`Name the ${scopeField}, or pass the uuid directly on the ` +
|
|
359
|
+
`${ref.relationship} relationship instead.`);
|
|
360
|
+
}
|
|
361
|
+
const [match, ...extra] = candidates.filter((c) => c.scopeId === scopeId);
|
|
362
|
+
if (match === undefined) {
|
|
363
|
+
const elsewhere = candidates
|
|
364
|
+
.map((c) => c.scopeId ?? '(none)')
|
|
365
|
+
.join(', ');
|
|
366
|
+
throw new Error(`${ref.noun} name '${name}' does not exist in ${scopeField} ` +
|
|
367
|
+
`'${scopeId}'. It exists in: ${elsewhere}.`);
|
|
368
|
+
}
|
|
369
|
+
if (extra.length > 0) {
|
|
370
|
+
throw new Error(`${ref.noun} name '${name}' is ambiguous even within ${scopeField} ` +
|
|
371
|
+
`'${scopeId}': ${describe([match, ...extra])}. Pass the uuid directly ` +
|
|
372
|
+
`on the ${ref.relationship} relationship instead.`);
|
|
373
|
+
}
|
|
374
|
+
resolved.push(match.id);
|
|
375
|
+
}
|
|
376
|
+
const current = at(relationships, ref.relationship, 'data');
|
|
377
|
+
const existing = Array.isArray(current)
|
|
378
|
+
? current
|
|
379
|
+
: current !== undefined
|
|
380
|
+
? [current]
|
|
381
|
+
: [];
|
|
382
|
+
const merged = [];
|
|
383
|
+
const seen = new Set();
|
|
384
|
+
for (const entry of [
|
|
385
|
+
...existing,
|
|
386
|
+
...resolved.map((id) => ({ type: ref.type, id })),
|
|
387
|
+
]) {
|
|
388
|
+
const id = isRecord(entry) ? entry.id : undefined;
|
|
389
|
+
if (typeof id !== 'string' || seen.has(id))
|
|
390
|
+
continue;
|
|
391
|
+
seen.add(id);
|
|
392
|
+
merged.push(entry);
|
|
393
|
+
}
|
|
394
|
+
if (merged.length > 0) {
|
|
395
|
+
relationships[ref.relationship] = { data: merged };
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
const nextData = { ...data };
|
|
399
|
+
if (Object.keys(nextAttributes).length > 0)
|
|
400
|
+
nextData.attributes = nextAttributes;
|
|
401
|
+
else
|
|
402
|
+
delete nextData.attributes;
|
|
403
|
+
if (Object.keys(relationships).length > 0)
|
|
404
|
+
nextData.relationships = relationships;
|
|
405
|
+
return { ...req, body: JSON.stringify({ ...doc, data: nextData }) };
|
|
406
|
+
},
|
|
407
|
+
};
|
|
408
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@gaia-ai/addon-name-refs",
|
|
3
|
+
"version": "0.11.1",
|
|
4
|
+
"description": "GAIA connection addon: resolve entity NAMES (--label_names, --environment_names) into the relationships that reference them, on the live write, via dropsh's extendOperationSchema and alterRequest hooks.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./dist/src/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist/src"
|
|
12
|
+
],
|
|
13
|
+
"publishConfig": {
|
|
14
|
+
"access": "public"
|
|
15
|
+
},
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://git.key-tec.de/keytec/gaia.git",
|
|
19
|
+
"directory": "gaia-cli/addons/name-refs"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"dropsh": "^0.6.1"
|
|
23
|
+
},
|
|
24
|
+
"peerDependencies": {
|
|
25
|
+
"@gaia-ai/core": "^0.11.1"
|
|
26
|
+
}
|
|
27
|
+
}
|