@cap-js/cds-typer 0.38.0 → 0.39.0

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/lib/cli.js CHANGED
@@ -213,7 +213,11 @@ const flags = enrichFlagSchema({
213
213
  desc: `How to cache typer runs.${EOL}none: fully run cds-typer whenever it is called${EOL}blake2s256: only run if the blake2s256-hash of the model has changed. Hash is stored in a file between runs.`,
214
214
  allowed: ['none', 'blake2s256'],
215
215
  default: 'none'
216
- }
216
+ },
217
+ brandedPrimitiveTypes: parameterTypes.boolean({
218
+ desc: `If set to true, generated primitive types will be branded to prevent accidental mixing of types with the same underlying primitive.${EOL}E.g., type CustomerID = string & { __brand: 'CustomerID' }`,
219
+ default: 'false'
220
+ })
217
221
  })
218
222
 
219
223
  const hint = () => 'Missing or invalid parameter(s). Call with --help for more details.'
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Create a class member with given modifiers in the right order.
2
+ * Create a class member with given modifiers in the right order and returns the resulting string.
3
3
  * @param {object} options - options
4
4
  * @param {string} options.name - the name of the member
5
5
  * @param {string} [options.type] - the type of the member
@@ -1,4 +1,4 @@
1
- const { normalise } = require('./identifier')
1
+ const { enquote } = require('./identifier')
2
2
 
3
3
  /**
4
4
  * Extracts all unique values from a list of enum key-value pairs.
@@ -57,7 +57,7 @@ function printEnum(buffer, name, kvs, options = {}, doc=[]) {
57
57
  buffer.add('// enum')
58
58
  if (opts.export) buffer.add(doc)
59
59
  buffer.addIndentedBlock(`${opts.export ? 'export ' : ''}const ${name} = {`, () =>
60
- kvs.forEach(([k, v]) => { buffer.add(`${normalise(k)}: ${v},`) })
60
+ kvs.forEach(([k, v]) => { buffer.add(`${enquote(k)}: ${v},`) })
61
61
  , '} as const;')
62
62
  buffer.add(`${opts.export ? 'export ' : ''}type ${name} = ${stringifyEnumType(kvs)}`)
63
63
  buffer.blankLine()
@@ -126,7 +126,7 @@ const isInlineEnumType = (element, csn) => element.enum
126
126
  */
127
127
  const stringifyEnumImplementation = (name, kvs, jsp) => jsp.printExport(
128
128
  name,
129
- `{ ${kvs.map(([k,v]) => `${normalise(k)}: ${v}`).join(', ')} }`,
129
+ `{ ${kvs.map(([k,v]) => `${enquote(k)}: ${v}`).join(', ')} }`,
130
130
  { coalesce: true })
131
131
 
132
132
 
@@ -1,12 +1,84 @@
1
+ const { createHash } = require('node:crypto')
2
+ const { LOG } = require('../logging')
3
+
4
+ // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Lexical_grammar
5
+ const JS_RESERVED = new Set([
6
+ 'abstract',
7
+ 'arguments',
8
+ 'await',
9
+ 'boolean',
10
+ 'break',
11
+ 'byte',
12
+ 'case',
13
+ 'catch',
14
+ 'char',
15
+ 'class',
16
+ 'const',
17
+ 'continue',
18
+ 'debugger',
19
+ 'default',
20
+ 'delete',
21
+ 'do',
22
+ 'double',
23
+ 'else',
24
+ 'enum',
25
+ 'eval',
26
+ 'export',
27
+ 'extends',
28
+ 'false',
29
+ 'final',
30
+ 'finally',
31
+ 'float',
32
+ 'for',
33
+ 'function',
34
+ 'goto',
35
+ 'if',
36
+ 'implements',
37
+ 'import',
38
+ 'in',
39
+ 'instanceof',
40
+ 'int',
41
+ 'interface',
42
+ 'let',
43
+ 'long',
44
+ 'native',
45
+ 'new',
46
+ 'null',
47
+ 'object',
48
+ 'package',
49
+ 'private',
50
+ 'protected',
51
+ 'public',
52
+ 'return',
53
+ 'short',
54
+ 'static',
55
+ 'super',
56
+ 'switch',
57
+ 'synchronized',
58
+ 'this',
59
+ 'throw',
60
+ 'throws',
61
+ 'transient',
62
+ 'true',
63
+ 'try',
64
+ 'typeof',
65
+ 'var',
66
+ 'void',
67
+ 'volatile',
68
+ 'while',
69
+ 'with',
70
+ 'yield',
71
+ ])
72
+
1
73
  const isValidIdent = /^[_$a-zA-Z][$\w]*$/
2
74
 
3
75
  /**
4
- * Normalises an identifier to a valid JavaScript identifier.
76
+ * Enquotes an identifier to a valid JavaScript identifier.
5
77
  * I.e. either the identifier itself or a quoted string.
6
- * @param {string} ident - the identifier to normalise
7
- * @returns {string} the normalised identifier
78
+ * @param {string} ident - the identifier to enquote
79
+ * @returns {string} the enquoted identifier
8
80
  */
9
- const normalise = ident => ident && !isValidIdent.test(ident)
81
+ const enquote = ident => ident && !isValidIdent.test(ident)
10
82
  ? `"${ident}"`
11
83
  : ident
12
84
 
@@ -17,7 +89,111 @@ const normalise = ident => ident && !isValidIdent.test(ident)
17
89
  */
18
90
  const last = ident => ident.split('.').at(-1) ?? ident
19
91
 
92
+ /**
93
+ * Normalises the name of a service or entity.
94
+ * This function is suited to normalise class names.
95
+ * To handle properties with exotic characters, see {@link enquote}.
96
+ * @param {string} name - name of the entity or fq thereof.
97
+ */
98
+ const normalise = name => {
99
+ const namespace = name.split('.').slice(0, -1)
100
+ const unqualified = /** @type {string} */ (name.split('.').at(-1))
101
+ let normalised = unqualified.match(/^[a-zA-Z]+\w*$/)
102
+ ? unqualified
103
+ : `__${unqualified.replaceAll(/[^a-zA-Z0-9]/g, '_')}`
104
+ if (/^_+$/.test(normalised)) {
105
+ // very rare case when the entire name consists of non-alphanumeric characters (Kanji, etc)
106
+ // That would leave us with just underscores,
107
+ // which has high potential of colliding with other such names
108
+ // -> fall back to hash. Hashes still bear a small risk of collision, but much lower.
109
+ // Prepend underscore to avoid problems with hashes starting with a digit.
110
+ normalised = '_' + createHash('md5').update(name).digest('hex')
111
+ }
112
+ if (JS_RESERVED.has(normalised)) {
113
+ normalised = `__${name}`
114
+ }
115
+ return {
116
+ original: name,
117
+ unqualified,
118
+ normalised: [...namespace, normalised].filter(Boolean).join('.'),
119
+ wasNormalised: unqualified !== normalised
120
+ }
121
+ }
122
+
123
+ class Identifier {
124
+ /** @type {string} */
125
+ plain
126
+ /** @type {string[]} */
127
+ scope = []
128
+ /** @type {Identifier | null} */
129
+ #from = null
130
+ /** @type {Identifier | undefined} */
131
+ #normalised
132
+ /** @type {boolean} */
133
+ #sealed = false
134
+
135
+ /**
136
+ * @returns whether normalisation was applied. This does not mean the identifier has changed!
137
+ * If it did not require normalisation, this will be true regardless.
138
+ * {@link Identifier#isChangedFromNormalisation} tells whether the identifier actually changed.
139
+ */
140
+ get isNormalised () {
141
+ return this.#sealed || Boolean(this.#from)
142
+ }
143
+
144
+ /**
145
+ * @returns whether the identifier actually changed due to normalisation.
146
+ */
147
+ get isChangedFromNormalisation () {
148
+ return this.isNormalised && this.#from !== null && this.plain !== this.#from.plain
149
+ }
150
+
151
+ /**
152
+ * @returns {Identifier}
153
+ */
154
+ get normalised () {
155
+ return this.isNormalised
156
+ ? this
157
+ : this.#normalised ??= new Identifier(normalise(this.plain).normalised, this.scope, this)
158
+ }
159
+
160
+ get scoped () {
161
+ return [...this.scope, this.plain].join('.')
162
+ }
163
+
164
+ /**
165
+ * @param {string | Identifier} name - the inflected name
166
+ * @param {string[]} [scope] - the scope of the identifier
167
+ * @param {Identifier | null} [from] - the original inflection if this is a normalised one
168
+ * @param {boolean} [sealed] - whether the identifier should be sealed to prevent further normalisation (e.g., for inline declarations).
169
+ * Setting this to true also prevents automatic scope splitting for identifiers with dots.
170
+ * @example
171
+ * ```js
172
+ * new Identifier('Books.texts') // name: 'texts', scope: ['Books']
173
+ * new Identifier('text', ['Books']) // name: 'text', scope: ['Books']
174
+ * new Identifier(new Identifier('Books.text'), ['Books']) // name: 'text', scope: ['Books'] -> as explicit scope is passed, the original name is retained as is!
175
+ * new Identifier('{x: Books.text}', null, true) // name: '{x: Books.text}', scope: [] -> as sealed is true, the original name is retained as is, and no scope splitting is applied, even though there are dots in the name!
176
+ * ```
177
+ */
178
+ constructor (name, scope = [], from = null, sealed = false) {
179
+ this.plain = typeof name === 'string' ? name : name.plain
180
+ this.scope = scope
181
+ this.#from = from
182
+ this.#sealed = sealed
183
+ if (from && name !== from.plain) {
184
+ LOG.debug(`Identifier '${from.plain}' was normalised to '${name}'.`)
185
+ }
186
+ if (this.plain.includes('.') && !this.scope.length && !sealed) {
187
+ const parts = this.plain.split('.')
188
+ this.plain = /** @type {string} */ (parts.at(-1))
189
+ this.scope = parts.slice(0, -1)
190
+ LOG.debug(`Identifier with dots without explicitly specified scope detected. Split into scope '${this.scope.join('.')}' and name '${this.plain}'.`)
191
+ }
192
+ }
193
+ }
194
+
20
195
  module.exports = {
21
- normalise,
22
- last
23
- }
196
+ enquote,
197
+ last,
198
+ Identifier,
199
+ }
@@ -1,6 +1,6 @@
1
1
  const { configuration } = require('../config')
