@things-factory/figure-service 10.1.109 → 10.1.110

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/figure-service",
3
- "version": "10.1.109",
3
+ "version": "10.1.110",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "@things-factory:registry": "https://registry.npmjs.org"
@@ -35,5 +35,5 @@
35
35
  "graphql-upload": "^15.0.2",
36
36
  "koa-router": "^7.4.0"
37
37
  },
38
- "gitHead": "4d51757c50d8347e83c96aeba73d770bdde55b73"
38
+ "gitHead": "b68feb6eccbef44af2a6365f384bd852dd70a8f9"
39
39
  }
@@ -374,6 +374,44 @@ describe('figureBlueprint 질의 — 소비처(board, twin, things-scene)에 발
374
374
  expect(source.slice(0, at)).toContain('@UseMiddleware(SpeakRefusals)')
375
375
  })
376
376
 
377
+ /* ADR-0104 decision 2: a consumer that pins a release asks for it by number; every answer carries its number. */
378
+ describe('a release asked for by number', () => {
379
+ async function threeReleases(type: string) {
380
+ const figure = await dataSource!.getRepository(Figure).save({ domain: testDomain, type, name: 'pinned', state: 'draft', source: boxNamed(type, 'unreleased'), version: 3 })
381
+ for (const version of [1, 2, 3])
382
+ await dataSource!.getRepository(FigureHistory).save({ domain: testDomain, originalId: figure.id, type, name: `v${version}`, state: 'released', version, source: boxNamed(type, `v${version}`) })
383
+ return figure
384
+ }
385
+
386
+ it('answers the latest with its number when no number is asked', async () => {
387
+ await threeReleases('PINNED')
388
+ const result = await new FigureQuery().figureBlueprint('PINNED', makeContext())
389
+ expect([result.version, partsOf(result)]).toEqual([3, ['v3']])
390
+ })
391
+
392
+ it('answers an older release by its number, not the latest', async () => {
393
+ await threeReleases('PINNED')
394
+ const result = await new FigureQuery().figureBlueprint('PINNED', makeContext(), 1)
395
+ expect([result.version, partsOf(result)]).toEqual([1, ['v1']])
396
+ })
397
+
398
+ it('refuses a number that was never released by name, never answering another release', async () => {
399
+ await threeReleases('PINNED')
400
+ await expect(new FigureQuery().figureBlueprint('PINNED', makeContext(), 7)).rejects.toMatchObject({ code: 'figure-release-not-found' })
401
+ for (const lang of ['ko', 'en']) expect(require(`../../../translations/${lang}.json`)['error.figure-release-not-found']).toMatch(/\{latest\}/)
402
+ })
403
+
404
+ it('a type nobody released is still null, whatever number is asked', async () => {
405
+ expect(await new FigureQuery().figureBlueprint('NOBODY', makeContext(), 1)).toBeNull()
406
+ })
407
+
408
+ it('a released row is answered with its own number', async () => {
409
+ await dataSource!.getRepository(Figure).save({ domain: testDomain, type: 'ROW', name: 'row', state: 'released', source: sampleSource, version: 4 })
410
+ expect((await new FigureQuery().figureBlueprint('ROW', makeContext())).version).toBe(4)
411
+ expect((await new FigureQuery().figureBlueprint('ROW', makeContext(), 4)).version).toBe(4)
412
+ })
413
+ })
414
+
377
415
  describe('a child domain shadows its parent only with a released version', () => {
378
416
  const boxOf = (_type: string, part: string) => box(part)
379
417
 
@@ -106,7 +106,7 @@ export class FigureHistory implements HistoryEntityInterface<Figure> {
106
106
  @Field({
107
107
  nullable: true,
108
108
  description:
109
- 'What changed against the previous release, as JSON: [{ code, blocking, subject?, values, detail }]. [] for the first release; null when the previous release could not be read.'
109
+ 'What changed against the previous release, as JSON: [{ code, blocking, subject?, values, detail }]. [] for the first release. null when it was not compared: the previous release could not be read, or this release was made before releases recorded it (figure-service 10.1.109).'
110
110
  })
111
111
  changes?: string
112
112
 
@@ -1,5 +1,5 @@
1
1
  import { In } from 'typeorm'
2
- import { Args, Arg, Ctx, Directive, Query, Resolver, UseMiddleware } from 'type-graphql'
2
+ import { Args, Arg, Ctx, Directive, Int, Query, Resolver, UseMiddleware } from 'type-graphql'
3
3
  import { GraphQLJSON } from 'graphql-scalars'
4
4
 
5
5
  import {
@@ -242,11 +242,13 @@ export class FigureQuery {
242
242
  @Directive('@privilege(category: "figure-published", privilege: "query", domainOwnerGranted: true)')
243
243
  @Query(returns => GraphQLJSON, {
244
244
  nullable: true,
245
- description: 'A released figure for its consumers (board, twin, things-scene): { type, asset } with the figure asset. Only returns released figures.'
245
+ description:
246
+ 'A released figure for its consumers (board, twin, things-scene): { type, version, asset }. The latest release, or the one numbered `version` for a consumer that pins releases (ADR-0104). Only returns released figures.'
246
247
  })
247
248
  async figureBlueprint(
248
249
  @Arg('type') type: string,
249
- @Ctx() context: ResolverContext
250
+ @Ctx() context: ResolverContext,
251
+ @Arg('version', type => Int, { nullable: true, description: 'A release number. Absent: the latest release.' }) pinned?: number
250
252
  ): Promise<any> {
251
253
  const { domain } = context.state
252
254
 
@@ -257,18 +259,32 @@ export class FigureQuery {
257
259
  architect ruling, 2026-09-17). This used to skip the fallback as soon as a child row existed and
258
260
  return null.
259
261
  */
260
- const found =
262
+ const latest =
261
263
  (await releasedSourceOf(domain.id, type)) ?? (domain.parentId ? await releasedSourceOf(domain.parentId, type) : null)
262
264
 
263
- if (!found) {
265
+ if (!latest) {
264
266
  return null
265
267
  }
266
268
 
269
+ /*
270
+ A pinned release is read from the same figure the latest comes from -- the shadowing rule decides whose
271
+ releases these are, then the number picks one. Releases are never deleted, so a number once handed out is
272
+ always there; a number that never was is refused by name rather than answered with another release.
273
+ */
274
+ const found = pinned === undefined || pinned === null || pinned === latest.version ? latest : await releaseNumbered(latest.figure, pinned)
275
+ if (!found) {
276
+ throw new Refusal(
277
+ 'figure-release-not-found',
278
+ { type, version: pinned, latest: latest.version },
279
+ '{type} has no release {version}. its latest release is {latest}.'
280
+ )
281
+ }
282
+
267
283
  const { figure, source: sourceToCompile, version } = found
268
284
 
269
285
  try {
270
286
  const text = typeof sourceToCompile === 'string' ? sourceToCompile : JSON.stringify(sourceToCompile)
271
- return { type, asset: consumablePayloadOf(text).asset }
287
+ return { type, version, asset: consumablePayloadOf(text).asset }
272
288
  } catch (err) {
273
289
  console.error(`[figureBlueprint] 타입 '${type}' (figure id: ${figure.id}) 자산 컴파일 실패:`, err)
274
290
 
@@ -290,6 +306,14 @@ export class FigureQuery {
290
306
  }
291
307
  }
292
308
 
309
+ /** One numbered release of a figure, or null when it has none by that number. */
310
+ async function releaseNumbered(figure: Figure, version: number): Promise<{ figure: Figure; source: string; version: number } | null> {
311
+ const row = await getRepository(FigureHistory).findOne({
312
+ where: { originalId: figure.id, state: 'released', version }
313
+ })
314
+ return row?.source ? { figure, source: row.source, version: row.version } : null
315
+ }
316
+
293
317
  /**
294
318
  * Finds the released source of a type in one domain, or null.
295
319
  *
@@ -74,5 +74,6 @@
74
74
  "figure.gate.contract.public-input-range-narrowed.message": "The range of '{input}' narrowed: {before} → {after}.",
75
75
  "figure.gate.contract.public-input-range-narrowed.why": "An instance may have set a value outside the new range. Which values are placed is not recorded.",
76
76
  "figure.gate.contract.public-input-added.message": "'{input}' can now be set on a board.",
77
- "figure.gate.contract.public-input-added.why": "Placed instances that give no value stand at its default."
77
+ "figure.gate.contract.public-input-added.why": "Placed instances that give no value stand at its default.",
78
+ "error.figure-release-not-found": "{type} has no release {version}. its latest release is {latest}."
78
79
  }
@@ -74,5 +74,6 @@
74
74
  "figure.gate.contract.public-input-range-narrowed.message": "'{input}' 값의 범위가 좁아졌습니다: {before} → {after}.",
75
75
  "figure.gate.contract.public-input-range-narrowed.why": "새 범위 밖의 값을 준 인스턴스가 있을 수 있습니다. 어떤 값이 놓였는지는 기록하지 않습니다.",
76
76
  "figure.gate.contract.public-input-added.message": "'{input}' 값을 이제 보드에서 정할 수 있습니다.",
77
- "figure.gate.contract.public-input-added.why": "값을 주지 않은 인스턴스는 기본값으로 섭니다."
77
+ "figure.gate.contract.public-input-added.why": "값을 주지 않은 인스턴스는 기본값으로 섭니다.",
78
+ "error.figure-release-not-found": "{type} 에는 {version} 번 발행판이 없습니다. 최신 발행판은 {latest} 번입니다."
78
79
  }