@x12i/xmemory-relations
v1.7.0
Published
Typed, equivalence-aware associations between Things for XMemory
Readme
@xronoces/xmemory-relations
Typed, equivalence-aware associations between Things for XMemory. Stores and queries directed and undirected edges with filters and pagination. Persistence via nx-mongo or @xronoces/xronox; thing identity via @xronoces/xmemory-equal.
Published: GitHub Packages (@xronoces scope). Configure .npmrc with @xronoces:registry=https://npm.pkg.github.com and auth token for npm.pkg.github.com.
Features
- Upsert/delete directed and undirected associations (idempotent by unique identity)
- Query with filters (type, direction, confidence, tags, source) and pagination
- Canonical by default: write and read use canonical thing IDs so equivalent aliases do not create duplicate edges
- Optional read modes:
canonical,expand(all equivalents), ornone - ThingRef: supports
thingType(e.g.question,product_name) and optionalexternalIdentifier(e.g.jira:PROJ-123,github:issue:owner/repo#14) for “go back to source” pointers; passed through to xmemory-equal - Endpoints:
getAssociationsandgetEdgereturn fullThingRecords forfrom/to, includingthingTypeandexternalIdentifierwhen provided by xmemory-equal (e.g. when it exposesgetThingById) - Edge-centric retrieval:
getEdgeById(edgeId),getEdgesByIds(edgeIds),getAssociationsBetween(a, b, query?),findEdges(query)(by type, direction, endpoint IDs, confidence, source, metadata), andgetRelatedNodes(thing, query?)(deduped neighbor nodes + edges) - Scope-aware filtering: retrieval uses xEqual-assisted filtering (
readEqualityMode: canonical / expand / none). Optional endpoint scope (scope: { db?, collection? }) and endpointThingTypes so Scoper can stay within scope; filter by sessionId / drivingFieldRelationId for mapper - Bulk upsert:
bulkAssociate(items)for mapper and batch workloads; idempotent per edge - Mapper metadata: optional
sessionId,drivingFieldRelationIdon associations for mapper use; stored as top-level fields and filterable infindEdges - Data tier: pass an nx-mongo–backed provider (e.g.
SimpleMongoHelper) or use createXronoxRelationsProvider(xronox, { role }) to run on @xronoces/xronox with the same role as the rest of the stack (e.g.mapping_metadata).
Install
npm install @xronoces/xmemory-relations @xronoces/xmemory-equal nx-mongoFor xronox as data tier (optional):
npm install @xronoces/xmemory-relations @xronoces/xmemory-equal @xronoces/xronoxConfigure .npmrc for GitHub Packages:
@xronoces:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=YOUR_GITHUB_TOKENRequirements
- Data tier: either
- nx-mongo: initialized (e.g.
SimpleMongoHelper) with your MongoDB connection; relations use it for the{prefix}_associationscollection; or - xronox: use
createXronoxRelationsProvider(xronox, { role: "mapping_metadata" })and pass it asnxMongo. The role must match a binding in your xronox config. Limitation:removeAssociation(delete) is not supported when using the xronox provider (xronox does not expose delete); use nx-mongo for full CRUD if you need deletes.
- nx-mongo: initialized (e.g.
- @xronoces/xmemory-equal:
EqualClientcreated with the same namespace and a compatible data tier. Used forensureThing,resolve, andgetEquivalents. If it exposesgetThingById(id), relations use it to return full thing records (includingexternalIdentifier) for association endpoints; optionalgetThingByExternalIdentifier(externalId, namespace?)allows querying by external id ingetAssociations(thing).
ThingRef and ThingRecord
Inputs use ThingRef (from xmemory-equal): namespace, thingType, kind, ref, and optional externalIdentifier. Thing identity is (namespace, thingType, kind, refNorm); externalIdentifier is a stable pointer to an external system (e.g. jira:PROJ-123, confluence:page:123, futurox:question:uuid). All association APIs accept ThingRef and pass it through to equal.
Returned ThingRecords (on AssociationRecord.from and .to) include id, namespace, thingType, kind, refRaw, refNorm, optional externalIdentifier, and timestamps. When xmemory-equal provides getThingById, relations use it so endpoints are full records from equal; otherwise a minimal record shape is returned.
Config and env
The client can read these from process.env (optional overrides in config):
| Env | Description | Default |
|-----|-------------|--------|
| XMEMORY_WRITE_EQUALITY_MODE | canonical or none | canonical |
| XMEMORY_READ_EQUALITY_MODE | canonical, expand, or none | canonical |
| XMEMORY_NAMESPACE | Resolved namespace (shared with equal) | — |
| XMEMORY_COLLECTION_PREFIX | Collection name = {prefix}_associations | xmemory |
Important: This package does not read MONGO_URI or MONGO_DB. You configure and initialize nx-mongo yourself and pass it in.
Database for associations (required)
Associations are written to the {prefix}_associations collection in a single MongoDB database. You must specify which database in one of two ways; otherwise the client throws at creation time to avoid writing to the wrong DB (e.g. the connection default admin):
Explicit
databasein config: passdatabase: "xmemory-meta"(or your DB name). Use this when your nxMongo helper does not expose the target DB name.Provider with
getDatabaseName(): implement optionalgetDatabaseName?: () => stringon the object you pass asnxMongo. The client will call it whendatabaseis not set and use the returned name for all reads/writes. Example wrapper when your helper hasgetDb():nxMongo: { ...metaHelper, getDatabaseName: () => metaHelper.getDb().databaseName, }
If neither database nor nxMongo.getDatabaseName() is provided (or getDatabaseName() returns undefined/empty), createRelationsClient throws: "RelationsConfig: database must be set or nxMongo must provide getDatabaseName() so associations are not written to the wrong database."
Usage
With nx-mongo
import { SimpleMongoHelper } from "nx-mongo";
import { createEqualClient } from "@xronoces/xmemory-equal";
import { createRelationsClient } from "@xronoces/xmemory-relations";
const helper = new SimpleMongoHelper(process.env.MONGO_URI!);
await helper.initialize({ databaseName: process.env.MONGO_XMEMORY_DB });
const nxEqual = createEqualClient({
nxMongo: { getDb: () => helper.getDatabaseByName(process.env.MONGO_XMEMORY_DB!), withTransaction: (cb) => helper.withTransaction(cb) },
namespace: "my-namespace",
defaultThingType: "generic",
});
await nxEqual.ensureIndexes();
const relations = createRelationsClient({
nxMongo: helper,
nxEqual,
namespace: "my-namespace",
database: process.env.MONGO_XMEMORY_DB,
});
await relations.ensureIndexes();
// Directed association (thingType and optional externalIdentifier)
await relations.associate(
{ namespace: "my-namespace", thingType: "person_name", kind: "literal", ref: "user-1" },
{ namespace: "my-namespace", thingType: "person_name", kind: "literal", ref: "user-2" },
"follows",
{ direction: "directed", confidence: 0.9 }
);
// Undirected
await relations.associate(thingRef("a"), thingRef("b"), "related", { direction: "undirected" });
// Query
const result = await relations.getAssociations(thingRef("user-1"), {
types: ["follows"],
direction: "outgoing",
limit: 50,
offset: 0,
});
// Remove
const { deleted } = await relations.removeAssociation(thingRef("a"), thingRef("b"), "related", { direction: "undirected" });
// Single edge (record.id is the Mongo _id when loaded from store)
const edge = await relations.getEdge(thingRef("a"), thingRef("b"), "related", { direction: "undirected" });
// Edge-centric: by id, between two nodes, or find by type/metadata
const one = await relations.getEdgeById(edge!.id);
const between = await relations.getAssociationsBetween(thingRef("a"), thingRef("b"), { types: ["related"] });
const { edges } = await relations.findEdges({ types: ["follows"], limit: 20 });
const { nodes, edges: nodeEdges } = await relations.getRelatedNodes(thingRef("user-1"), { types: ["follows"] });With xronox (same role as mapper / equal)
When the rest of the stack uses @xronoces/xronox, use createXronoxRelationsProvider so relations use the same role and config:
import { createXronox } from "@xronoces/xronox";
import { createEqualClient } from "@xronoces/xmemory-equal";
import { createRelationsClient, createXronoxRelationsProvider } from "@xronoces/xmemory-relations";
await xronox.init({ engine: "nxMongo", engineConfig: { nxMongo: { connectionString: process.env.MONGO_URI } } });
const nxEqual = createEqualClient({ nxMongo: /* your equal provider */, namespace: "my-namespace" });
const nxMongo = createXronoxRelationsProvider(xronox, { role: "mapping_metadata" });
const relations = createRelationsClient({
nxMongo,
nxEqual,
namespace: "my-namespace",
});
await relations.ensureIndexes();
// associate, getAssociations, findEdges, bulkAssociate work as usual.
// removeAssociation is not supported with xronox provider (throws); use nx-mongo if you need deletes.API summary
createRelationsClient(config)→RelationsClient- Required:
nxMongo,nxEqual,namespace - Database: either pass
database(string) or use annxMongoprovider that implementsgetDatabaseName(). If neither is set, creation throws so associations are not written to the wrong DB. - Optional:
collectionPrefix,writeEqualityMode,readEqualityMode
- Required:
associate(from, to, type, opts?)→Promise<AssociationRecord>
Upserts one association (directed or undirected). Optional:direction,confidence,weight,source,tags,reason,sessionId,drivingFieldRelationId.bulkAssociate(items: BulkAssociateItem[])→Promise<AssociationRecord[]>
Bulk upsert (required for mapper). Each item:{ from, to, type, opts? }. Idempotent per edge.removeAssociation(from, to, type, opts?)→Promise<{ deleted: number }>
Deletes by identity (0 or 1).getAssociations(thing, query?)→Promise<AssociationQueryResult>
Filters:types,direction(incoming/outgoing/both),confidenceMin/confidenceMax,tagsAny,source. Scope-aware:scope?: { db?, collection? },endpointThingTypes?: string[](only edges whose both endpoints match). Pagination:limit(default 50, max 500),offset. OptionalreadEqualityModeoverride. Ifthinghas onlyexternalIdentifier(and namespace), relations resolve it via equal’sgetThingByExternalIdentifierwhen available. Returns{ items, total, limit, offset }with full thing records for endpoints (includingthingTypeandexternalIdentifierwhen provided by equal).getEdgeById(edgeId)→Promise<AssociationRecord | null>
Returns the association document by its Mongo_id(hex string). Returnsnullfor invalid or missing id.getEdgesByIds(edgeIds)→Promise<AssociationRecord[]>
Returns association records for the given edge ids (invalid ids are skipped).getAssociationsBetween(a, b, query?)→Promise<AssociationRecord[]>
Returns all edges between two things (directed both ways + undirected). Optionalquery:types,limit,offset.findEdges(query)→Promise<FindEdgesResult>
Edge-centric query:types,direction(directed/undirected/any),endpointThingIds,confidenceMin/confidenceMax,source,sessionId,drivingFieldRelationId,metadataEquals(key-value match on top-level fields). Scope-aware:scope?: { db?, collection? },endpointThingTypes?: string[].limit,offset. Returns{ edges, total, limit, offset }. Required for mapper/scoper reporting.getRelatedNodes(thing, query?)→Promise<GetRelatedNodesResult>
Same filters asgetAssociations; returns{ nodes, edges, total, limit, offset }with deduped neighbor nodes (useful for UI and mapper reports).ensureIndexes()→Promise<void>
Ensures unique and query indexes on the associations collection (with partial filters for directed vs undirected).getEdge(from, to, type, opts?)→Promise<AssociationRecord | null>
Equality modes
Write
- canonical (default):
ensureThingthenresolveto canonical IDs before storing → no duplicate edges across equivalent things. - none: store using the IDs returned by
ensureThing(no resolve).
- canonical (default):
Read
- canonical (default): resolve the input thing to one canonical ID and query that.
- expand: resolve to all equivalent IDs, query all, dedupe (max group size 500; throws
ExpandLimitExceededErrorif exceeded). - none: query the single thing ID only.
Scope-aware retrieval and multi-DB
- Scope filter (
scope: { db?, collection? }): When provided ingetAssociations(thing, query)orfindEdges(query), only edges whose both endpoints (Things) match the givendband/orcollectionare returned. Scope lives on Things in@xmemory/equal; relations resolve endpoints viagetThingByIdand filter by optionaldb/collectionon the returnedThingRecord. If equal does not store or return these fields, scope filtering has no effect. - Endpoint thing types (
endpointThingTypes: string[]): Only edges whose both endpoints havethingTypein this list are returned. - Multi-DB limitation: Relations store only Thing IDs; they do not store database or collection names. Scope (db/collection) is resolved from
@xmemory/equalat read time. Cross-DB or multi-dB edge traversal is therefore limited to what equal can resolve per namespace; when using multiple MongoDB databases or collections for Things, ensure equal exposesdb/collectionon ThingRecords and thatgetThingById(or optionallistThings) is consistent with that model.
Collection and indexes
- Collection:
{collectionPrefix}_associations(e.g.xmemory_associations). - Directed docs:
namespace,direction: "directed",type,fromThingId,toThingId, plus optional metadata (confidence,weight,source,tags,reason,sessionId,drivingFieldRelationId) and timestamps. - Undirected docs: same but
leftThingId/rightThingId(min/max of the two thing IDs). - Unique indexes (with partial filter by direction) and query indexes for outgoing/incoming/undirected lookups.
- Each document has a Mongo
_id; usegetEdgeById(edgeId)with the hex string of_id(e.g. fromgetEdge(…).id).
See docs/specs.md for the full specification.
Errors
NamespaceMismatchError: config namespace does not match the one expected for the Equal client.ExpandLimitExceededError: expand-mode read would exceed the max equivalence group size (500).
License
ISC
