Skip to content

console-one/address

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

3 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

@console-one/address

Multi-dimensional addresses for objects.

A single object often has more than one identity at the same time: a path inside a tenant, a path inside an environment, a version on a release axis, a parent pointer for orchestration. Address collapses those identities into one ordered, serializable container so they travel together and survive a round-trip through string form.

user:/u/42@2;org:/o/acme;env:/prod@1

That string is one ObjectAddress. The first segment is the anchor — immutable, the thing the object "really is." The rest are secondary coordinates, each in its own dimension (user, org, env). Anything that knows about a dimension can ask the address for its path, version, and parent payload in that dimension; anything that doesn't can ignore them and just use the anchor.

Zero dependencies. Pure TypeScript. ESM.

Install

npm install @console-one/address

When to reach for this

Plain string IDs work fine until one of these starts to hurt:

  • The same logical entity needs to be located in several namespaces at once (tenant + environment + version axis), and you keep building parallel maps to keep them in sync.
  • You need a stable, parseable wire form for that compound identity (logging, caching keys, message envelopes).
  • Some callers care about the version, others don't — and you want both reads to be one-liners against the same value.
  • You want to "fork" an entity into a sibling location: same anchor, different env or version, without manually stitching the new identity together.

If a string is enough, use a string. If you've reached for a Map<TenantId, Map<EnvId, ...>>, you're in this package's territory.

Quick start

import { ObjectAddressImpl, Address, Addressed } from '@console-one/address';

// Construct directly
const a = new ObjectAddressImpl({ dim: 'user', path: '/u/42', version: 3 });
a.set({ dim: 'org', path: '/o/acme' });
a.set({ dim: 'env', path: '/prod', order: 1 }); // place env between user and org

a.toString();             // "user:/u/42@3;env:/prod;org:/o/acme"
a.getPath('org');         // "/o/acme"
a.isVersioned('user');    // true
a.dimensions();           // ["user", "env", "org"]

// Round-trip through a string
const b = ObjectAddressImpl.parse('user:/u/42@3;org:/o/acme');
a.equals(b, 'user');      // true (same dim+path+version on user)

// Tag a domain object with its address
type User = Addressed<'User', { username: string }>;
const ada: User = Addressed.create('User', { username: 'ada' }, [
  { dim: 'user', path: '/u/ada', version: 1, parent: { id: 'u1' } },
  { dim: 'org', path: '/o/eng' },
]);
ada.address.toString();          // "user:/u/ada@1;org:/o/eng"
ada.address.getParent('user');   // { id: "u1" }

Wire format

<dim>:<path>[@<version>][;<dim>:<path>[@<version>]]*
  • The first segment is the anchor and cannot be removed or reordered.
  • Segments are separated by ;. Order is preserved across parse / stringify.
  • @<version> is optional. The version slot accepts any number; non-numeric tails parse as "no version."
  • Empty input yields an addressless container (an anchor is materialised on first read at the default dimension).

Address.parse('user:/a/b@3') and Address.stringify(addr) are exact inverses on well-formed input.

Public surface

Single coordinates

  • Address — one {type, dim, path, version?, parent?, order?} record. Address.create, Address.parse, Address.stringify, Address.clone, Address.compare, Address.describes.
  • AddressLike — the loose "shape" used as input. AddressLike.parse(str) does the textual decode without building a full Address. AddressLike.describes(x) is the structural type guard.
  • AddressBuilder — fluent builder for an Address (.addDimension, .addPath, .addVersion, .addParent, .addOrder, .build).
  • AddressPath / RelativeAddressPath / AbsoluteAddressPath — string aliases with type guards. They're nominally distinct but structurally identical (both are strings); the split exists to document author intent at call sites.

Multi-dimensional containers

  • ObjectAddress / ObjectAddressImpl — the multi-dimensional container. The interface is read-side; the impl carries the mutators (set, remove, fork, setParentPayload, [Symbol.iterator]).
  • ObjectAddressImpl.parse(s) / .fromJSON(...) — accept a stringified form, a single Address, an array of addresses, or a {dim: {path,...}} map.
  • AddressSet — the toJSON() shape: {type: 'addressset', addresses: {[dim]: AddressLike}}. Useful as a wire form when you want a JSON object instead of the colon/semicolon string.
  • dim(name) / dims(...names) — small helpers for building dimension selectors used by fork().

