@bakers-build/addon-name-refs 0.1.2-next.88 → 0.2.1-next.211

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.
Files changed (2) hide show
  1. package/dist/src/index.js +200 -148
  2. package/package.json +2 -2
package/dist/src/index.js CHANGED
@@ -1,5 +1,5 @@
1
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`
2
+ // a skill writes `--type_names bug` or `--environment_names staging`
3
3
  // instead of searching the collection, pulling a UUID out with jq, and
4
4
  // hand-building the linkage.
5
5
  //
@@ -19,28 +19,31 @@
19
19
  // committed config already has is unchanged. It contributes no preset
20
20
  // accumulator, so it is a plain sibling edge in `PERMITTED_SIBLINGS`.
21
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.
22
+ // Adding a target is one RESOLVERS entry. Both kinds of name are unique only
23
+ // within a parent. `bb_environment` names are unique WITHIN A PROJECT
24
+ // (BbEnvironmentUniquePerProject), which the write carries directly — hence
25
+ // `scopeField`. `bb_term` names (ticket type, recipe, label) are unique within
26
+ // the ticket's WORKSPACE, reached through `scopeChain`; a term name missing
27
+ // there is created there, so an agent never needs a lead to add vocabulary.
28
+ /** A term resolver: workspace-scoped, created on first use. */
29
+ const term = (field, noun, relationship, vid, single) => ({
30
+ field,
31
+ noun,
32
+ relationship,
33
+ type: `bb_term--${vid}`,
34
+ path: `bb_term/${vid}`,
35
+ title: `${noun[0]?.toUpperCase()}${noun.slice(1)} names (comma-separated ${vid} term names` +
36
+ `${single ? '; one name' : ''}; resolved in the ticket's workspace and ` +
37
+ 'created there when missing, on the live write)',
38
+ scopeField: 'workspace_id',
39
+ scopeChain: 'project_id.group_id.workspace_id',
40
+ single,
41
+ });
42
+ const WORKSPACE_TYPE = 'bb_workspace--bb_workspace';
28
43
  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