2
2
  const { SourceFile, Buffer } = require('../file')
3
- const { normalise } = require('./identifier')
3
+ const { enquote } = require('./identifier')
4
4
  const { docify } = require('../printers/wrappers')
5
5
 
6
6
  /** @typedef {import('../resolution/resolver').TypeResolveInfo} TypeResolveInfo */
@@ -88,7 +88,13 @@ class InlineDeclarationResolver {
88
88
  const type = this.visitor.resolver.resolveAndRequire(element, file, resolverOptions)
89
89
  this.depth--
90
90
  if (this.depth === 0) {
91
- this.printInlineType({fq: name, type, buffer, modifiers})
91
+ // For builtin types (Composition, Association, etc.), just print the property directly
92
+ // since the typeName is already fully resolved and doesn't need further flattening
93
+ if (type.typeInfo.isBuiltin) {
94
+ buffer.add(`${this.stringifyModifiers(modifiers)}${enquote(name)}${this.getPropertyTypeSeparator()} ${this.getPropertyDatatype(type)}`)
95
+ } else {
96
+ this.printInlineType({fq: name, type, buffer, modifiers})
97
+ }
92
98
  }
93
99
  return type
94
100
  }
@@ -193,7 +199,7 @@ class FlatInlineDeclarationResolver extends InlineDeclarationResolver {
193
199
  ? Object.entries(type.typeInfo.structuredType).map(
194
200
  ([k,v]) => this.flatten({prefix: `${this.prefix(prefix)}${k}`, type: v, modifiers}) // for flat we pass the modifiers!
195
201
  ).flat()
196
- : [`${this.stringifyModifiers(modifiers)}${normalise(prefix)}${this.getPropertyTypeSeparator()} ${this.getPropertyDatatype(type)}`]
202
+ : [`${this.stringifyModifiers(modifiers)}${enquote(prefix)}${this.getPropertyTypeSeparator()} ${this.getPropertyDatatype(type)}`]
197
203
  }
