Releases: linked-fw/core
Release list
v2.18.1
v2.18.0
Minor Changes
-
#208
085cfadThanks @flyon! - Ask queries — a first-class query kind whose answer is a boolean — plus twordf:type/sh:path
resolution fixes it surfaced.Ask queries
.exists()is a shortcut for an ask, not a special case of select. An ask carries a pattern and
nothing else — no projection, sorting or pagination — at every layer:AskBuilder,IRAskQuery,
anop: 'ask'wire envelope, andASK WHERE { … }in SPARQL.await Person.exists({ id }); // ASK { ?a0 a <PersonClass> . FILTER(?a0 = <id>) } await Person.select() .where((p) => p.name.equals("Semmy")) .exists(); // ASK with the filter await Shape.exists(uri); // ASK { <uri> ?p ?o }
Shape.exists(uri)on the base class asks whether a node exists at all — nordf:type
constraint, under any shape or none. (Shapeis free to mean "anything": the shapes themselves are
described byNodeShapeandPropertyShape.) A shapeless ask has no shape to route on, so
LinkedStorageasks every dataset it knows and ORs the answers, short-circuiting on the first
true— cheap precisely because the answers are booleans. Any router implementingIDataset
inherits that obligation: a shapeless ask means "anywhere I can reach", not "in my default store".Breaking:
IDataset.askQuery(query: AskQuery): Promise<boolean>is requiredEvery store must implement it;
query.shapeis optional. No code path in this package rewrites an
ask as a select — a store with no boolean primitive decides for itself how to answer, and
defaulting that here would hide the choice.askQuerymust resolve to a real boolean (a non-boolean
is rejected, not coerced, since a truthy value would read as "exists") and must reject on failure.Wire format
1.1An ask travels as its own envelope, discriminated by
op: 'ask':{"v": "1.1", "op": "ask", "shape": "…/Person", "subject": "…/p1"} {"v": "1.1", "op": "ask", "subject": "https://example.org/thing"}Omitting
shapeis the shapeless form. There is nofields,limit,offset,sortByor
one, so a receiver has nothing to validate or ignore.fromJSONroutesop: 'ask'to an
AskBuilderand still throwsUnknown query opon anything unrecognised, so an older peer fails
loud rather than reinterpreting the envelope as a select. Deploy receivers first.New exports:
AskBuilder,isAskQuery, and theAskQuery/AskQueryJSON/RawAskInput/
AskSpectypes.lower()gains an ask overload returningIRAskQuery;askToAlgebra/
askToSparql/askPlanToSparql/SparqlAskPlan/mapSparqlAskResultare the SPARQL arm.Breaking: a shape must declare a
targetClassA query or mutation on a shape with none — on it or on any shape it extends — now throws instead of
silently typing instances with the shape's own IRI.@linkedShape class Person extends Shape { static targetClass = { id: "https://example.org/Person" }; // required }
rdf:typenames the class a node is; the shape IRI identifies the SHACL description of that
class — a different node. Substituting one for the other conflated them, and did so invisibly: read
and write used the same substitution, so data round-tripped and nothing surfaced the mistake.
targetClassis read off the shape class, so JavaScript static inheritance already walks the
superclass chain.A declared
sh:pathis always the predicateThe resolver skipped any shape or property IRI beginning
linked://tmp/, substituting the property
shape's own "shadow" IRI. That skip existed only so this repo's fixtures could assert shape-derived
predicates; it is gone, along with thelinked://tmp/special case. Two mutation paths that built
traversal predicates by hand — bypassing the resolver — now go through it, fixing an expression
update (p.bestFriend.name) that emitted the shadow IRI as a predicate and therefore matched
nothing.Migration
- Declare a
targetClasson any shape lacking one. Data written under the old behaviour is typed
with the shape IRI: either set that IRI as thetargetClass, or retype the nodes. - Implement
askQueryon anyIDataset. - A shape declaring a
linked://tmp/path gets its declared path as the predicate instead of the
shadow IRI. No released code minted such IRIs, so this is expected to affect nobody.
- Declare a
-
#214
67e015bThanks @flyon! - One shape metamodel, and inheritance that works for shapes known only as data.NodeShapeDatagains a JSON-safe transport form (toWire/fromWirein
shapes/nodeShapeWire.ts). It is defined by subtraction from the metamodel — drop the
circularparentNodeShapeback-reference, carrypatternas its source string and flags
— so new metamodel fields are carried automatically instead of a hand-maintained subset
falling behind.PathExpris already a plain discriminated union, so complex SHACL paths
(sequence, alternative, inverse, the cardinality operators, negated property sets) survive
a round trip intact rather than collapsing to a single IRI.Display vocabulary:
linked_core:displayRank(a single linear importance rank, lower =
more important) andlinked_core:displayHidden, accepted by the property decorators,
carried onPropertyShapeData, registered as meta-shape properties and serialized by
syncShapes. They materialize onto the puresh:NodeShape, so they travel with an
ejected app.Fixes
orderandgroup, which were declared onPropertyShapeConfigbut never copied
onto the property shape — a declaredsh:orderwas silently dropped and renderers fell
back to array position.getSuperShapesis now the single canonical inheritance walk, andgetPropertyShapes,
getPropertyShapeand the class-returning helpers all delegate to it: the prototype chain
for a class-backed shape (which includes the frameworkShaperoot, whoselabeland
typereally are inherited),extendsthrough the shape registry for a shape with no
compiled class. Previously the latter case returned only own properties, so a
project-authored shape silently lost everything it inherited (backlog 040). Since
getPropertyShapeByLabeldelegates togetPropertyShape, the query proxies are fixed
too.Adds a primary IRI to
NodeShapeDataregistry alongside the class registry, so query
lowering and predicate resolution work for both kinds of shape;
registerNodeShape/getNodeShape/getAllNodeShapesand the data-based
getSuperShapes/getSubShapes/isSubShapeOfare exported.
SelectBuilder.from(iri)now resolves a shape that exists only as data.Cache invalidation moves from a
setTimeoutplus registry-size comparison to a monotonic
version counter — the old scheme silently reused a stale cache when a registration and a
removal coincided, or when a shape was re-registered in place.Adds
registerRuntimeShape/registerRuntimeShapes: register a shape that exists only
as data, taking metadata (NodeShapeDataor its wire form) rather than a bespoke DTO.
registerRuntimeShapesorders a batch parents-first, because inheritance resolves
extendsthrough the registry and a child registered ahead of its parent would resolve an
empty chain. Neither shadows a compiled class.Query lowering, containment resolution, blank-node deletion and mutation lowering all read
the shape registry rather than the class registry, so a shape that exists only as data
lowers to the same SPARQL a compiled one does — with its declaredtargetClass(walking
extendswhere it is inherited) and its declaredsh:pathas the predicate.validate()
accepts such a shape as registered. The three lowering caches key on the registration
version instead of the class registry's size, which did not change when a shape was
re-registered in place.The SHACL meta-model's
sh:equalsaccessor is relabelledequalsConstraint, matching the
PropertyShapeDatafield (the predicate is unchanged).equalsis a query-builder method,
and the query proxy answers a key from its own surface before it looks for a property with
that label — so a property labelledequalsreturned the DSL method and the field tracer
failed on a native function. The meta-shape could not read its own constraint. Anything
looking a constraint up ingetPropertyShapeTerms()by the labelequalsmust now ask for
equalsConstraint.Registration now reports the general case:
registerPropertyShapeand
registerRuntimeShapecheck each label against the query DSL surface
(RESERVED_QUERY_DSL_NAMES) and warn once per shape+label, naming the shape, the property
and why selecting it will fail. It warns rather than throws —size,idandsomeare
legitimate domain property names, such a property still round-trips and is still reachable
by path through DSL-JSON, and throwing would break existing apps on upgrade.The reserved-name warning distinguishes the two proxy surfaces. A name on
QueryShapeis
always shadowed; a name only onQueryShapeSet—size,some,every,where,add,
concat,none— is fine to read directly and only shadowed when the shape is reached
through a multi-valued property. Both messages now name the existing escape hatch,
select(['size']), which takes the label as a string and never touches the proxy.
v2.17.0
Minor Changes
-
#198
4a3f607Thanks @flyon! - AddcanonicalPathKey(expr)— a stable, prefix-independent identity for a property path.A
PathExprneeds a scalar form whenever it is used as an identity: keying a map of properties, comparing two paths, or naming a property across a process boundary.pathExprToSparqlcannot serve that purpose — it renders for humans and for queries, shortening IRIs viaformatUri, so the same path serialises differently depending on which prefixes happen to be registered in the current process.import { canonicalPathKey } from "@_linked/core/paths/pathExprToSparql"; canonicalPathKey("https://schema.org/name"); // 'https://schema.org/name' canonicalPathKey({ id: "https://schema.org/name" }); // 'https://schema.org/name' — same key canonicalPathKey({ seq: [a, b] }); // '<a>/<b>' canonicalPathKey({ inv: a }); // '^<a>'
Absolute IRIs, always, never prefixed, so a catalog written in one process matches the same catalog read in another. A simple path returns the bare predicate IRI, so single-predicate property identities are unchanged and only complex paths gain a new spelling. Note it is an identity, not a round-trippable path: a bare IRI is not valid property-path syntax, so a simple key cannot be fed back to
parsePropertyPath(complex keys can).Fixes:
normalizePropertyPaththrew on any bare absolute IRI.'https://schema.org/name'contains/, so it matched the path-operator test and was handed to the path parser, which then failed on the//in the scheme. This was invisible for as long as paths arrived asNamedNodes or prefixed names from decorators, and appears the moment a plain IRI string is used — which is every simple property in a shape catalog. A hierarchical IRI is now distinguished from a prefixed-name sequence, so'ex:a/ex:b'still parses as a sequence and'<a>/<b>'still parses as an expression.PropertyShapeConfig.pathnow documents all four accepted forms, including that an ontology term is passed directly (documents.confidence) and never via.id— which would unwrap it back to the bare string. -
#199
ec22ee1Thanks @flyon! -syncShapescan scope its orphan sweep to the namespaces it owns.The sweep previously pruned every store-only shape it found. In a multi-writer dataset — an app-data store written by more than one package — that means one writer's sync deletes shapes another writer legitimately owns.
await syncShapes(shapes, { orphanScope: "ownedNamespaces" });
'all'(default, unchanged) — prune every store-only shape. Correct when the sync is the sole writer.'ownedNamespaces'— only prune shapes in namespaces this sync owns.'none'— never prune.
Additive: omit the option and behaviour is exactly as before.
-
#201
fe7ed7dThanks @flyon! -xsd:timeproperties take a pattern-checked string, written as a typed literal.Breaking for
xsd:timeonly: aDateon anxsd:timeproperty is now rejected.xsd:dateandxsd:dateTimeare unchanged and still take aDateand only aDate.A time of day is not an instant. Using
Datefor one means inventing a date to carry it: the date half is meaningless, is discarded during serialisation anyway, and makes two identical clock times recorded on different days compare unequal. JavaScript has no time-only type —Temporal.PlainTimeis the right answer and is not yet available — so the lexical form is the honest representation.@literalProperty({path: schedule.startsAt, datatype: xsd.time, maxCount: 1}) get startsAt(): string { return ''; } Appointment.create({startsAt: '14:30:00'}); // <…> <…#startsAt> "14:30:00"^^xsd:time .
Accepted:
HH:MM:SS, optional milliseconds, optionalZor±HH:MMoffset —'14:30:00','14:30:00.250','14:30:00Z','14:30:00.250+02:00'. Ranges are enforced by the pattern (hours00-23, minutes and seconds00-59), so'25:00:00'is rejected rather than written as a malformed literal that no engine will match — a failure that otherwise surfaces as "the data is simply missing". The optional timezone is accepted because it is validxsd:time; rejecting'14:30:00Z'would make the check stricter than the datatype it validates.The serialisation half matters as much as the validation. Mutation literals are typed from the JavaScript type when they reach SPARQL, so a plain string would be written as a plain literal and silently stop matching the property it was meant to fill. A string on an
xsd:timeproperty is now typed from the declared datatype instead.That behaviour is driven by an explicit allow-list rather than "type every string from whatever is declared". A string reaching a numeric or boolean property is a mistake
assertValidrejects; typing it from the declaration would instead write a plausible-looking"abc"^^xsd:integerand hide the error in the data. Only datatypes for which a string is a valid lexical form belong in the list.
Patch Changes
-
#200
0fc0fcfThanks @flyon! - Temporarily relax thenew Shape()constructor guard.The guard added in the shape-instantiation work rejects
new SomeShape()outright, on the principle that shapes are metadata rather than data. That principle stands — but several framework classes legitimatelyextends Shapeand are constructed as runtime service objects (LinkedServer,BackendAPIStore,LocalFileStore,LincdAPI,LincdWebApp), and the guard crashes a consuming backend at boot.The constructor returns to its pre-guard behaviour: it accepts an optional node reference and sets
id, mirroringcreateShapeTarget.validate()/assertValid()from the same release are untouched — only the constructor throw is deferred.This is a deliberate, temporary relaxation, kept as one revertible commit. Re-enable the guard once those classes move to composition or a non-
Shapebase.
v2.16.1
Patch Changes
-
#194
2cef8f7Thanks @flyon! -validate()no longer passes silently when it cannot resolve a shape.Neither inheritance nor nesting is carried inside a
NodeShapeData: a subclass'spropertyShapesholds only its own, and a property'svalueShapeis a bare{id}. The validator resolves both through the shape registry by id — so a caller holding only decorator-generated shape objects can validate with those alone, andvalidate(Slide, data)andvalidate(Slide.shape, data)return the same report. Passing a shape class remains supported; it was never required.When such a lookup fails — a shape object whose id was never registered, e.g. one deserialized in a process where the shape definitions were never loaded — the result used to be a false clean bill of health. A shape whose id was unregistered lost its inherited property shapes, so required inherited properties went unchecked and any that were supplied were reported as undeclared keys; an unresolvable nested value was skipped entirely, letting a node report
conforms: trueon the strength of a branch that was never looked at.Both now produce an
sh:NodeConstraintComponentviolation naming the shape that could not be resolved:Cannot validate the value of 'author': its shape '…/Author' is not registered. Cannot validate the value of 'anything': the property declares no shape for its values. Add a 'shape' to its @objectProperty decorator, or give the value a 'shape' key.The second case also catches something that previously passed validation and then threw during normalization: a plain object supplied to a property that declares no shape for its values.
v2.16.0
Minor Changes
-
#188
a05a59bThanks @flyon! - New:validate(shape, data)— SHACL-aligned validation of a plain object against a shape, returning every violation at once instead of throwing on the first.New root exports:
validate,assertValid,ShapeValidationError, and the typesValidationReport,ValidationResult,ValidationMode,ValidateOptions,ValidatableShape.import { validate } from "@_linked/core"; const report = validate(Slide, extractedFromDocument); report.conforms; // false report.results[0]; // { // sourceConstraintComponent: {id: 'http://www.w3.org/ns/shacl#MinCountConstraintComponent'}, // resultSeverity: {id: 'http://www.w3.org/ns/shacl#Violation'}, // resultMessage: "Property 'title' requires at least 1 value(s), but none were provided.", // resultPath: {id: '…/props/title'}, // propertyPath: 'title', // }
No builder, no store round-trip. Pass
{mode: 'partial'}to check only the values provided (an update) rather than the whole node (a create), and{maxDepth}to bound descent into nested creates.assertValid()is the throwing form;ShapeValidationError.reportcarries the same report.Each result is one
sh:ValidationResultunder SHACL's own property names, with IRI-valued fields as{id}node references — so a report can be persisted by an ordinary create query against shape classes forsh:ValidationReport/sh:ValidationResult, with no transform step.Constraints now enforced on writes that were previously parsed and serialized but never checked:
sh:datatype,sh:minInclusive/sh:maxInclusive/sh:minExclusive/sh:maxExclusive,sh:minLength/sh:maxLength,sh:pattern, andsh:in. These check the value in hand, so they apply to creates, updates, and the values inside a{add: […]}set modification.Mutations arriving as DSL-JSON are validated too.
lowerMutationJSONpreviously checked only that each property existed on the shape, so an inbound mutation was held to a weaker standard than a locally-built one. It now runs the same validator,completefor creates andpartialfor updates.Behavioural changes to review before upgrading:
Shape.create(data).toJSON()now throws when a required (minCount >= 1) property is missing. Previously onlylower()andexec()did —toJSON(),lower()andexec()now reject identical input.- Required properties of nested creates are now checked; previously unchecked at any level.
- A failing mutation reports every violation rather than the first. Individual messages are unchanged; the aggregated
Missing required fields for 'X': a, bmessage is replaced by one result per property. - A mistyped literal is now rejected:
{age: '42'}on anxsd:integerproperty throws. This is a correctness fix — mutation literals are typed from the JavaScript value when they reach SPARQL, so that string was being written as an untyped literal. - Properties declared
xsd:date,xsd:dateTimeorxsd:timeaccept a JavaScriptDateand nothing else; a lexical string is now a violation. - The serializer honours the declared
sh:datatypefor temporal and numeric literals. OneDatebecomes"2020-06-15"^^xsd:dateon anxsd:dateproperty and a full timestamp on anxsd:dateTimeone; a property declaredxsd:longorxsd:decimalnow emits that datatype instead of thexsd:integer/xsd:doubleinferred from the value. Neither the IR nor the wire format changed.
See
docs/reports/027-shape-validation-report.mdfor the full mapping tables, design decisions, and known limitations.
v2.15.1
Patch Changes
-
#182
f3f2c4aThanks @abdipramana! - Preserve child property keys when lowering nested array selections. Queries such asAction.select(action => ({image: action.image.select(image => [image.contentUrl])}))now map the nested value toimage.contentUrlinstead of incorrectly returning it asimage.image. -
#183
81c7b56Thanks @abdipramana! - Preserve concrete nested shapes when serializing polymorphicpreloadFor()queries. A preload such asperson.pets.as(Dog).preloadFor(DogCard)now records theDogshape IRI on the wire, allowing fields defined only onDogto resolve correctly after client-server deserialization.
v2.15.0
Minor Changes
-
#177
e5373fdThanks @flyon! - Shapes are now metadata-only:Shapesubclasses can no longer be instantiated, and SHACL metadata is exposed as plain objects.Behavioral change —
new SomeShape()throws. Constructing anyShapesubclass now throws a clear error steering you to the DSL (Shape.select(...),.create(...),.update(...),.delete(...)). Shapes never carried live data — their decorated getters only returned typing stubs — so this turns a silent footgun into a loud error. All querying and mutation continues to go through the DSL exactly as before.Metadata is plain objects.
SomeShape.shapeand each property shape are now plainNodeShapeData/PropertyShapeDataobjects (importable as types from@_linked/core), not class instances. The formerNodeShape/PropertyShapeinstance methods are now free functions, exported from the package:import { getPropertyShapes, // (nodeShape, includeSuperClasses?) => PropertyShapeData[] getUniquePropertyShapes, // (nodeShape) => PropertyShapeData[] getPropertyShape, // (nodeShape, label, checkSubShapes?) => PropertyShapeData | undefined addPropertyShape, // (nodeShape, propertyShape) => void nodeShapeEquals, // (a, b) => boolean } from "@_linked/core"; // before: Person.shape.getUniquePropertyShapes() // now: getUniquePropertyShapes(Person.shape)
If you read shape metadata via the old instance methods, switch to these free functions; if you only use the query DSL, no change is needed.
Deprecations (scheduled for removal):
Shape.getSetOf,Shape.mapPropertyShapes,propertyShapeToResult, and thePropertyShapeResulttype. Read the plainPropertyShapeDatafields directly instead of the result projection.SparqlDatasetno longer extendsShape. A SPARQL-backed dataset is a live store, not a metadata shape; it never used anyShapemember. This has no effect on constructing or using datasets/stores.
v2.14.4
Patch Changes
- #174
55fcf97Thanks @flyon! - Dev-only warning when a shape registers under a numerically-suffixed URI (e.g..../Person2) with the sametargetClassas its base — the signature of a bundler emitting more than one copy of a framework package, which silently breaks cross-runtime shape lookup. Surfaces a build-config regression loudly instead of a no-op at query-forward time.
v2.14.3
Patch Changes
v2.14.2
Patch Changes
- #164
17bfb35Thanks @flyon! - docs: document owned properties in the README — a new "Owned properties (contains/dependent)" section under Shapes explaining exclusive-ownership object properties (contains: true) and the automatic cascade cleanup on update-replace, set-remove, and parent-delete, and how the property-levelcontainsflag differs from the shape-leveldependentflag. No code change.