@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 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,2 @@
1
+ import type { DropSHPlugin } from 'dropsh/plugin';
2
+ export declare function nameRefsPlugin(): DropSHPlugin;
@@ -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
+ }