198
204
 
199
205
  /**
@@ -254,8 +260,11 @@ class StructuredInlineDeclarationResolver extends InlineDeclarationResolver {
254
260
  this.printDepth++
255
261
  const lineEnding = this.printDepth > 1 ? ',' : statementEnd
256
262
  if (type.typeInfo.structuredType) {
257
- const prefix = fq ? `${this.stringifyModifiers(modifiers)}${normalise(fq)}${this.getPropertyTypeSeparator()}`: ''
258
- buffer.add(`${prefix} {`)
263
+ // When fq is empty, we're generating a bare struct type without a property name
264
+ // (used for inline declarations in compositions/associations)
265
+ buffer.add(fq
266
+ ? `${this.stringifyModifiers(modifiers)}${enquote(fq)}${this.getPropertyTypeSeparator()} {`
267
+ : '{')
259
268
  buffer.indent()
260
269
  for (const [n, t] of Object.entries(type.typeInfo.structuredType)) {
261
270
  this.flatten({fq: n, type: t, buffer})
@@ -263,7 +272,7 @@ class StructuredInlineDeclarationResolver extends InlineDeclarationResolver {
263
272
  buffer.outdent()
264
273
  buffer.add(`}${this.getPropertyDatatype(type, '')}${lineEnding}`)
265
274
  } else {
266
- buffer.add(`${this.stringifyModifiers(modifiers)}${normalise(fq)}${this.getPropertyTypeSeparator()} ${this.getPropertyDatatype(type)}${lineEnding}`)
275
+ buffer.add(`${this.stringifyModifiers(modifiers)}${enquote(fq)}${this.getPropertyTypeSeparator()} ${this.getPropertyDatatype(type)}${lineEnding}`)
267
276
  }
268
277
  this.printDepth--
269
278
  return buffer
package/lib/file.js CHANGED
@@ -4,10 +4,10 @@ const fs = require('fs').promises
4
4
  const path = require('path')
5
5
  const { readFileSync } = require('fs')
6
6
  const { printEnum, propertyToInlineEnumName, stringifyEnumImplementation } = require('./components/enum')
7
- const { normalise } = require('./components/identifier')
7
+ const { enquote } = require('./components/identifier')
8
8
  const { empty } = require('./components/typescript')
9
9
  const { proxyAccessFunction } = require('./components/javascript')
10
- const { createObjectOf, stringIdent } = require('./printers/wrappers')
10
+ const { createObjectOf, stringIdent, docify } = require('./printers/wrappers')
11
11
  const { configuration } = require('./config')
12
12
  const { CJSPrinter, ESMPrinter } = require('./printers/javascript')
13
13
 
@@ -142,7 +142,7 @@ class SourceFile extends File {
142
142
  this.classNames = {} // for .js file
143
143
  /** @type {{[key: string]: any}} */
144
144
  this.typeNames = {}
145
- /** @type {[string, string, string][]} */
145
+ /** @type {{singular: string, plural: string, original: string, isNormalised: boolean}[]} */
146
146
  this.inflections = []
147
147
  /** @type {{ buffer: Buffer, names: string[]}} */
148
148
  this.services = { buffer: new Buffer(), names: [] }
@@ -190,21 +190,21 @@ class SourceFile extends File {
190
190
  */
191
191
  static stringifyLambda({name, parameters=[], returns='any', kind, initialiser, self='never', isStatic=false, callStyles={positional:true, named:true}, doc}) {
192
192
  let docStr = doc?.length ? doc.join('\n')+'\n' : ''
193
- const parameterTypes = parameters.map(({name, modifier, type, doc}) => `${doc?'\n'+doc:''}${normalise(name)}${modifier}: ${type}`).join(', ')
193
+ const parameterTypes = parameters.map(({name, modifier, type, doc}) => `${doc?'\n'+doc:''}${enquote(name)}${modifier}: ${type}`).join(', ')
194
194
  const parameterTypeAsObject = parameterTypes.length
195
195
  ? createObjectOf(parameterTypes)
196
196
  : empty
197
197
  const callableSignatures = []
198
198
  if (callStyles.positional) {
199
- const paramTypesPositional = parameters.map(({name, type, doc}) => `${doc?'\n'+doc:''}${normalise(name)}: ${type}`).join(', ') // must not include ? modifiers
199
+ const paramTypesPositional = parameters.map(({name, type, doc}) => `${doc?'\n'+doc:''}${enquote(name)}: ${type}`).join(', ') // must not include ? modifiers
200
200
  callableSignatures.push('// positional',`${docStr}(${paramTypesPositional}): ${returns}`) // docs shows up on action consumer side: `.action(...)`
201
201
  }
202
202
  if (callStyles.named) {
203
- const parameterNames = createObjectOf(parameters.map(({name}) => normalise(name)).join(', '))
203
+ const parameterNames = createObjectOf(parameters.map(({name}) => enquote(name)).join(', '))
204
204
  callableSignatures.push('// named',`${docStr}(${parameterNames}: ${parameterTypeAsObject}): ${returns}`)
205
205
  }
206
206
  if (callableSignatures.length === 0) throw new Error('At least one call style must be specified')
207
- let prefix = name ? `${normalise(name)}: `: ''
207
+ let prefix = name ? `${enquote(name)}: `: ''
208
208
  if (prefix && isStatic) {
209
209
  prefix = `static ${prefix}`
210
210
  }
@@ -222,13 +222,15 @@ class SourceFile extends File {
222
222
  * Adds a pair of singular and plural inflection.
223
223
  * These are later used to generate the singular -> plural
224
224
  * aliases in the index.js file.
225
- * @param {string} singular - singular type without namespace.
226
- * @param {string} plural - plural type without namespace
227
- * @param {string} original - original entity name without namespace.
225
+ * @param {object} options - options
226
+ * @param {string} options.singular - singular type without namespace.
227
+ * @param {string} options.plural - plural type without namespace
228
+ * @param {string} options.original - original entity name without namespace.
228
229
  * In many cases this will be the same as plural.
230
+ * @param {boolean} [options.isNormalised] - whether the passed names are already normalised.
229
231
  */
230
- addInflection(singular, plural, original) {
231
- this.inflections.push([singular, plural, original])
232
+ addInflection({singular, plural, original, isNormalised = false}) {
233
+ this.inflections.push({singular, plural, original, isNormalised})
232
234
  }
233
235
 
234
236
  /**
@@ -239,12 +241,22 @@ class SourceFile extends File {
239
241
  * @param {'function' | 'action'} kind - kind of the node
240
242
  * @param {string[]} doc - documentation for the function
241
243
  * @param {{positional?: boolean, named?: boolean}} callStyles - how the operation can be called
244
+ * @param {string} [originalName] - the original name before normalization, if different
242
245
  */
243
- addOperation(name, parameters, returns, kind, doc, callStyles) {
246
+ addOperation(name, parameters, returns, kind, doc, callStyles, originalName) {
244
247
  // this.operations.buffer.add(`// ${kind}`)
245
248
  if (doc) this.operations.buffer.add(doc.join('\n')) // docs shows up on action provider side: `.on(action,...)`
246
249
  const [opener, content, closer] = SourceFile.stringifyLambda({name, parameters, returns, kind, doc, callStyles})
247
250
  this.operations.buffer.addIndentedBlock(`export declare const ${opener}`, content, closer)
251
+ // Add alias export if normalized name differs from original
252
+ if (originalName && originalName !== name) {
253
+ this.operations.buffer.add(docify([
254
+ ` This ${kind} can be accessed via:`,
255
+ ` - Named import: \`import { ${name} } from '...'\``,
256
+ ` - Aliased import: \`import { "${originalName}" as MyAlias } from '...'\`\n`
257
+ ]))
258
+ this.operations.buffer.add(`export { ${name} as "${originalName}" }`)
259
+ }
248
260
  this.operations.names.push(name)
249
261
  }
250
262
 
@@ -326,9 +338,8 @@ class SourceFile extends File {
326
338
  entityProxy.push(propertyName)
327
339
 
328
340
  // REVISIT: find a better way to do this???
329
- const printEnumToBuffer = (/** @type {Buffer} */buffer) => printEnum(buffer, propertyToInlineEnumName(entityCleanName, propertyName), kvs, {export: false}, doc)
330
-
331
341
  if (buffer?.namespace) {
342
+ const printEnumToBuffer = (/** @type {Buffer} */buffer) => printEnum(buffer, propertyToInlineEnumName(entityCleanName, propertyName), kvs, {export: false}, doc)
332
343
  const tempBuffer = new Buffer()
333
344
  // we want to put the enums on class level
334
345
  tempBuffer.indent()
@@ -338,6 +349,9 @@ class SourceFile extends File {
338
349
  const [first,...rest] = buffer.parts
339
350
  buffer.parts = [first, ...tempBuffer.parts, ...rest]
340
351
  } else {
352
+ // top-level inline enums must be exported so they can be referenced cross-namespace
353
+ // (e.g. when a service projects an entity whose key has an inline enum type)
354
+ const printEnumToBuffer = (/** @type {Buffer} */buffer) => printEnum(buffer, propertyToInlineEnumName(entityCleanName, propertyName), kvs, {export: true}, doc)
341
355
  printEnumToBuffer(this.inlineEnums.buffer)
342
356
  }
343
357
  }
@@ -550,8 +564,8 @@ class SourceFile extends File {
550
564
  // sorting the entries based on the number of dots in their singular.
551
565
  // that makes sure we have defined all parent namespaces before adding subclasses to them e.g.:
552
566
  // "module.exports.Books" is defined before "module.exports.Books.text"
553
- .sort(([a], [b]) => a.split('.').length - b.split('.').length)
554
- .flatMap(([singular, plural, original]) => {
567
+ .sort(({singular:a}, {singular:b}) => a.split('.').length - b.split('.').length)
568
+ .flatMap(({singular, plural, original, isNormalised}) => {
555
569
  const { singularRhs, pluralRhs } = this.#getEntityExportsRhs(singular, original)
556
570
 
557
571
  const exports = [
@@ -565,8 +579,9 @@ class SourceFile extends File {
565
579
  // FIXME: we currently produce at most 3 entries.
566
580
  // This could be an issue when the user re-used the original name in a @singular/@plural annotation.
567
581
  // Seems unlikely, but we have to eliminate the original entry if users start running into this.
568
- if (singular !== original) {
582
+ if (singular !== original && !isNormalised) {
569
583
  // do not do the is_singular spiel if the original name is used for the plural
584
+ // or if we had to normalise the name, which would imply an invalid identifier we can not use anyway
570
585
  const rhs = plural === original ? pluralRhs : singularRhs
571
586
  exports.push(jsp.printExport(original, rhs))
572
587
  }
@@ -592,21 +607,13 @@ class Buffer {
592
607
  * @param {string} indentation - indentation to use (two spaces by default)
593
608
  */
594
609
  constructor(indentation = ' ') {
595
- /**
596
- * @type {string[]}
597
- */
610
+ /** @type {string[]} */
598
611
  this.parts = []
599
- /**
600
- * @type {string}
601
- */
612
+ /** @type {string} */
602
613
  this.indentation = indentation
603
- /**
604
- * @type {string}
605
- */
614
+ /** @type {string} */
606
615
  this.currentIndent = ''
607
- /**
608
- * @type {boolean}
609
- */
616
+ /** @type {boolean} */
610
617
  this.closed = false
611
618
  /**
612
619
  * Required for inline enums of inline compositions or text entities
@@ -105,6 +105,12 @@ const createIntersectionOf = (...types) => types.join(' & ')
105
105
  */
106
106
  const createPromiseOf = t => `globalThis.Promise<${t}>`
107
107
 
108
+ /**
109
+ * Creates a branded type.
110
+ * @param {string} t - the type to brand.
111
+ */
112
+ const createBrandedType = t => `{ __brand: '${t}' }`
113
+
108
114
  /**
109
115
  * Wraps type into a deep require (removes all posibilities of undefined recursively).
110
116
  * @param {string} t - the singular type name.
@@ -123,13 +129,16 @@ const unkey = t => `${base}.Unkey<${t}>`
123
129
 
124
130
  /**
125
131
  * Puts a passed string in docstring format.
126
- * @param {string | undefined} doc - raw string to docify. May contain linebreaks.
132
+ * @param {string | string[] | undefined} doc - raw string to docify.
133
+ * Passing a string may contain linebreaks, which are used to split the doc into multiple lines.
134
+ * Alternatively, an array of strings can be passed, where each string is a line in the doc.
127
135
  * @returns {string[]} an array of lines wrapped in doc format. The result is not
128
136
  * concatenated to be properly indented by `buffer.add(...)`.
129
137
  */
130
138
  const docify = doc => {
131
139
  if (!doc) return []
132
- const lines = doc.split(/\r?\n/).map(l => l.trim().replaceAll('*/', '*\\/')) // mask any */ with *\/
140
+ const lines = (Array.isArray(doc) ? doc : doc.split(/\r?\n/))
141
+ .map(l => l.trim().replaceAll('*/', '*\\/')) // mask any */ with *\/
133
142
  if (lines.length === 1) return [`/** ${lines[0]} */`] // one-line doc
134
143
  return ['/**'].concat(lines.map(line => `* ${line}`)).concat(['*/'])
135
144
  }
@@ -143,6 +152,7 @@ const stringIdent = s => `'${s}'`
143
152
 
144
153
  module.exports = {
145
154
  createArrayOf,
155
+ createBrandedType,
146
156
  createDraftOf,
147
157
  createDraftsOf,
148
158
  createKey,
@@ -46,7 +46,7 @@ class EntityInfo {
46
46
  */
47
47
  propertyAccess
48
48
 
49
- /** @type {{singular: string, plural: string} | undefined} */
49
+ /** @type {import('./resolver').Inflection | undefined} */
50
50
  #inflection
51
51
 
52
52
  /** @type {import('./resolver').Resolver} */
@@ -150,22 +150,22 @@ class EntityRepository {
150
150
  #resolver
151
151
 
152
152
  /**
153
- * @param {string} fq - fully qualified name of the entity
153
+ * @param {import('../components/identifier').Identifier |string} fq - fully qualified name of the entity
154
154
  * @returns {EntityInfo | null}
155
155
  */
156
156
  getByFq (fq) {
157
+ if (typeof fq !== 'string') fq = fq.plain
157
158
  if (this.#cache[fq] !== undefined) return this.#cache[fq]
158
- this.#cache[fq] = this.#resolver.isPartOfModel(fq)
159
+ return this.#cache[fq] = this.#resolver.isPartOfModel(fq)
159
160
  ? new EntityInfo(fq, this, this.#resolver)
160
161
  : null
161
- return this.#cache[fq]
162
162
  }
163
163
 
164
164
  /**
165
165
  * Convenience for getByFq when you are 100% sure the entity exists.
166
166
  * Serves to eliminate cumbersome null-handling where you know it's not necessary.
167
167
  * For example when fq is derived from a reference to another entity.
168
- * @param {string} fq - fully qualified name of the entity
168
+ * @param {import('../components/identifier').Identifier | string} fq - fully qualified name of the entity
169
169
  * @returns {EntityInfo}
170
170
  */
171
171
  getByFqOrThrow(fq) {
@@ -195,7 +195,7 @@ class EntityRepository {
195
195
  function asIdentifier ({info, wrapper = undefined, relative = undefined}) {
196
196
  const name = isType(info.csn)
197
197
  ? info.entityName
198
- : info.inflection.singular
198
+ : info.inflection.singular?.plain
199
199
 
200
200
  const wrapped = typeof wrapper === 'function'
201
201
  ? wrapper(name)