+ term('type_names', 'ticket type', 'ticket_type', 'type', true),
45
+ term('recipe_names', 'recipe', 'recipe', 'recipe', true),
46
+ term('label_names', 'label', 'labels', 'label', false),
44
47
  {
45
48
  field: 'environment_names',
46
49
  noun: 'environment',
@@ -65,7 +68,7 @@ const MAX_PAGES = 10;
65
68
  /** Rows this resolver will walk before it refuses to resolve at all. */
66
69
  const MAX_ROWS = PAGE_LIMIT * MAX_PAGES;
67
70
  /**
68
- * How many known names an error may list. `bb_labels` is small; a future
71
+ * How many known names an error may list. A term bundle is small; a future
69
72
  * target need not be, and an error nobody can read is an error nobody reads.
70
73
  */
71
74
  const NAME_LIST_CAP = 20;
@@ -185,12 +188,12 @@ async function resolveChainScope(req, ctx, ref, data) {
185
188
  }
186
189
  /**
187
190
  * 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
+ * target type, in the cardinality the resolver writes? A single resolver
192
+ * writes one object, a multi-valued one an array; matching the other shape
193
+ * would produce an invalid payload.
191
194
  */
192
195
  function declares(schema, ref) {
193
- const targets = at(schema, 'properties', 'data', 'properties', 'relationships', 'properties', ref.relationship, 'properties', 'data', 'items', 'properties', 'type', 'enum');
196
+ const targets = at(schema, 'properties', 'data', 'properties', 'relationships', 'properties', ref.relationship, 'properties', 'data', ...(ref.single ? [] : ['items']), 'properties', 'type', 'enum');
194
197
  return Array.isArray(targets) && targets.includes(ref.type);
195
198
  }
196
199
  /** `/jsonapi`, however the config spelled it — with, without, or with a trailing slash. */
@@ -210,10 +213,19 @@ function normalisePrefix(raw) {
210
213
  * of handing an HTML error page to `JSON.parse`.
211
214
  */
212
215
  async function fetchDoc(ctx, url, what) {
216
+ return sendDoc(ctx, { method: 'GET', url }, `could not read ${what}`);
217
+ }
218
+ /** Send one JSON:API request and parse its document, or fail by name. */
219
+ async function sendDoc(ctx, req, failure) {
220
+ const url = req.url;
213
221
  const res = await ctx.http.send(await ctx.auth.apply({
214
- method: 'GET',
215
- url,
216
- headers: { Accept: 'application/vnd.api+json' },
222
+ ...req,
223
+ headers: {
224
+ Accept: 'application/vnd.api+json',
225
+ ...(req.body === undefined
226
+ ? {}
227
+ : { 'Content-Type': 'application/vnd.api+json' }),
228
+ },
217
229
  }));
218
230
  if (res.status < 200 || res.status >= 300) {
219
231
  const expiry = res.status === 401 || res.status === 403
@@ -221,15 +233,37 @@ async function fetchDoc(ctx, url, what) {
221
233
  'and, unlike dropsh’s JSON:API client, cannot renew one, so an expired ' +
222
234
  'token surfaces here instead of being refreshed. Re-authenticate and retry.'
223
235
  : '';
224
- throw new Error(`could not read ${what}: HTTP ${res.status} from ${url}.${expiry}`);
236
+ throw new Error(`${failure}: HTTP ${res.status} from ${url}.${expiry}`);
225
237
  }
226
238
  try {
227
239
  return JSON.parse(res.body);
228
240
  }
229
241
  catch (cause) {
230
- throw new Error(`could not read ${what}: ${url} answered ${res.status} but not JSON (${String(cause)})`);
242
+ throw new Error(`${failure}: ${url} answered ${res.status} but not JSON (${String(cause)})`);
231
243
  }
232
244
  }
245
+ /** Create a term in a workspace and return its uuid. */
246
+ async function createTerm(ctx, ref, name, workspaceId) {
247
+ const prefix = normalisePrefix(ctx.jsonapiPrefix);
248
+ const doc = await sendDoc(ctx, {
249
+ method: 'POST',
250
+ url: `${ctx.baseUrl.replace(/\/+$/, '')}${prefix}/${ref.path}`,
251
+ body: JSON.stringify({
252
+ data: {
253
+ type: ref.type,
254
+ attributes: { name },
255
+ relationships: {
256
+ workspace_id: { data: { type: WORKSPACE_TYPE, id: workspaceId } },
257
+ },
258
+ },
259
+ }),
260
+ }, `could not create ${ref.noun} '${name}' in workspace '${workspaceId}'`);
261
+ const id = at(doc, 'data', 'id');
262
+ if (typeof id !== 'string') {
263
+ throw new Error(`could not create ${ref.noun} '${name}': the response carries no id`);
264
+ }
265
+ return id;
266
+ }
233
267
  /**
234
268
  * The whole collection as name → candidates. One round trip regardless of how
235
269
  * many names were given, and it yields the known-names list the error needs.
@@ -289,15 +323,113 @@ function describe(candidates) {
289
323
  .map((c) => (c.scopeId === undefined ? c.id : `${c.id} (${c.scopeId})`))
290
324
  .join(', ');
291
325
  }
326
+ /**
327
+ * One term name → its uuid in the ticket's workspace, created there when
328
+ * missing. Without a workspace (a create naming no project) nothing can be
329
+ * created and only an unambiguous existing name resolves.
330
+ */
331
+ async function resolveTerm(ctx, ref, byName, name, workspaceId) {
332
+ const candidates = byName.get(name) ?? [];
333
+ if (workspaceId === undefined) {
334
+ const [only, ...rivals] = candidates;
335
+ if (only === undefined) {
336
+ throw new Error(`${ref.noun} name '${name}' does not exist in the ${ref.path} ` +
337
+ `vocabulary, and this write names no project_id whose workspace it ` +
338
+ `could be created in. Known names: ${knownNames(byName)}`);
339
+ }
340
+ if (rivals.length > 0) {
341
+ throw new Error(`${ref.noun} name '${name}' is ambiguous — ${candidates.length} ` +
342
+ `entries in ${ref.path} share it. Candidates: ${describe(candidates)}. ` +
343
+ `Pass the uuid directly on the ${ref.relationship} relationship instead.`);
344
+ }
345
+ return only.id;
346
+ }
347
+ const [match, ...extra] = candidates.filter((c) => c.scopeId === workspaceId);
348
+ if (extra.length > 0 && match !== undefined) {
349
+ throw new Error(`${ref.noun} name '${name}' is ambiguous even within workspace_id ` +
350
+ `'${workspaceId}': ${describe([match, ...extra])}. Pass the uuid ` +
351
+ `directly on the ${ref.relationship} relationship instead.`);
352
+ }
353
+ if (match !== undefined)
354
+ return match.id;
355
+ const id = await createTerm(ctx, ref, name, workspaceId);
356
+ byName.set(name, [...candidates, { id, scopeId: workspaceId }]);
357
+ return id;
358
+ }
359
+ /**
360
+ * One name → its uuid within the parent the write carries (`scopeField`),
361
+ * read off the entity under update when the payload omits it.
362
+ */
363
+ async function resolveScoped(req, ctx, ref, data, byName, name, scopes) {
364
+ const candidates = byName.get(name) ?? [];
365
+ const [only, ...rivals] = candidates;
366
+ if (only === undefined) {
367
+ throw new Error(`${ref.noun} name '${name}' does not exist in the ${ref.path} ` +
368
+ `vocabulary. Known names: ${knownNames(byName)}`);
369
+ }
370
+ const scopeField = ref.scopeField;
371
+ if (scopeField === undefined) {
372
+ if (rivals.length > 0) {
373
+ throw new Error(`${ref.noun} name '${name}' is ambiguous — ${candidates.length} ` +
374
+ `entries in ${ref.path} share it. Candidates: ${describe(candidates)}. ` +
375
+ `Pass the uuid directly on the ${ref.relationship} relationship instead.`);
376
+ }
377
+ return only.id;
378
+ }
379
+ // A scoped resolver ALWAYS establishes a scope — including for a name that
380
+ // is unique across the whole collection today. Uniqueness is a property of
381
+ // the current rows, not of the name: `staging` is unique right up to the
382
+ // moment a second project creates one, and until then an unscoped write
383
+ // would attach another project's environment and report success. The scope
384
+ // the payload carries is free; otherwise it is read off the entity being
385
+ // updated (memoised: one read per write, however many names were given). A
386
+ // create has no entity yet, hence `undefined`.
387
+ let scopeId = relationshipId(data, scopeField);
388
+ if (scopeId === undefined) {
389
+ let pending = scopes.get(scopeField);
390
+ if (pending === undefined) {
391
+ pending =
392
+ ctx.operation === 'update'
393
+ ? fetchScope(req, ctx, scopeField)
394
+ : Promise.resolve(undefined);
395
+ scopes.set(scopeField, pending);
396
+ }
397
+ scopeId = await pending;
398
+ }
399
+ if (scopeId === undefined) {
400
+ throw new Error(rivals.length > 0
401
+ ? `${ref.noun} name '${name}' is ambiguous — ${candidates.length} ` +
402
+ `entries in ${ref.path} share it and the write carries no ` +
403
+ `${scopeField} to narrow them. Candidates: ${describe(candidates)}. ` +
404
+ `Pass the uuid directly on the ${ref.relationship} relationship instead.`
405
+ : `${ref.noun} name '${name}' cannot be resolved: ${ref.path} names are ` +
406
+ `unique only within ${scopeField}, and this write carries no ` +
407
+ `${scopeField} to narrow it — being the only '${name}' that exists ` +
408
+ `today does not make it this write's. Candidate: ${describe(candidates)}. ` +
409
+ `Name the ${scopeField}, or pass the uuid directly on the ` +
410
+ `${ref.relationship} relationship instead.`);
411
+ }
412
+ const [match, ...extra] = candidates.filter((c) => c.scopeId === scopeId);
413
+ if (match === undefined) {
414
+ const elsewhere = candidates.map((c) => c.scopeId ?? '(none)').join(', ');
415
+ throw new Error(`${ref.noun} name '${name}' does not exist in ${scopeField} ` +
416
+ `'${scopeId}'. It exists in: ${elsewhere}.`);
417
+ }
418
+ if (extra.length > 0) {
419
+ throw new Error(`${ref.noun} name '${name}' is ambiguous even within ${scopeField} ` +
420
+ `'${scopeId}': ${describe([match, ...extra])}. Pass the uuid directly ` +
421
+ `on the ${ref.relationship} relationship instead.`);
422
+ }
423
+ return match.id;
424
+ }
292
425
  export function nameRefsPlugin() {
293
426
  // Per-instance, not module-level: one CLI invocation performs one write, so
294
427
  // these guard against a second lookup, and they keep tests isolated.
295
428
  const indexes = new Map();
296
429
  const scopes = new Map();
297
430
  /**
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.
431
+ * Chain-scope resolutions, keyed by the chain: the three term resolvers share
432
+ * one, and a chain resolution is a round trip we want to pay once per write.
301
433
  */
302
434
  const chainScopes = new Map();
303
435
  /**
@@ -381,6 +513,18 @@ export function nameRefsPlugin() {
381
513
  ? { ...data.relationships }
382
514
  : {};
383
515
  const declaredHere = offered.get(bundleKey(ctx.entityType, ctx.bundle));
516
+ const namesOf = (ref) => [
517
+ ...new Set(String(attributes[ref.field])
518
+ .split(',')
519
+ .map((name) => name.trim())
520
+ .filter((name) => name.length > 0)),
521
+ ];
522
+ // Before any round trip: a single-valued field takes one name.
523
+ for (const ref of active) {
524
+ if (ref.single && namesOf(ref).length > 1) {
525
+ throw new Error(`'${ref.field}' takes one name: ${ref.relationship} holds a single ${ref.noun}.`);
526
+ }
527
+ }
384
528
  for (const ref of active) {
385
529
  // A name field on a bundle that has no such relationship. Reachable by
386
530
  // a hand-built body or `--no-validate`, since the flag itself only
@@ -393,10 +537,7 @@ export function nameRefsPlugin() {
393
537
  `Remove it, or write to a bundle that has ${ref.relationship}.`);
394
538
  }
395
539
  delete nextAttributes[ref.field];
396
- const names = String(attributes[ref.field])
397
- .split(',')
398
- .map((name) => name.trim())
399
- .filter((name) => name.length > 0);
540
+ const names = namesOf(ref);
400
541
  if (names.length === 0)
401
542
  continue;
402
543
  let index = indexes.get(ref.field);
@@ -405,120 +546,31 @@ export function nameRefsPlugin() {
405
546
  indexes.set(ref.field, index);
406
547
  }
407
548
  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
549
  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;
550
+ if (ref.scopeChain !== undefined) {
551
+ // A term always belongs to the ticket's workspace: one fetch walks
552
+ // project → group → workspace, shared by all term fields of the write.
553
+ let pending = chainScopes.get(ref.scopeChain);
554
+ if (pending === undefined) {
555
+ pending = resolveChainScope(req, ctx, ref, data);
556
+ chainScopes.set(ref.scopeChain, pending);
436
557
  }
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
- }
558
+ const workspaceId = await pending;
559
+ for (const name of names) {
560
+ resolved.push(await resolveTerm(ctx, ref, byName, name, workspaceId));
464
561
  }
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}.`);
562
+ }
563
+ else {
564
+ for (const name of names) {
565
+ resolved.push(await resolveScoped(req, ctx, ref, data, byName, name, scopes));
515
566
  }
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.`);
567
+ }
568
+ if (ref.single) {
569
+ const [id] = resolved;
570
+ if (id !== undefined) {
571
+ relationships[ref.relationship] = { data: { type: ref.type, id } };
520
572
  }
521
- resolved.push(match.id);
573
+ continue;
522
574
  }
523
575
  const current = at(relationships, ref.relationship, 'data');
524
576
  const existing = Array.isArray(current)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bakers-build/addon-name-refs",
3
- "version": "0.1.2-next.88",
3
+ "version": "0.2.1-next.211",
4
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
5
  "type": "module",
6
6
  "license": "MIT",
@@ -22,6 +22,6 @@
22
22
  "dropsh": "^0.7.5"
23
23
  },
24
24
  "peerDependencies": {
25
- "@bakers-build/core": "^0.1.2-next.88"
25
+ "@bakers-build/core": "^0.2.1-next.211"
26
26
  }
27
27
  }