@bakers-build/addon-name-refs 0.0.0-stage → 0.1.0-next.42
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 +4 -2
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.js +555 -0
- package/package.json +25 -4
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
|
-
#
|
|
1
|
+
# @bakers-build/addon-name-refs
|
|
2
2
|
|
|
3
|
-
|
|
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,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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0-next.42",
|
|
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.42"
|
|
26
|
+
}
|
|
27
|
+
}
|