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. -
#205
48ecb4dThanks @flyon! - Expose the meta-model's SHACL constraint table viagetPropertyShapeTerms()/
getPropertyShapeTerm(label), so an alternative serializer can look up each constraint's
predicate, datatype and node kind rather than hard-coding its own copy.Purely additive —
buildPropertyShapeDatais unchanged. Create Now's code→RDF shape sync uses
this to emit the full SHACL constraint set (pattern,sh:in, ranges, lengths, class) instead of
the five it previously enumerated by hand. -
#206
a4989a8Thanks @flyon! - Add a boolean existence check to the query API:Shape.exists(id)and a terminal
.exists()on the select builder.if (await SourceDocument.exists({ id })) { await SourceDocument.update(values).for({ id }); } else { await SourceDocument.create({ id, ...values }); } // or, for "does anything match?" await Person.select() .where((p) => p.name.equals("Semmy")) .exists();
Until now "does this node exist?" had no direct expression. The natural workaround —
select().where(…).one()— resolves to a row ornull, so callers wrap it in a
.catch(() => null)and convert; that swallow makes an unreachable store
indistinguishable from a missing node, silently turning every
exists ? update : createinto an unconditionalcreate..exists()returns a realPromise<boolean>and never catches: a store, transport or
lowering failure rejects, including an unresolved query-context reference in a where
clause (whichexec()still reports asnull, unchanged).It also normalises the query to its cheapest correct form first. Dropped: the projection,
preloads, sorting and pagination — none of them can change whether a match exists, and
honouringoffsetwhile dropping the projection could actively flip the answer, since
OFFSETskips rows of a solution sequence whose cardinality depends on the projection.
Kept: filters,minusentries and the subject. So
Person.select(p => p.name).orderBy(…).offset(10).exists()costs and answers exactly the same
as a barePerson.exists({id}).See the ask-query entry in this release for what that normalised query becomes on the wire and in
SPARQL:.exists()is a shortcut for an ask query, which is its own query kind with its own
IDataset.askQuerymethod. -
#212
44936deThanks @flyon! -Shape.upsert()— create-or-replace against a known id, in one request.await SourceDocument.upsert({ filename, checksum }).for({ id });
It replaces the branch callers otherwise hand-roll:
if (await S.exists({ id })) await S.update(values).for({ id }); else await S.create({ id, ...values });
which costs two round-trips, races between them, and — if the existence check is wrong in the
falsedirection — silently takescreate, whereINSERT DATAduplicates single-valued
properties instead of erroring.Semantics
- Replaces only the properties named; others on an existing node are untouched. It is not a
whole-node replace. - Always asserts the node's type.
update().for({id})does not — an update against an absent id
writes its properties onto an untyped node that shape-scoped selects cannot find. That single
triple is the entire difference between the two:update'sWHEREis a bareOPTIONAL, so it
already matches when the node is missing. - Returns what
updatereturns. It deliberately does not report whether it created or replaced —
knowing that needs the extra read the single round-trip exists to avoid. .where()and.forAll()throw: an upsert targets one known id.- Expression-valued fields throw. An expression reads the node's current value, which does not
exist when upsert creates it, and SPARQL would silently drop the triple.
Wire format — a new
op: "upsert"envelope (modeis always"for"), documented in
documentation/dsl-json.md. It is a distinctoprather than a newmodeonupdateso that a
consumer which does not understand it fails loudly instead of falling through to an
update-every-instance.IR — a new
IRUpsertMutationkind, withupsertToAlgebra/upsertToSparqlalongside the
update equivalents. - Replaces only the properties named; others on an existing node are untouched. It is not a
Patch Changes
-
#207
02a32e2Thanks @flyon! - Never emit a prefixed name whose local part is not a legal SPARQLPN_LOCAL.Prefix.toPrefixed()guarded only against/, so when a registered namespace was a proper
string prefix of an IRI's own namespace the compaction produced names like
create-now:access#PolicyRegistry.#is not inPN_LOCAL: the tokenizer ends the name at
create-now:accessand reads the rest of the line as a comment, eating the triple
terminator and yielding a query the store rejects — with a parse error pointing at the
following line, which is why this was hard to attribute.The local part is now validated against a conservative
PN_LOCALallowlist and falls back to
<full-iri>when it does not fit.collectPrefixesaskstoPrefixedrather than
re-implementing the rule, so thePREFIXblock and the terms can never disagree. -
#213
46bc8deThanks @flyon! - Read result bindings under the same sanitized variable name they were written with.algebraToStringsanitizes a projection into a legal SPARQL variable — only letters, digits
and underscore survive — whileresultMappingderived the name it reads back without doing
the same. A property named by a person ("Volume share", "Avg. basket") was therefore emitted
as?a0_Volume_shareand looked up asa0_Volume share, matching no binding: the value came
backnullwith no error, for every multi-word property, on every query. Single-word names
were unaffected, which made it look like missing data rather than a naming mismatch. -
#189
70a6d33Thanks @carlenmy! - RegisterPropertyShape.defaultValueas a queryable property.sh:defaultValuewas read from config and emitted byPropertyShape.getResult(),
and the publishedShapeDetailstype declares it — but the property was never
registered in the meta-model, so any query referencingdefaultValuethrew
before executing. Adds the missingsh:defaultValueontology term and its
createPropertyShaperegistration, mirroring the existing generichasValue
registration (literal or IRI,maxCount: 1). -
#209
0eb6b3cThanks @flyon! - Fixupdate(expr).where(…)writing one value per node in the store when the expression traverses a
relation.Person.update((p) => ({ hobby: p.bestFriend.name.ucase() })).where((p) => p.name.equals("Moa") );
The traversal's leaf property was emitted as an
OPTIONALbeside the traversal edge rather than
inside it, and before it:OPTIONAL { ?__trav_0__ <…/name> ?__trav_0___name . } # subject var not yet bound OPTIONAL { ?a0 <…/bestFriend> ?__trav_0__ . }
The first
OPTIONALintroduces?__trav_0__and so shares no variable with anything to its left —
a left join with no join condition, i.e. a cartesian product over every node in the store carrying
that predicate. The second cannot repair it: the variable is already bound, andOPTIONALnever
removes rows. Every resulting row then reached theINSERT, so a single-valued property was written
once per named node, with values taken from unrelated nodes.The leaf is now nested inside the edge's
OPTIONAL, which is what.for(id)already emitted for the
identical expression — the two mutation paths disagreed.Only
update()with a computed expression that traverses a relation and a.where()clause is
affected. Plainupdate().where(), and anyupdate().for(id), were already correct.