Relative / pending coordinates

  • RelativeAddressSet — wire-form list of relative coordinates.
  • RelativeObjectAddress / RelativeObjectAddressImpl — a builder-style container of relative coordinates, indexable by dimension, used as input to fork.

Tagged objects

  • Addressable{...item, location | address} mixin for "give this domain object an identity later." Addressable.getAddress(item) resolves either the eagerly-parsed address or a lazily-generated one.
  • Addressed{type, address, ...child} for "give this domain object an identity right now." Addressed.create(type, child, refs) builds the full record.

Forking

fork() produces a new ObjectAddress derived from an existing one. The argument selects which dimensions to carry over and (optionally) overrides their paths or versions:

const base = ObjectAddressImpl.parse('user:/u/42@2;org:/o/acme;env:/prod@1');

base.fork();                              // deep copy of all three dims
base.fork([dim('env'), dim('user')]);     // subset, in the order given
base.fork('/elsewhere');                  // single new default-dim address
base.fork({ env: { path: '/staging' } }); // override env's path on the fork
base.fork(RelativeObjectAddressImpl.create({ dim: 'env', path: 'ignored' }));
                                          // pick dims via a relative address container

Override paths win; missing fields fall back to the corresponding dim on the base.

Layout

src/
├── address.ts               # Address, AddressLike, AddressBuilder
├── addresspath.ts           # path string aliases + guards
├── addressset.ts            # AddressSet — JSON wire form
├── addressable.ts           # Addressable mixin + getAddress helper
├── addressed.ts             # Addressed<Type, Child> tag
├── dimension.ts             # DefaultDimension, dim()
├── objectaddress.ts         # ObjectAddress, ObjectAddressImpl, fork, dims()
├── relativeaddressset.ts    # RelativeAddressLike, RelativeAddressSet
├── relativeobjectaddress.ts # RelativeObjectAddress, RelativeObjectAddressImpl
├── util.ts                  # uuid, splitFirst, splitLast (vendored)
├── smoke.ts                 # end-to-end test
└── index.ts                 # public surface

Notes on behavior

  • The anchor is immutable. Setting a new dimension with order: 0 throws; calling remove(anchorDim) returns false. Fork the address and re-anchor instead.
  • set is upsert. Calling set({dim, path}) on an existing dim merges field-by-field — only fields you provide overwrite the prior value. Calling it on a new dim requires path (we don't materialise pathless secondary refs).
  • get(dim) falls back to the anchor. If the requested dim isn't present, get returns the anchor instead of undefined. This keeps the read path branchless on the caller side; if you need a presence check, use has(dim).
  • fork(termArray) honours overrides. A term's path and version win over what's on the base. Dimensions you don't list aren't carried over.
  • getRoots() returns defensive clones. Mutating the returned array's entries does not affect the source.
  • Address.parse is total. Malformed-but-string input yields a best-effort Address; non-numeric versions become undefined. There is no throwing parser.
  • The string form is order-stable. parse then stringify is a no-op on a well-formed input.

Smoke test

npm run smoke exercises the package's full surface against node:assert. Anchor immutability, parse/stringify round-trips, fork variants, defensive copies, address-set JSON shape, the Addressable and Addressed mixins, and every code path on RelativeObjectAddressImpl. The build aborts publish if any assertion fails.

Known limitations

  • RelativeAddressPath and AbsoluteAddressPath are both string and structurally indistinguishable — the type guards return the same answer. The split is documentation, not enforcement.
  • fork accepts a {[dim]: {path, version, ...}} map, but the typed signature treats values as Omit<RelativeAddressLike, 'dim'>. In practice you'll often as any the value object until the input type is narrowed.
  • Versions are bare numbers. There is no opinion about monotonicity, semver, or vector clocks; comparison is ===.

License

MIT.

About

Multi-dimensional addresses for objects: stable anchor plus optional secondary coordinates (tenant, environment, version, parent). Compact textual form. Zero dependencies.

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors