@twinfinity/entity-reference
v6.2.0-beta
Published
Parser and builder for Twinfinity entity references
Maintainers
Readme
@twinfinity/entity-reference
Parser and builder for Twinfinity entity references — the one string spelling every service uses to name an entity, such as twin~twin:<uuid>.file:<uuid>/entities/<gid>. It is the TypeScript twin of the server's ScopedEntityReference and agrees with it character for character; both are held to the same conformance corpus.
The package has no dependencies. Everything is a free function over frozen plain objects, so values stay compatible even when an application ends up with two copies of the package.
Installation
bun add @twinfinity/entity-referenceUsage
Parsing and formatting
import { tryParse, parse, format, leaf, stem } from '@twinfinity/entity-reference';
const reference = tryParse(
'twin~twin:550e8400-e29b-41d4-a716-446655440000.file:6fa459ea-ee8a-4ca4-894e-db77e160355e/entities/3WoDmit2L9H8xguu5dNQPk'
);
if (reference !== undefined) {
reference.domain; // 'twin'
reference.components; // [{ type: 'twin', id: '550e…' }, { type: 'file', id: '6fa4…' }]
reference.path; // ['entities', '3WoDmit2L9H8xguu5dNQPk']
leaf(reference); // { type: 'file', id: '6fa4…' }
stem(reference); // everything before the first '/'
format(reference); // the original string; nothing is rewritten
}
parse('not a reference'); // throws a plain ErrortryParse returns undefined for input that is not well formed, and is the form to use for input you don't control. parse throws instead. There is no error class to catch.
Always decompose a reference with the parser, never with string operations. Splitting the whole string breaks across a path, because a path segment may contain :, ., ~ and spaces. Even on the stem alone, a split skips the character policy the parser enforces (text must be NFC, with no invisible or control characters), so use the parser. The grammar also bounds length at 512 code points.
Building
import { root, child, withPath, format } from '@twinfinity/entity-reference';
const twin = root('twin', 'twin', '550e8400-e29b-41d4-a716-446655440000');
const file = child(twin, 'file', '6fa459ea-ee8a-4ca4-894e-db77e160355e');
format(file); // 'twin~twin:550e8400-….file:6fa459ea-…'
const gid = withPath(file, 'entities', '3WoDmit2L9H8xguu5dNQPk');
format(gid); // 'twin~twin:550e8400-….file:6fa459ea-…/entities/3WoDmit2L9H8xguu5dNQPk'root, child and withPath throw when the grammar refuses a part, such as an id containing . or /, rather than splicing it in, and when the result would exceed 512 code points. child also throws on a reference that has a path. Build references with these instead of interpolating strings: add a path with withPath, never by formatting a reference, appending to the string and parsing it again. withPath also extends a path that is already there, and parent undoes one appended segment.
For ids you don't control, tryRoot, tryChild and tryWithPath return undefined where the others throw. They take no version: build a reference with a version qualifier through root and child. tryChild and tryWithPath also accept undefined and null, returning undefined for either, so they chain without a check in between:
import { tryRoot, tryChild, tryWithPath } from '@twinfinity/entity-reference';
tryWithPath(tryChild(tryRoot('twin', 'twin', twinId), 'file', fileId), 'entities', gid); // EntityReference | undefinedContainment and cardinality
import { parse, isUnder, parent, prefixes, cardinalityOf } from '@twinfinity/entity-reference';
const twin = parse('twin~twin:550e8400-e29b-41d4-a716-446655440000');
const gid = parse(
'twin~twin:550e8400-e29b-41d4-a716-446655440000.file:6fa459ea-ee8a-4ca4-894e-db77e160355e/entities/3WoDmit2L9H8xguu5dNQPk'
);
isUnder(gid, twin); // true
isUnder(twin, twin); // true; a reference is under itself
isUnder(parse('twin~twin:XY'), parse('twin~twin:X')); // false, where startsWith would say true
parent(gid); // drops the last path segment first, then components
prefixes(gid); // every ancestor, outermost first
cardinalityOf(gid); // 'many': a reference with a path names a collection
cardinalityOf(twin); // 'one'Use isUnder for containment, not startsWith. A version (@5) counts as the same entity, so twin~twin:X@5 is under twin~twin:X. To compare regardless of version, use withoutVersions.
Bare domains
A reference can also name a whole domain, e.g. twin, which is how a customer-wide grant is spelled. This arm has its own type and its own parser, and the two parsers are disjoint: tryParse('twin') and tryParseDomain('twin~twin:X') both return undefined.
import { tryParse, tryParseDomain, forKnownDomain } from '@twinfinity/entity-reference';
const input = 'twin';
const parsed = tryParse(input) ?? tryParseDomain(input); // EntityReference | EntityDomainReference | undefined
forKnownDomain('twin'); // ok
forKnownDomain('folder'); // throws: well formed, but no such domaintryParseDomain checks the shape only and accepts any lowercase word of three or more letters. forKnownDomain also checks the word against DOMAINS, the closed allowlist (twin, documents, activity, forum, permission).
Shapes
Parsing is layer 1 and knows no domain. Layer 2 checks a reference against the platform's shape table. It matches the whole chain and the id spellings, never the leaf alone:
import { parse, matchShape, ALL_SHAPES } from '@twinfinity/entity-reference';
matchShape(parse('twin~twin:550e8400-e29b-41d4-a716-446655440000'), ALL_SHAPES); // the twin shape
matchShape(parse('twin~twin:not-a-uuid'), ALL_SHAPES); // undefined: parses, but no shape claims itaddressable(reference, shapes) returns the reference itself when a shape claims it and undefined otherwise. It accepts undefined and null, so it closes a chain of try* builders: addressable(tryRoot('twin', 'twin', twinId), ALL_SHAPES). Like matchShape, it refuses a reference carrying a version qualifier the shape does not support, unless passed the 'ignore' version policy.
URLs
References travel as query parameters. toUriComponent percent-encodes one, and fromUriComponent decodes and parses it, throwing if the result is not well formed.
Queries
parseQuery / tryParseQuery read the entity query language: the stem grammar plus the wildcards type:* and * and the ... gap, e.g. forum~forum:<uuid>.... parse never accepts a wildcard, so a query can never be mistaken for a reference. See ADR 0005.
Specification
The grammar and the character policy are defined in
Entity References. The shape table is in
Entity Reference Spellings; ALL_SHAPES
mirrors PS.BIM.Core.Model.EntityShapes. The rationale is in
ADR 0004 (addressing) and
ADR 0005 (queries).
Exports
The package has two export paths:
@twinfinity/entity-reference- Public API: parsing, formatting, building, containment, bare domains, shapes and queries@twinfinity/entity-reference/internal/*- Internal modules (no stability guarantees)
