Skip to content

Releases: linked-fw/core

v2.18.1

Choose a tag to compare

@linked-cm-release-bot linked-cm-release-bot released this 11 Sep 12:06
91a2b5a

Patch Changes

  • #220 dc1bdbb Thanks @flyon! - Fix Class extends value undefined when registering a runtime shape — getOrCreateShapeAdapter
    captured Shape at module-evaluation time, which could be before Shape.js had finished.

v2.18.0

Choose a tag to compare

@linked-cm-release-bot linked-cm-release-bot released this 10 Sep 10:12
3a0029d

Minor Changes

  • #208 085cfad Thanks @flyon! - Ask queries — a first-class query kind whose answer is a boolean — plus two rdf: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,
    an op: 'ask' wire envelope, and ASK 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 — no rdf:type
    constraint, under any shape or none. (Shape is free to mean "anything": the shapes themselves are
    described by NodeShape and PropertyShape.) A shapeless ask has no shape to route on, so
    LinkedStorage asks every dataset it knows and ORs the answers, short-circuiting on the first
    true — cheap precisely because the answers are booleans. Any router implementing IDataset
    inherits that obligation: a shapeless ask means "anywhere I can reach", not "in my default store".

    Breaking: IDataset.askQuery(query: AskQuery): Promise<boolean> is required

    Every store must implement it; query.shape is 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. askQuery must 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.1

    An 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 shape is the shapeless form. There is no fields, limit, offset, sortBy or
    one, so a receiver has nothing to validate or ignore. fromJSON routes op: 'ask' to an
    AskBuilder and still throws Unknown query op on anything unrecognised, so an older peer fails
    loud rather than reinterpreting the envelope as a select. Deploy receivers first.

    New exports: AskBuilder, isAskQuery, and the AskQuery / AskQueryJSON / RawAskInput /
    AskSpec types. lower() gains an ask overload returning IRAskQuery; askToAlgebra /
    askToSparql / askPlanToSparql / SparqlAskPlan / mapSparqlAskResult are the SPARQL arm.

    Breaking: a shape must declare a targetClass

    A 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:type names 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.
    targetClass is read off the shape class, so JavaScript static inheritance already walks the
    superclass chain.

    A declared sh:path is always the predicate

    The 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 the linked://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 targetClass on any shape lacking one. Data written under the old behaviour is typed
      with the shape IRI: either set that IRI as the targetClass, or retype the nodes.
    • Implement askQuery on any IDataset.
    • 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.
  • #214 67e015b Thanks @flyon! - One shape metamodel, and inheritance that works for shapes known only as data.

    NodeShapeData gains a JSON-safe transport form (toWire / fromWire in
    shapes/nodeShapeWire.ts). It is defined by subtraction from the metamodel — drop the
    circular parentNodeShape back-reference, carry pattern as its source string and flags
    — so new metamodel fields are carried automatically instead of a hand-maintained subset
    falling behind. PathExpr is 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) and linked_core:displayHidden, accepted by the property decorators,
    carried on PropertyShapeData, registered as meta-shape properties and serialized by
    syncShapes. They materialize onto the pure sh:NodeShape, so they travel with an
    ejected app.

    Fixes order and group, which were declared on PropertyShapeConfig but never copied
    onto the property shape — a declared sh:order was silently dropped and renderers fell
    back to array position.

    getSuperShapes is now the single canonical inheritance walk, and getPropertyShapes,
    getPropertyShape and the class-returning helpers all delegate to it: the prototype chain
    for a class-backed shape (which includes the framework Shape root, whose label and
    type really are inherited), extends through 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
    getPropertyShapeByLabel delegates to getPropertyShape, the query proxies are fixed
    too.

    Adds a primary IRI to NodeShapeData registry alongside the class registry, so query
    lowering and predicate resolution work for both kinds of shape;
    registerNodeShape / getNodeShape / getAllNodeShapes and the data-based
    getSuperShapes / getSubShapes / isSubShapeOf are exported.
    SelectBuilder.from(iri) now resolves a shape that exists only as data.

    Cache invalidation moves from a setTimeout plus 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 (NodeShapeData or its wire form) rather than a bespoke DTO.
    registerRuntimeShapes orders a batch parents-first, because inheritance resolves
    extends through 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 declared targetClass (walking
    extends where it is inherited) and its declared sh:path as 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:equals accessor is relabelled equalsConstraint, matching the
    PropertyShapeData field (the predicate is unchanged). equals is 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 labelled equals returned 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 in getPropertyShapeTerms() by the label equals must now ask for
    equalsConstraint.

    Registration now reports the general case: registerPropertyShape and
    registerRuntimeShape check 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, id and some are
    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 QueryShape is
    always shadowed; a name only on QueryShapeSetsize, 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 48ecb4d Thanks [@flyon](htt...

Read more

v2.17.0

Choose a tag to compare

@linked-cm-release-bot linked-cm-release-bot released this 27 Aug 13:51
b05c937

Minor Changes

  • #198 4a3f607 Thanks @flyon! - Add canonicalPathKey(expr) — a stable, prefix-independent identity for a property path.

    A PathExpr needs 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. pathExprToSparql cannot serve that purpose — it renders for humans and for queries, shortening IRIs via formatUri, 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: normalizePropertyPath threw 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 as NamedNodes 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.path now 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 ec22ee1 Thanks @flyon! - syncShapes can 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 fe7ed7d Thanks @flyon! - xsd:time properties take a pattern-checked string, written as a typed literal.

    Breaking for xsd:time only: a Date on an xsd:time property is now rejected. xsd:date and xsd:dateTime are unchanged and still take a Date and only a Date.

    A time of day is not an instant. Using Date for 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.PlainTime is 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, optional Z or ±HH:MM offset — '14:30:00', '14:30:00.250', '14:30:00Z', '14:30:00.250+02:00'. Ranges are enforced by the pattern (hours 00-23, minutes and seconds 00-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 valid xsd: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:time property 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 assertValid rejects; typing it from the declaration would instead write a plausible-looking "abc"^^xsd:integer and hide the error in the data. Only datatypes for which a string is a valid lexical form belong in the list.

Patch Changes

  • #200 0fc0fcf Thanks @flyon! - Temporarily relax the new 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 legitimately extends Shape and 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, mirroring createShapeTarget. 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-Shape base.

v2.16.1

Choose a tag to compare

@linked-cm-release-bot linked-cm-release-bot released this 16 Aug 16:56
7d25383

Patch Changes

  • #194 2cef8f7 Thanks @flyon! - validate() no longer passes silently when it cannot resolve a shape.

    Neither inheritance nor nesting is carried inside a NodeShapeData: a subclass's propertyShapes holds only its own, and a property's valueShape is 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, and validate(Slide, data) and validate(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: true on the strength of a branch that was never looked at.

    Both now produce an sh:NodeConstraintComponent violation 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

Choose a tag to compare

@linked-cm-release-bot linked-cm-release-bot released this 16 Aug 08:05
c00f323

Minor Changes

  • #188 a05a59b Thanks @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 types ValidationReport, 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.report carries the same report.

    Each result is one sh:ValidationResult under 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 for sh: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, and sh: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. lowerMutationJSON previously 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, complete for creates and partial for updates.

    Behavioural changes to review before upgrading:

    • Shape.create(data).toJSON() now throws when a required (minCount >= 1) property is missing. Previously only lower() and exec() did — toJSON(), lower() and exec() 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, b message is replaced by one result per property.
    • A mistyped literal is now rejected: {age: '42'} on an xsd:integer property 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:dateTime or xsd:time accept a JavaScript Date and nothing else; a lexical string is now a violation.
    • The serializer honours the declared sh:datatype for temporal and numeric literals. One Date becomes "2020-06-15"^^xsd:date on an xsd:date property and a full timestamp on an xsd:dateTime one; a property declared xsd:long or xsd:decimal now emits that datatype instead of the xsd:integer / xsd:double inferred from the value. Neither the IR nor the wire format changed.

    See docs/reports/027-shape-validation-report.md for the full mapping tables, design decisions, and known limitations.

v2.15.1

Choose a tag to compare

@linked-cm-release-bot linked-cm-release-bot released this 10 Aug 11:05
efa2e90

Patch Changes

  • #182 f3f2c4a Thanks @abdipramana! - Preserve child property keys when lowering nested array selections. Queries such as Action.select(action => ({image: action.image.select(image => [image.contentUrl])})) now map the nested value to image.contentUrl instead of incorrectly returning it as image.image.

  • #183 81c7b56 Thanks @abdipramana! - Preserve concrete nested shapes when serializing polymorphic preloadFor() queries. A preload such as person.pets.as(Dog).preloadFor(DogCard) now records the Dog shape IRI on the wire, allowing fields defined only on Dog to resolve correctly after client-server deserialization.

v2.15.0

Choose a tag to compare

@linked-cm-release-bot linked-cm-release-bot released this 13 Jul 17:08
15d2d2e

Minor Changes

  • #177 e5373fd Thanks @flyon! - Shapes are now metadata-only: Shape subclasses can no longer be instantiated, and SHACL metadata is exposed as plain objects.

    Behavioral change — new SomeShape() throws. Constructing any Shape subclass 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.shape and each property shape are now plain NodeShapeData / PropertyShapeData objects (importable as types from @_linked/core), not class instances. The former NodeShape / PropertyShape instance 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 the PropertyShapeResult type. Read the plain PropertyShapeData fields directly instead of the result projection.

    SparqlDataset no longer extends Shape. A SPARQL-backed dataset is a live store, not a metadata shape; it never used any Shape member. This has no effect on constructing or using datasets/stores.

v2.14.4

Choose a tag to compare

@linked-cm-release-bot linked-cm-release-bot released this 12 Jul 07:56
18c881b

Patch Changes

  • #174 55fcf97 Thanks @flyon! - Dev-only warning when a shape registers under a numerically-suffixed URI (e.g. .../Person2) with the same targetClass as 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

Choose a tag to compare

@linked-cm-release-bot linked-cm-release-bot released this 09 Jul 13:43
367abc0

Patch Changes

  • #169 806dbec Thanks @flyon! - docs: surface the owned-properties cleanup in the README overview — the "Full CRUD Operations" feature bullet now notes automatic cleanup of owned (contains) values on replace/remove/delete and links to the "Owned properties (contains / dependent)" section.

v2.14.2

Choose a tag to compare

@linked-cm-release-bot linked-cm-release-bot released this 09 Jul 13:28
cd824c7

Patch Changes

  • #164 17bfb35 Thanks @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-level contains flag differs from the shape-level dependent flag. No code change.