@bakers-build/addon-name-refs 0.0.0-stage → 0.1.0-next.38

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 CHANGED
@@ -1,3 +1,5 @@
1
- # Temporary Holding Version
1
+ # @bakers-build/addon-name-refs
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ bakers.build 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 bakers.build CLI. Install the meta package `@bakers-build/bakers` to get the `bakers` CLI with all addons. Source: https://git.key-tec.de/bakers-build/bakers-build (bakers-cli/).
@@ -0,0 +1,2 @@
1
+ import type { DropSHPlugin } from 'dropsh/plugin';
2
+ export declare function nameRefsPlugin(): DropSHPlugin;
@@ -0,0 +1,555 @@
1
+ // BB-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, `@bakers-build/addon-name-refs`, carried as a
17
+ // child of `@bakers-build/addon-essentials` exactly as `@bakers-build/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: `bb_term` names are, so a name
24
+ // identifies a term outright. `bb_environment` names are unique only WITHIN A
25
+ // PROJECT (BbEnvironmentUniquePerProject), 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: 'bb_term--bb_labels',
34
+ path: 'bb_term/bb_labels',
35
+ title: 'Label names (comma-separated bb_labels names; ' +
36
+ 'unique only within workspace_id — resolved to labels on the live write)',
37
+ // BB-531: bb_term names are unique within (workspace_id, vid), so the
38
+ // same name legitimately exists in several workspaces and a bare index
39
+ // would silently pick one. The workspace is reached from the payload via
40
+ // the chain project_id → group_id → workspace_id.
41
+ scopeField: 'workspace_id',
42
+ scopeChain: 'project_id.group_id.workspace_id',
43
+ },
44
+ {
45
+ field: 'environment_names',
46
+ noun: 'environment',
47
+ relationship: 'environments',
48
+ type: 'bb_environment--bb_environment',
49
+ path: 'bb_environment/bb_environment',
50
+ title: 'Environment names (comma-separated; unique per project, so an ' +
51
+ 'ambiguous name is narrowed by the project_id of the write — ' +
52
+ 'resolved to environments on the live write)',
53
+ scopeField: 'project_id',
54
+ },
55
+ ];
56
+ /**
57
+ * page[limit] bounds the QUERY, not the row count — links.next is the only end
58
+ * signal. Asking for more than Drupal's `OffsetPage::SIZE_MAX` is not an error,
59
+ * it is silently clamped to it, so requesting 200 bought nothing and made the
60
+ * ceiling below misreport itself by 4x. 50 is the number the server will
61
+ * actually serve.
62
+ */
63
+ const PAGE_LIMIT = 50;
64
+ const MAX_PAGES = 10;
65
+ /** Rows this resolver will walk before it refuses to resolve at all. */
66
+ const MAX_ROWS = PAGE_LIMIT * MAX_PAGES;
67
+ /**
68
+ * How many known names an error may list. `bb_labels` is small; a future
69
+ * target need not be, and an error nobody can read is an error nobody reads.
70
+ */
71
+ const NAME_LIST_CAP = 20;
72
+ function isRecord(value) {
73
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
74
+ }
75
+ function at(node, ...path) {
76
+ let cursor = node;
77
+ for (const key of path) {
78
+ if (!isRecord(cursor))
79
+ return undefined;
80
+ cursor = cursor[key];
81
+ }
82
+ return cursor;
83
+ }
84
+ /** The id a relationship points at, for the single-valued relationships used as scopes. */
85
+ function relationshipId(node, field) {
86
+ const id = at(node, 'relationships', field, 'data', 'id');
87
+ return typeof id === 'string' ? id : undefined;
88
+ }
89
+ /**
90
+ * The type a relationship points at. Needed to look the related entity up in
91
+ * `included[*]`, which is keyed by (type, id) in JSON:API.
92
+ */
93
+ function relationshipType(node, field) {
94
+ const type = at(node, 'relationships', field, 'data', 'type');
95
+ return typeof type === 'string' ? type : undefined;
96
+ }
97
+ /** `'bb_project--bb_project'` → `'bb_project/bb_project'`. */
98
+ function typeToPath(type) {
99
+ return type.replace('--', '/');
100
+ }
101
+ /** Find a related entity in `included[*]` by (type, id). */
102
+ function findInIncluded(doc, type, id) {
103
+ if (type === undefined)
104
+ return undefined;
105
+ const included = at(doc, 'included');
106
+ if (!Array.isArray(included))
107
+ return undefined;
108
+ return included.find((entry) => isRecord(entry) && entry.type === type && entry.id === id);
109
+ }
110
+ /**
111
+ * Walk a chain of relationships through `included[*]`, starting from
112
+ * `doc.data`. Each segment names a relationship on the current node whose
113
+ * target entity is the next node. The final segment's `data.id` is returned.
114
+ *
115
+ * The first segment is allowed to be absent on the starting node: an UPDATE
116
+ * that fetches its own entity may receive a document whose `data` is already
117
+ * the first entity in the chain (the project, not the ticket that points at
118
+ * it), and the walk must skip the first hop rather than fail.
119
+ */
120
+ function walkChain(doc, segments) {
121
+ let node = at(doc, 'data');
122
+ if (!isRecord(node))
123
+ return undefined;
124
+ let skippedFirst = false;
125
+ for (let i = 0; i < segments.length; i += 1) {
126
+ const segment = segments[i];
127
+ if (segment === undefined)
128
+ return undefined;
129
+ const nextId = relationshipId(node, segment);
130
+ if (nextId === undefined) {
131
+ if (i === 0 && !skippedFirst) {
132
+ skippedFirst = true;
133
+ continue;
134
+ }
135
+ return undefined;
136
+ }
137
+ if (i === segments.length - 1)
138
+ return nextId;
139
+ const nextType = relationshipType(node, segment);
140
+ const nextNode = findInIncluded(doc, nextType, nextId);
141
+ if (!isRecord(nextNode))
142
+ return undefined;
143
+ node = nextNode;
144
+ }
145
+ return undefined;
146
+ }
147
+ /**
148
+ * Resolve the chain scope uuid. For a CREATE the first segment is read off
149
+ * the payload and the rest is fetched via `?include=<remaining>`; for an
150
+ * UPDATE the entity itself is fetched with the full chain as include, so a
151
+ * single round trip resolves both the payload-side scope and the chain.
152
+ *
153
+ * Returns `undefined` when the chain cannot be followed — a missing project,
154
+ * a broken chain, or no `project_id` anywhere the resolver can read. The
155
+ * caller turns that into a hard error rather than guessing a uuid.
156
+ */
157
+ async function resolveChainScope(req, ctx, ref, data) {
158
+ if (ref.scopeChain === undefined)
159
+ return undefined;
160
+ const segments = ref.scopeChain.split('.');
161
+ const prefix = normalisePrefix(ctx.jsonapiPrefix);
162
+ const base = ctx.baseUrl.replace(/\/+$/, '');
163
+ if (ctx.operation === 'create') {
164
+ const firstSegment = segments[0];
165
+ if (firstSegment === undefined)
166
+ return undefined;
167
+ const firstId = relationshipId(data, firstSegment);
168
+ if (firstId === undefined)
169
+ return undefined;
170
+ const firstType = relationshipType(data, firstSegment);
171
+ if (firstType === undefined)
172
+ return undefined;
173
+ const firstPath = typeToPath(firstType);
174
+ const remaining = segments.slice(1).join('.');
175
+ const url = `${base}${prefix}/${firstPath}/${firstId}` +
176
+ (remaining === '' ? '' : `?include=${remaining}`);
177
+ const doc = await fetchDoc(ctx, url, `the ${firstPath} entity`);
178
+ return walkChain(doc, segments.slice(1));
179
+ }
180
+ const includeParam = segments.join('.');
181
+ const separator = req.url.includes('?') ? '&' : '?';
182
+ const url = `${req.url}${separator}include=${includeParam}`;
183
+ const doc = await fetchDoc(ctx, url, 'the entity being updated');
184
+ return walkChain(doc, segments);
185
+ }
186
+ /**
187
+ * Does this operation schema declare this resolver's relationship over its
188
+ * target type? Only the multi-valued (`items`) shape is recognised: both
189
+ * targets are unlimited-cardinality, and the merge below writes an array, so
190
+ * matching a single-valued relationship here would produce an invalid payload.
191
+ */
192
+ function declares(schema, ref) {
193
+ const targets = at(schema, 'properties', 'data', 'properties', 'relationships', 'properties', ref.relationship, 'properties', 'data', 'items', 'properties', 'type', 'enum');
194
+ return Array.isArray(targets) && targets.includes(ref.type);
195
+ }
196
+ /** `/jsonapi`, however the config spelled it — with, without, or with a trailing slash. */
197
+ function normalisePrefix(raw) {
198
+ const trimmed = (raw ?? '/jsonapi').replace(/^\/+/, '').replace(/\/+$/, '');
199
+ return trimmed === '' ? '' : `/${trimmed}`;
200
+ }
201
+ /**
202
+ * GET one JSON:API document, or fail by name.
203
+ *
204
+ * This plugin issues its own reads through `ctx.http` / `ctx.auth`, which is the
205
+ * only seam the plugin API offers — and it is NOT the seam dropsh's JSON:API
206
+ * client uses. That client catches a 401 and calls `auth.renew()` once
207
+ * (`client.js:44-47`); nothing here can, because `AuthAdapter.apply` exposes no
208
+ * renewal. So an expired token cannot be refreshed transparently mid-write: it
209
+ * arrives here as a plain non-2xx, and the least this can do is say so instead
210
+ * of handing an HTML error page to `JSON.parse`.
211
+ */
212
+ async function fetchDoc(ctx, url, what) {
213
+ const res = await ctx.http.send(await ctx.auth.apply({
214
+ method: 'GET',
215
+ url,
216
+ headers: { Accept: 'application/vnd.api+json' },
217
+ }));
218
+ if (res.status < 200 || res.status >= 300) {
219
+ const expiry = res.status === 401 || res.status === 403
220
+ ? ' The access token may have expired: this plugin performs its own reads ' +
221
+ 'and, unlike dropsh’s JSON:API client, cannot renew one, so an expired ' +
222
+ 'token surfaces here instead of being refreshed. Re-authenticate and retry.'
223
+ : '';
224
+ throw new Error(`could not read ${what}: HTTP ${res.status} from ${url}.${expiry}`);
225
+ }
226
+ try {
227
+ return JSON.parse(res.body);
228
+ }
229
+ catch (cause) {
230
+ throw new Error(`could not read ${what}: ${url} answered ${res.status} but not JSON (${String(cause)})`);
231
+ }
232
+ }
233
+ /**
234
+ * The whole collection as name → candidates. One round trip regardless of how
235
+ * many names were given, and it yields the known-names list the error needs.
236
+ */
237
+ async function fetchIndex(ctx, ref) {
238
+ const prefix = normalisePrefix(ctx.jsonapiPrefix);
239
+ const fields = ref.scopeField === undefined ? 'name' : `name,${ref.scopeField}`;
240
+ let url = `${ctx.baseUrl.replace(/\/+$/, '')}${prefix}/${ref.path}` +
241
+ `?fields[${ref.type}]=${fields}&sort=name&page[limit]=${PAGE_LIMIT}`;
242
+ const byName = new Map();
243
+ for (let page = 0; page < MAX_PAGES; page += 1) {
244
+ const doc = await fetchDoc(ctx, url, `the ${ref.path} vocabulary`);
245
+ const rows = at(doc, 'data');
246
+ for (const row of Array.isArray(rows) ? rows : []) {
247
+ const name = at(row, 'attributes', 'name');
248
+ const id = isRecord(row) ? row.id : undefined;
249
+ if (typeof name !== 'string' || typeof id !== 'string')
250
+ continue;
251
+ const candidate = {
252
+ id,
253
+ scopeId: ref.scopeField === undefined
254
+ ? undefined
255
+ : relationshipId(row, ref.scopeField),
256
+ };
257
+ const bucket = byName.get(name);
258
+ if (bucket === undefined)
259
+ byName.set(name, [candidate]);
260
+ else if (!bucket.some((c) => c.id === id))
261
+ bucket.push(candidate);
262
+ }
263
+ const next = at(doc, 'links', 'next', 'href');
264
+ if (typeof next !== 'string')
265
+ return byName;
266
+ url = next;
267
+ }
268
+ throw new Error(`the ${ref.path} collection did not end within ${MAX_PAGES} pages of ` +
269
+ `${PAGE_LIMIT} (Drupal clamps page[limit] to OffsetPage::SIZE_MAX = ` +
270
+ `${PAGE_LIMIT}, so the ceiling is ${MAX_ROWS} rows); refusing to resolve ` +
271
+ `against a truncated list`);
272
+ }
273
+ /** Read the scope off the entity being updated — the fallback when the PATCH omits it. */
274
+ async function fetchScope(req, ctx, scopeField) {
275
+ const doc = await fetchDoc(ctx, req.url, `the entity being updated`);
276
+ return relationshipId(at(doc, 'data'), scopeField);
277
+ }
278
+ /** The known-names list an error may print — bounded, and honest about the total. */
279
+ function knownNames(byName) {
280
+ const names = [...byName.keys()];
281
+ if (names.length === 0)
282
+ return '(none)';
283
+ if (names.length <= NAME_LIST_CAP)
284
+ return names.join(', ');
285
+ return `${names.slice(0, NAME_LIST_CAP).join(', ')}, … (${names.length} in total)`;
286
+ }
287
+ function describe(candidates) {
288
+ return candidates
289
+ .map((c) => (c.scopeId === undefined ? c.id : `${c.id} (${c.scopeId})`))
290
+ .join(', ');
291
+ }
292
+ export function nameRefsPlugin() {
293
+ // Per-instance, not module-level: one CLI invocation performs one write, so
294
+ // these guard against a second lookup, and they keep tests isolated.
295
+ const indexes = new Map();
296
+ const scopes = new Map();
297
+ /**
298
+ * Chain-scope resolutions, keyed by the resolver field (not by `scopeField`):
299
+ * several resolvers could share a scopeField but differ in their chain, and
300
+ * a chain resolution is a round trip we want to pay once per write.
301
+ */
302
+ const chainScopes = new Map();
303
+ /**
304
+ * Which synthetic fields each bundle was offered, recorded as
305
+ * `extendOperationSchema` runs. `alterRequest` gets no schema — `RequestContext`
306
+ * carries only the entity type, the bundle and the operation — so this is the
307
+ * only way it can know that a `label_names` reaching a `bb_comment` write is
308
+ * not a field that bundle has.
309
+ *
310
+ * ABSENCE PROVES NOTHING. dropsh disk-caches the extended schema and skips the
311
+ * hook on a cache hit, so an empty record is the normal case for a repeat
312
+ * write, not evidence that the bundle declares nothing. The check below is
313
+ * therefore a NEGATIVE one only: refuse when this instance has positive
314
+ * evidence to the contrary, never merely because it has none.
315
+ */
316
+ const offered = new Map();
317
+ const bundleKey = (entityType, bundle) => `${entityType ?? ''}/${bundle ?? ''}`;
318
+ return {
319
+ id: 'bakers-name-refs',
320
+ requiredModules: [],
321
+ async extendOperationSchema(entityType, bundle, _operation, schema) {
322
+ if (!isRecord(schema))
323
+ return schema;
324
+ const matching = RESOLVERS.filter((ref) => declares(schema, ref));
325
+ // Recorded even when nothing matched — "this bundle was offered nothing"
326
+ // is exactly the fact `alterRequest` needs.
327
+ offered.set(bundleKey(entityType, bundle), new Set(matching.map((ref) => ref.field)));
328
+ if (matching.length === 0)
329
+ return schema;
330
+ const data = at(schema, 'properties', 'data');
331
+ const attributes = at(data, 'properties', 'attributes');
332
+ if (!isRecord(data) || !isRecord(attributes))
333
+ return schema;
334
+ const synthetic = {};
335
+ for (const ref of matching) {
336
+ synthetic[ref.field] = { type: 'string', title: ref.title };
337
+ }
338
+ return {
339
+ ...schema,
340
+ properties: {
341
+ ...(isRecord(schema.properties) ? schema.properties : {}),
342
+ data: {
343
+ ...data,
344
+ properties: {
345
+ ...(isRecord(data.properties) ? data.properties : {}),
346
+ attributes: {
347
+ ...attributes,
348
+ properties: {
349
+ ...(isRecord(attributes.properties)
350
+ ? attributes.properties
351
+ : {}),
352
+ ...synthetic,
353
+ },
354
+ },
355
+ },
356
+ },
357
+ },
358
+ };
359
+ },
360
+ async alterRequest(req, ctx) {
361
+ if (ctx.operation !== 'create' && ctx.operation !== 'update')
362
+ return req;
363
+ if (typeof req.body !== 'string' || req.body === '')
364
+ return req;
365
+ let doc;
366
+ try {
367
+ doc = JSON.parse(req.body);
368
+ }
369
+ catch {
370
+ return req;
371
+ }
372
+ const data = at(doc, 'data');
373
+ const attributes = at(data, 'attributes');
374
+ if (!isRecord(doc) || !isRecord(data) || !isRecord(attributes))
375
+ return req;
376
+ const active = RESOLVERS.filter((ref) => attributes[ref.field] !== undefined);
377
+ if (active.length === 0)
378
+ return req;
379
+ const nextAttributes = { ...attributes };
380
+ const relationships = isRecord(data.relationships)
381
+ ? { ...data.relationships }
382
+ : {};
383
+ const declaredHere = offered.get(bundleKey(ctx.entityType, ctx.bundle));
384
+ for (const ref of active) {
385
+ // A name field on a bundle that has no such relationship. Reachable by
386
+ // a hand-built body or `--no-validate`, since the flag itself only
387
+ // exists where the schema was extended. Inventing the relationship here
388
+ // would send a document the server can only answer 422 to, naming a
389
+ // field the caller never wrote.
390
+ if (declaredHere !== undefined && !declaredHere.has(ref.field)) {
391
+ throw new Error(`${ctx.entityType}/${ctx.bundle} declares no '${ref.relationship}' ` +
392
+ `relationship, so '${ref.field}' cannot be resolved on this write. ` +
393
+ `Remove it, or write to a bundle that has ${ref.relationship}.`);
394
+ }
395
+ delete nextAttributes[ref.field];
396
+ const names = String(attributes[ref.field])
397
+ .split(',')
398
+ .map((name) => name.trim())
399
+ .filter((name) => name.length > 0);
400
+ if (names.length === 0)
401
+ continue;
402
+ let index = indexes.get(ref.field);
403
+ if (index === undefined) {
404
+ index = fetchIndex(ctx, ref);
405
+ indexes.set(ref.field, index);
406
+ }
407
+ const byName = await index;
408
+ const scopeField = ref.scopeField;
409
+ // The scope the payload already carries. Free — no round trip — so it
410
+ // narrows EVERY name, including an unambiguous one: a name that is
411
+ // unique across the whole collection can still belong to a different
412
+ // parent than the entity being written, and attaching it silently would
413
+ // be worse than the round trip we are saving. This shortcut only
414
+ // applies when the payload carries the SAME relationship the target's
415
+ // `scopeField` names; a chain resolver reaches the scope via a fetch
416
+ // and never gets a free read here.
417
+ const payloadScope = scopeField === undefined || ref.scopeChain !== undefined
418
+ ? undefined
419
+ : relationshipId(data, scopeField);
420
+ const resolved = [];
421
+ for (const name of names) {
422
+ const candidates = byName.get(name) ?? [];
423
+ const [only, ...rivals] = candidates;
424
+ if (only === undefined) {
425
+ throw new Error(`${ref.noun} name '${name}' does not exist in the ${ref.path} ` +
426
+ `vocabulary. Known names: ${knownNames(byName)}`);
427
+ }
428
+ if (scopeField === undefined) {
429
+ if (rivals.length > 0) {
430
+ throw new Error(`${ref.noun} name '${name}' is ambiguous — ${candidates.length} ` +
431
+ `entries in ${ref.path} share it. Candidates: ${describe(candidates)}. ` +
432
+ `Pass the uuid directly on the ${ref.relationship} relationship instead.`);
433
+ }
434
+ resolved.push(only.id);
435
+ continue;
436
+ }
437
+ // A scoped resolver ALWAYS establishes a scope — including for a name
438
+ // that is unique across the whole collection today. Uniqueness is a
439
+ // property of the current rows, not of the name: `staging` is unique
440
+ // right up to the moment a second project creates one, and until then
441
+ // an unscoped write would attach another project's environment and
442
+ // report success. A chain resolver reaches the scope via a single
443
+ // fetch (project + group + workspace in one round trip); a direct
444
+ // resolver reads the scope off the entity being updated (also
445
+ // memoised: one read per write, however many names were given). A
446
+ // create has no entity yet, hence `undefined`.
447
+ //
448
+ // A chain resolver fetches ONLY when there is something to narrow
449
+ // (multiple candidates). A single candidate needs no workspace check
450
+ // and skips the round trip — the unscoped-path fallback below
451
+ // resolves it directly. That keeps a write with one label and a
452
+ // `project_id` on the payload at one URL per resolver, matching the
453
+ // pre-fix cost.
454
+ let scopeId;
455
+ if (ref.scopeChain !== undefined) {
456
+ if (rivals.length > 0) {
457
+ let pending = chainScopes.get(ref.field);
458
+ if (pending === undefined) {
459
+ pending = resolveChainScope(req, ctx, ref, data);
460
+ chainScopes.set(ref.field, pending);
461
+ }
462
+ scopeId = await pending;
463
+ }
464
+ }
465
+ else {
466
+ scopeId = payloadScope;
467
+ if (scopeId === undefined) {
468
+ let pending = scopes.get(scopeField);
469
+ if (pending === undefined) {
470
+ pending =
471
+ ctx.operation === 'update'
472
+ ? fetchScope(req, ctx, scopeField)
473
+ : Promise.resolve(undefined);
474
+ scopes.set(scopeField, pending);
475
+ }
476
+ scopeId = await pending;
477
+ }
478
+ }
479
+ // No scope at all is a hard error for a direct resolver (an
480
+ // environment name must belong to a project — never guess). A
481
+ // chain resolver falls back to the unscoped path instead: when no
482
+ // `project_id` can be derived, a single candidate resolves
483
+ // (backward-compatible with pre-fix writes) and multiple
484
+ // candidates raise the same "ambiguous" error an unscoped resolver
485
+ // would, with the candidate workspaces named via `describe()`.
486
+ if (scopeId === undefined) {
487
+ if (ref.scopeChain !== undefined) {
488
+ if (rivals.length > 0) {
489
+ throw new Error(`${ref.noun} name '${name}' is ambiguous — ${candidates.length} ` +
490
+ `entries in ${ref.path} share it. Candidates: ${describe(candidates)}. ` +
491
+ `Pass the uuid directly on the ${ref.relationship} relationship instead.`);
492
+ }
493
+ resolved.push(only.id);
494
+ continue;
495
+ }
496
+ throw new Error(rivals.length > 0
497
+ ? `${ref.noun} name '${name}' is ambiguous — ${candidates.length} ` +
498
+ `entries in ${ref.path} share it and the write carries no ` +
499
+ `${scopeField} to narrow them. Candidates: ${describe(candidates)}. ` +
500
+ `Pass the uuid directly on the ${ref.relationship} relationship instead.`
501
+ : `${ref.noun} name '${name}' cannot be resolved: ${ref.path} names are ` +
502
+ `unique only within ${scopeField}, and this write carries no ` +
503
+ `${scopeField} to narrow it — being the only '${name}' that exists ` +
504
+ `today does not make it this write's. Candidate: ${describe(candidates)}. ` +
505
+ `Name the ${scopeField}, or pass the uuid directly on the ` +
506
+ `${ref.relationship} relationship instead.`);
507
+ }
508
+ const [match, ...extra] = candidates.filter((c) => c.scopeId === scopeId);
509
+ if (match === undefined) {
510
+ const elsewhere = candidates
511
+ .map((c) => c.scopeId ?? '(none)')
512
+ .join(', ');
513
+ throw new Error(`${ref.noun} name '${name}' does not exist in ${scopeField} ` +
514
+ `'${scopeId}'. It exists in: ${elsewhere}.`);
515
+ }
516
+ if (extra.length > 0) {
517
+ throw new Error(`${ref.noun} name '${name}' is ambiguous even within ${scopeField} ` +
518
+ `'${scopeId}': ${describe([match, ...extra])}. Pass the uuid directly ` +
519
+ `on the ${ref.relationship} relationship instead.`);
520
+ }
521
+ resolved.push(match.id);
522
+ }
523
+ const current = at(relationships, ref.relationship, 'data');
524
+ const existing = Array.isArray(current)
525
+ ? current
526
+ : current !== undefined
527
+ ? [current]
528
+ : [];
529
+ const merged = [];
530
+ const seen = new Set();
531
+ for (const entry of [
532
+ ...existing,
533
+ ...resolved.map((id) => ({ type: ref.type, id })),
534
+ ]) {
535
+ const id = isRecord(entry) ? entry.id : undefined;
536
+ if (typeof id !== 'string' || seen.has(id))
537
+ continue;
538
+ seen.add(id);
539
+ merged.push(entry);
540
+ }
541
+ if (merged.length > 0) {
542
+ relationships[ref.relationship] = { data: merged };
543
+ }
544
+ }
545
+ const nextData = { ...data };
546
+ if (Object.keys(nextAttributes).length > 0)
547
+ nextData.attributes = nextAttributes;
548
+ else
549
+ delete nextData.attributes;
550
+ if (Object.keys(relationships).length > 0)
551
+ nextData.relationships = relationships;
552
+ return { ...req, body: JSON.stringify({ ...doc, data: nextData }) };
553
+ },
554
+ };
555
+ }
package/package.json CHANGED
@@ -1,6 +1,27 @@
1
1
  {
2
2
  "name": "@bakers-build/addon-name-refs",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0-next.38",
4
+ "description": "bakers.build 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/bakers-build/bakers-build.git",
19
+ "directory": "bakers-cli/addons/name-refs"
20
+ },
21
+ "dependencies": {
22
+ "dropsh": "^0.7.5"
23
+ },
24
+ "peerDependencies": {
25
+ "@bakers-build/core": "^0.1.0-next.38"
26
+ }
27
+ }