@_linked/core 2.28.4 → 2.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,7 +6,7 @@ import { rdf } from '../ontologies/rdf.js';
6
6
  import { shacl } from '../ontologies/shacl.js';
7
7
  import { xsd } from '../ontologies/xsd.js';
8
8
  import { getSimplePathId } from '../paths/normalizePropertyPath.js';
9
- import { getAllNodeShapes, getNodeShape, getRegistryVersion, getShapeClass, getTargetClassId, } from '../utils/ShapeClass.js';
9
+ import { getAllNodeShapes, getNodeShape, getRegistryVersion, getShapeClass, getSubShapes, getTargetClassId, } from '../utils/ShapeClass.js';
10
10
  import { UnresolvedContextError } from '../queries/QueryContext.js';
11
11
  // ---------------------------------------------------------------------------
12
12
  // Constants
@@ -1486,11 +1486,31 @@ export function createToAlgebra(query, options) {
1486
1486
  // ---------------------------------------------------------------------------
1487
1487
  // Shared update field processing
1488
1488
  // ---------------------------------------------------------------------------
1489
+ /** A bare `{id}` reference: a link to an existing node, not a nested create. */
1490
+ export function isBareNodeReference(value) {
1491
+ return (!!value &&
1492
+ typeof value === 'object' &&
1493
+ !Array.isArray(value) &&
1494
+ !(value instanceof Date) &&
1495
+ typeof value.id === 'string' &&
1496
+ !('shape' in value) &&
1497
+ !('fields' in value));
1498
+ }
1499
+ /**
1500
+ * The values a `contains` replace keeps: the bare `{id}`s of the new single value or of
1501
+ * the overwrite array. A nested create that carries an id is not kept — it rewrites that
1502
+ * node completely, as it always has.
1503
+ */
1504
+ function keptReferences(value) {
1505
+ const items = Array.isArray(value) ? value : [value];
1506
+ return items.filter(isBareNodeReference).map((ref) => iriTerm(ref.id));
1507
+ }
1489
1508
  /**
1490
1509
  * Processes IRNodeData fields into DELETE/INSERT/WHERE triples.
1491
1510
  * Shared between updateToAlgebra (IRI subject) and updateWhereToAlgebra (variable subject).
1492
1511
  */
1493
1512
  function processUpdateFields(data, subjectTerm, options) {
1513
+ var _a, _b;
1494
1514
  const deletePatterns = [];
1495
1515
  const insertPatterns = [];
1496
1516
  const oldValueTriples = [];
@@ -1505,6 +1525,10 @@ function processUpdateFields(data, subjectTerm, options) {
1505
1525
  const propertyTerm = resolvePropertyPredicateTerm(field.property);
1506
1526
  const suffix = propertySuffix(field.property);
1507
1527
  const isContainsField = propertyTerm.kind === 'iri' && updContainsPreds.includes(propertyTerm.value);
1528
+ // Only a `contains` property that declares a value shape deletes the old value's own
1529
+ // triples. Without one (a polymorphic slot like `sh:path`) the value may be a shared
1530
+ // IRI, so it is left to the cascade, which deletes it only when it is dependent-typed.
1531
+ const hasValueShape = isContainsField && !!((_a = findPropertyShapeById(field.property)) === null || _a === void 0 ? void 0 : _a.valueShape);
1508
1532
  // Check for set modification ({add, remove})
1509
1533
  if (field.value &&
1510
1534
  typeof field.value === 'object' &&
@@ -1515,17 +1539,26 @@ function processUpdateFields(data, subjectTerm, options) {
1515
1539
  ('add' in field.value || 'remove' in field.value)) {
1516
1540
  const setMod = field.value;
1517
1541
  if (setMod.remove) {
1542
+ // A value that is removed and added back in the same modification stays: its edge
1543
+ // is deleted and re-inserted (a no-op), and the node itself is not touched.
1544
+ const readdedIds = new Set(((_b = setMod.add) !== null && _b !== void 0 ? _b : []).filter(isBareNodeReference).map((ref) => ref.id));
1518
1545
  setMod.remove.forEach((removeItem, ri) => {
1519
- const removeTerm = iriTerm(removeItem.id);
1520
- deletePatterns.push(tripleOf(subjectTerm, propertyTerm, removeTerm));
1521
- oldValueTriples.push(tripleOf(subjectTerm, propertyTerm, removeTerm));
1546
+ const removeId = removeItem.id;
1547
+ const removeTerm = iriTerm(removeId);
1548
+ const edge = tripleOf(subjectTerm, propertyTerm, removeTerm);
1549
+ deletePatterns.push(edge);
1550
+ oldValueTriples.push(edge);
1522
1551
  // For a `contains` set, removing a value deletes the removed node itself
1523
1552
  // (its own triples) and its owned subtree — exclusive ownership via the edge.
1524
- if (isContainsField) {
1525
- const self = buildOwnedSelfDelete(subjectTerm, propertyTerm, removeTerm, `ucr_${suffix}_${ri}_`);
1526
- deletePatterns.push(...self.deletePatterns);
1527
- cascadeOptionals.push(...self.whereOptionals);
1528
- const cascade = buildOwnedCascade(removeTerm, `ucr_${suffix}_${ri}_`);
1553
+ if (isContainsField && !readdedIds.has(removeId)) {
1554
+ if (hasValueShape) {
1555
+ const self = buildOwnedSelfDelete(subjectTerm, propertyTerm, removeTerm, `ucr_${suffix}_${ri}_`);
1556
+ deletePatterns.push(...self.deletePatterns);
1557
+ cascadeOptionals.push(...self.whereOptionals);
1558
+ }
1559
+ // The edge leads the cascade too: with a zero-or-more path the removed node is
1560
+ // itself a match, and it may only be deleted while it is still owned here.
1561
+ const cascade = buildOwnedCascade(removeTerm, `ucr_${suffix}_${ri}_`, edge);
1529
1562
  deletePatterns.push(...cascade.deletePatterns);
1530
1563
  cascadeOptionals.push(...cascade.whereOptionals);
1531
1564
  }
@@ -1551,7 +1584,12 @@ function processUpdateFields(data, subjectTerm, options) {
1551
1584
  // Non-set-modification replace of a `contains` property: cascade the old value's
1552
1585
  // subtree (every branch below binds `old_${suffix}` in the WHERE).
1553
1586
  if (isContainsField) {
1554
- containsOldVars.push({ oldVar: `old_${suffix}`, propertyTerm });
1587
+ containsOldVars.push({
1588
+ oldVar: `old_${suffix}`,
1589
+ propertyTerm,
1590
+ keep: keptReferences(field.value),
1591
+ hasValueShape,
1592
+ });
1555
1593
  }
1556
1594
  // Unset (undefined/null) — delete only
1557
1595
  if (field.value === undefined || field.value === null) {
@@ -1636,15 +1674,19 @@ function processUpdateFields(data, subjectTerm, options) {
1636
1674
  }
1637
1675
  // For each replaced `contains` property: delete the old value's own triples
1638
1676
  // (exclusive ownership via the edge — backlog 032) then cascade any owned descendants.
1639
- containsOldVars.forEach(({ oldVar, propertyTerm }, n) => {
1677
+ // Values re-sent as a bare `{id}` are kept: their blocks are filtered, so re-sending the
1678
+ // current value is a no-op (the edge is deleted and re-inserted).
1679
+ containsOldVars.forEach(({ oldVar, propertyTerm, keep, hasValueShape }, n) => {
1640
1680
  const oldTerm = varTerm(oldVar);
1641
- const self = buildOwnedSelfDelete(subjectTerm, propertyTerm, oldTerm, `uc${n}_`);
1642
- deletePatterns.push(...self.deletePatterns);
1643
- cascadeOptionals.push(...self.whereOptionals);
1681
+ if (hasValueShape) {
1682
+ const self = buildOwnedSelfDelete(subjectTerm, propertyTerm, oldTerm, `uc${n}_`, keep);
1683
+ deletePatterns.push(...self.deletePatterns);
1684
+ cascadeOptionals.push(...self.whereOptionals);
1685
+ }
1644
1686
  // Every cascade block binds `?old` itself through the owning edge, like the
1645
1687
  // self-delete block does. Without it an absent old value leaves `?old` unbound and
1646
1688
  // the block matches the owned subtree of EVERY node in the graph.
1647
- const cascade = buildOwnedCascade(oldTerm, `uc${n}_`, tripleOf(subjectTerm, propertyTerm, oldTerm));
1689
+ const cascade = buildOwnedCascade(oldTerm, `uc${n}_`, tripleOf(subjectTerm, propertyTerm, oldTerm), keep);
1648
1690
  deletePatterns.push(...cascade.deletePatterns);
1649
1691
  cascadeOptionals.push(...cascade.whereOptionals);
1650
1692
  });
@@ -1846,11 +1888,37 @@ function buildUpdateWhere(base, result, traversalPatterns, subjectTerm) {
1846
1888
  // - a property marked `contains` → the cascade FOLLOWS that edge,
1847
1889
  // - a shape marked `dependent` → its instances may be DELETED when reached.
1848
1890
  // Both are read from the live registry, so no vocabulary is hardcoded. From a root
1849
- // node we follow `(c1|c2|…)+` over all contains predicates and, for each dependent
1850
- // targetClass, wildcard-delete the reached typed nodes. Requiring an asserted
1851
- // `?owned a <dependentType>` naturally excludes shared predicate IRIs (e.g. a simple
1852
- // `sh:path`) and `rdf:nil` (never typed), so only owned structural nodes are removed.
1891
+ // node we follow `(c1|c2|…)*` over all contains predicates (zero hops included, so a
1892
+ // dependent-typed root is itself a match) and, for each dependent targetClass,
1893
+ // wildcard-delete the reached typed nodes. Requiring an asserted `?owned a <dependentType>`
1894
+ // naturally excludes shared predicate IRIs (e.g. a simple `sh:path`) and `rdf:nil` (never
1895
+ // typed), so only owned structural nodes are removed.
1896
+ //
1897
+ // Whether a field owns its value is decided per predicate across the registry
1898
+ // (`collectContainment`): the replace and set-remove deletes, and the class check in
1899
+ // `ownedLinkCheck.ts`, apply that same rule. The subject deletes (by id and bulk) use the
1900
+ // deleted shape's own `contains` properties with a value shape for the immediate children.
1853
1901
  // ---------------------------------------------------------------------------
1902
+ /**
1903
+ * The simple-path predicates of the shape's own and inherited `contains` properties that
1904
+ * declare a value shape, deduplicated in declaration order. These are the properties
1905
+ * whose immediate values a subject delete removes.
1906
+ */
1907
+ function ownedValueShapePredicates(shapeId) {
1908
+ var _a, _b;
1909
+ const nodeShape = (_b = (_a = getShapeClass(shapeId)) === null || _a === void 0 ? void 0 : _a.shape) !== null && _b !== void 0 ? _b : getNodeShape(shapeId);
1910
+ if (!nodeShape)
1911
+ return [];
1912
+ const predicates = new Set();
1913
+ for (const ps of getPropertyShapes(nodeShape, true)) {
1914
+ if (!ps.contains || !ps.valueShape)
1915
+ continue;
1916
+ const predicate = getSimplePathId(ps.path);
1917
+ if (predicate)
1918
+ predicates.add(predicate);
1919
+ }
1920
+ return [...predicates];
1921
+ }
1854
1922
  /** True when the shape (or an ancestor) declares at least one `contains` property. */
1855
1923
  function shapeHasContainsProperty(shapeId) {
1856
1924
  var _a, _b;
@@ -1866,7 +1934,7 @@ function shapeHasContainsProperty(shapeId) {
1866
1934
  let containmentCache = null;
1867
1935
  let containmentCacheSize = -1;
1868
1936
  /** Gather contains-predicate IRIs and dependent targetClass IRIs from the registry. */
1869
- function collectContainment() {
1937
+ export function collectContainment() {
1870
1938
  var _a;
1871
1939
  const version = getRegistryVersion();
1872
1940
  if (containmentCache && version === containmentCacheSize) {
@@ -1893,6 +1961,78 @@ function collectContainment() {
1893
1961
  containmentCacheSize = version;
1894
1962
  return containmentCache;
1895
1963
  }
1964
+ let reachableCache = new Map();
1965
+ let reachableCacheVersion = -1;
1966
+ /**
1967
+ * The dependent targetClasses a subject delete of `shapeId` can reach through `contains`
1968
+ * edges, in `collectContainment` order. A subject delete emits one cascade block per type it
1969
+ * returns, and each block is a property-path closure per subject, so a shape that owns only
1970
+ * non-dependent values (every `Thing.image` owner) pays for none.
1971
+ *
1972
+ * The walk starts at the shape and its registered sub-shapes and follows every flattened
1973
+ * property on a `contains` predicate (the registry-wide predicate rule the cascade path uses)
1974
+ * to its value shape and that shape's sub-shapes. A property on a `contains` predicate with no
1975
+ * value shape is polymorphic (`sh:path`) and may reach anything, so it yields every dependent
1976
+ * type; that keeps the shape-sync delete of a NodeShape exact.
1977
+ */
1978
+ function reachableDependentTypes(shapeId) {
1979
+ var _a, _b;
1980
+ const version = getRegistryVersion();
1981
+ if (version !== reachableCacheVersion) {
1982
+ reachableCache = new Map();
1983
+ reachableCacheVersion = version;
1984
+ }
1985
+ const cached = reachableCache.get(shapeId);
1986
+ if (cached)
1987
+ return cached;
1988
+ const { containsPreds, dependentTypes } = collectContainment();
1989
+ const shapeOf = (id) => { var _a, _b; return (_b = (_a = getShapeClass(id)) === null || _a === void 0 ? void 0 : _a.shape) !== null && _b !== void 0 ? _b : getNodeShape(id); };
1990
+ const reached = new Set();
1991
+ // Shapes whose instances an owned edge can lead to. The start shapes join only when an
1992
+ // edge leads back to them (a dependent shape that owns its own kind, like a list cell).
1993
+ const owned = new Set();
1994
+ const seen = new Set();
1995
+ const queue = [];
1996
+ const visit = (id, viaEdge) => {
1997
+ for (const sid of [id, ...getSubShapes(id).map((sub) => sub.id)]) {
1998
+ if (viaEdge)
1999
+ owned.add(sid);
2000
+ if (seen.has(sid))
2001
+ continue;
2002
+ seen.add(sid);
2003
+ queue.push(sid);
2004
+ }
2005
+ };
2006
+ visit(shapeId, false);
2007
+ let everything = false;
2008
+ while (queue.length > 0 && !everything) {
2009
+ const nodeShape = shapeOf(queue.shift());
2010
+ if (!nodeShape)
2011
+ continue;
2012
+ for (const ps of getPropertyShapes(nodeShape, true)) {
2013
+ const predicate = getSimplePathId(ps.path);
2014
+ if (!predicate || !containsPreds.includes(predicate))
2015
+ continue;
2016
+ if (!((_a = ps.valueShape) === null || _a === void 0 ? void 0 : _a.id)) {
2017
+ everything = true;
2018
+ break;
2019
+ }
2020
+ visit(ps.valueShape.id, true);
2021
+ }
2022
+ }
2023
+ if (!everything) {
2024
+ for (const id of owned) {
2025
+ const nodeShape = shapeOf(id);
2026
+ const dependent = nodeShape === null || nodeShape === void 0 ? void 0 : nodeShape.dependent;
2027
+ if (dependent && ((_b = nodeShape === null || nodeShape === void 0 ? void 0 : nodeShape.targetClass) === null || _b === void 0 ? void 0 : _b.id)) {
2028
+ reached.add(nodeShape.targetClass.id);
2029
+ }
2030
+ }
2031
+ }
2032
+ const result = everything ? dependentTypes : dependentTypes.filter((t) => reached.has(t));
2033
+ reachableCache.set(shapeId, result);
2034
+ return result;
2035
+ }
1896
2036
  /**
1897
2037
  * Build DELETE patterns + WHERE OPTIONAL blocks that cascade-delete the owned subtree
1898
2038
  * reachable from `rootTerm` via `contains` edges. `varPrefix` keeps generated variables
@@ -1900,16 +2040,24 @@ function collectContainment() {
1900
2040
  *
1901
2041
  * `anchor`, when given, leads every block: the triple that binds a variable root, so
1902
2042
  * each block stays correct on its own (as a UNION branch) when no such root exists.
2043
+ * `onlyTypes`, when given, restricts the blocks to those dependent types (a subject delete
2044
+ * passes the types its shape can reach); by default every registered dependent type gets one.
1903
2045
  */
1904
- export function buildOwnedCascade(rootTerm, varPrefix, anchor) {
1905
- const { containsPreds, dependentTypes } = collectContainment();
2046
+ export function buildOwnedCascade(rootTerm, varPrefix, anchor, keep, onlyTypes) {
2047
+ const containment = collectContainment();
2048
+ const { containsPreds } = containment;
2049
+ const dependentTypes = onlyTypes !== null && onlyTypes !== void 0 ? onlyTypes : containment.dependentTypes;
1906
2050
  if (containsPreds.length === 0 || dependentTypes.length === 0) {
1907
2051
  return { deletePatterns: [], whereOptionals: [] };
1908
2052
  }
1909
- // (c1|c2|…)+ — follow one-or-more contains edges from the root.
2053
+ // (c1|c2|…)* — zero or more contains edges, so the root itself is matched when it is
2054
+ // dependent-typed. That is what cleans up the value of a `contains` property that has
2055
+ // no value shape (a polymorphic slot like `sh:path`): it is deleted only when it is a
2056
+ // dependent node, never when it is a shared IRI. On a subject delete `*` also matches
2057
+ // the subject, which is being deleted anyway.
1910
2058
  const pathExpr = containsPreds.length === 1
1911
- ? { oneOrMore: { id: containsPreds[0] } }
1912
- : { oneOrMore: { alt: containsPreds.map((id) => ({ id })) } };
2059
+ ? { zeroOrMore: { id: containsPreds[0] } }
2060
+ : { zeroOrMore: { alt: containsPreds.map((id) => ({ id })) } };
1913
2061
  const pathTerm = {
1914
2062
  kind: 'path',
1915
2063
  value: pathExprToSparql(pathExpr),
@@ -1922,7 +2070,7 @@ export function buildOwnedCascade(rootTerm, varPrefix, anchor) {
1922
2070
  const p = varTerm(`${varPrefix}op${i}`);
1923
2071
  const o = varTerm(`${varPrefix}ov${i}`);
1924
2072
  deletePatterns.push(tripleOf(owned, p, o));
1925
- whereOptionals.push({
2073
+ whereOptionals.push(keepFilter({
1926
2074
  type: 'bgp',
1927
2075
  triples: [
1928
2076
  ...(anchor ? [anchor] : []),
@@ -1930,10 +2078,32 @@ export function buildOwnedCascade(rootTerm, varPrefix, anchor) {
1930
2078
  tripleOf(owned, iriTerm(RDF_TYPE), iriTerm(typeId)),
1931
2079
  tripleOf(owned, p, o),
1932
2080
  ],
1933
- });
2081
+ }, rootTerm, keep));
1934
2082
  });
1935
2083
  return { deletePatterns, whereOptionals };
1936
2084
  }
2085
+ /**
2086
+ * Wrap an owned-cleanup block in `FILTER(?old NOT IN (<k1>, …))` so a value the mutation
2087
+ * keeps (re-sent as a bare `{id}`) is not deleted. The filter sits inside the block, next
2088
+ * to the edge triple that binds `?old`, so it only removes that block's matches; the
2089
+ * edge-only delete of the same value stays and is undone by the INSERT. With an IRI old
2090
+ * term, or nothing to keep, the block is returned unchanged.
2091
+ */
2092
+ function keepFilter(block, oldTerm, keep) {
2093
+ const kept = (keep !== null && keep !== void 0 ? keep : []).flatMap((term) => term.kind === 'iri' ? [{ kind: 'iri_expr', value: term.value }] : []);
2094
+ if (oldTerm.kind !== 'variable' || kept.length === 0)
2095
+ return block;
2096
+ return {
2097
+ type: 'filter',
2098
+ expression: {
2099
+ kind: 'in_expr',
2100
+ negated: true,
2101
+ value: { kind: 'variable_expr', name: oldTerm.name },
2102
+ list: kept,
2103
+ },
2104
+ inner: block,
2105
+ };
2106
+ }
1937
2107
  /**
1938
2108
  * Build DELETE + WHERE OPTIONAL that removes the *old node's own* one-hop triples when a
1939
2109
  * `contains` edge is replaced, unset, or set-removed. A `contains` edge asserts exclusive
@@ -1942,22 +2112,31 @@ export function buildOwnedCascade(rootTerm, varPrefix, anchor) {
1942
2112
  * does NOT require the child shape to be `dependent`: ownership via the edge is sufficient.
1943
2113
  * Owned descendants *below* the old node are cascaded separately by `buildOwnedCascade`.
1944
2114
  *
2115
+ * Callers use it only for a property that declares a value shape. A shape-less `contains`
2116
+ * slot (like `sh:path`) may hold a shared IRI, so its old value is left to
2117
+ * `buildOwnedCascade`, which deletes it only when it is dependent-typed.
2118
+ *
1945
2119
  * The owning edge `<subject> <prop> ?old` is re-asserted INSIDE the WHERE group so `?old`
1946
2120
  * is bound there: when no old value exists the group matches nothing and the DELETE is a
1947
2121
  * no-op, rather than an unbound `?old` matching (and deleting) the whole graph. When
1948
2122
  * `oldTerm` is a concrete IRI (set-remove), the guard just confirms the link still holds.
2123
+ *
2124
+ * `keep` lists the bare `{id}`s the mutation writes again (a re-sent current value, the
2125
+ * retained ids of an array overwrite). With a variable `oldTerm` the block is wrapped in
2126
+ * `FILTER(?old NOT IN (…))`, so a kept value is not deleted; its edge is deleted and
2127
+ * re-inserted, a no-op. A nested create carrying an id is never kept: it rewrites that node.
1949
2128
  */
1950
- export function buildOwnedSelfDelete(subjectTerm, propertyTerm, oldTerm, varPrefix) {
2129
+ export function buildOwnedSelfDelete(subjectTerm, propertyTerm, oldTerm, varPrefix, keep) {
1951
2130
  const p = varTerm(`${varPrefix}sp`);
1952
2131
  const o = varTerm(`${varPrefix}so`);
1953
2132
  const wildcard = tripleOf(oldTerm, p, o);
1954
2133
  return {
1955
2134
  deletePatterns: [wildcard],
1956
2135
  whereOptionals: [
1957
- {
2136
+ keepFilter({
1958
2137
  type: 'bgp',
1959
2138
  triples: [tripleOf(subjectTerm, propertyTerm, oldTerm), wildcard],
1960
- },
2139
+ }, oldTerm, keep),
1961
2140
  ],
1962
2141
  };
1963
2142
  }
@@ -1966,7 +2145,12 @@ export function buildOwnedSelfDelete(subjectTerm, propertyTerm, oldTerm, varPref
1966
2145
  *
1967
2146
  * The WHERE is the type guard of every id, joined with ONE UNION of independent
1968
2147
  * blocks: per id its own triples, its incoming references and, for a shape that
1969
- * owns something, one block per dependent type of its owned subtree.
2148
+ * owns something, one block per immediately-owned child (`<s> <p> ?dN . ?dN ?dN_p ?dN_o`,
2149
+ * for each of the shape's own and inherited `contains` properties with a value shape and
2150
+ * a simple path) and one block per dependent type its owned subtree can reach
2151
+ * (`reachableDependentTypes`; none for a shape that owns only non-dependent values). The
2152
+ * bulk deletes (`deleteAllToAlgebra`, `deleteWhereToAlgebra`) add the same owned blocks,
2153
+ * rooted at the subject variable (see `bulkDeleteUnion`).
1970
2154
  *
1971
2155
  * The blocks must not be chained as OPTIONALs. A chain of OPTIONALs is a join, so
1972
2156
  * the solutions multiply: own triples x incoming references x owned triples of each
@@ -1984,6 +2168,9 @@ export function deleteToAlgebra(query, _options) {
1984
2168
  const deletePatterns = [];
1985
2169
  const guards = [];
1986
2170
  const blocks = [];
2171
+ const hasContains = shapeHasContainsProperty(query.shape);
2172
+ const ownedChildPredicates = hasContains ? ownedValueShapePredicates(query.shape) : [];
2173
+ const cascadeTypes = hasContains ? reachableDependentTypes(query.shape) : [];
1987
2174
  for (let i = 0; i < query.ids.length; i++) {
1988
2175
  const subjectTerm = iriTerm(query.ids[i].id);
1989
2176
  const idx = query.ids.length > 1 ? `_${i}` : '';
@@ -1996,10 +2183,22 @@ export function deleteToAlgebra(query, _options) {
1996
2183
  // incoming references (the entity may have none) are separate UNION blocks.
1997
2184
  guards.push(typeGuard);
1998
2185
  blocks.push({ type: 'bgp', triples: [subjWild] }, { type: 'bgp', triples: [objWild] });
2186
+ // The immediately-owned children: for every `contains` property with a value shape,
2187
+ // the child's own one-hop triples, as a replace or a set-remove deletes them. No type
2188
+ // guard and no reference check: the `contains` edge alone asserts ownership.
2189
+ ownedChildPredicates.forEach((predicate, n) => {
2190
+ const child = varTerm(`d${n}${idx}`);
2191
+ const childOwn = tripleOf(child, varTerm(`d${n}${idx}_p`), varTerm(`d${n}${idx}_o`));
2192
+ deletePatterns.push(childOwn);
2193
+ blocks.push({
2194
+ type: 'bgp',
2195
+ triples: [tripleOf(subjectTerm, iriTerm(predicate), child), childOwn],
2196
+ });
2197
+ });
1999
2198
  // Cascade-delete the owned subtree — only for shapes that actually own something
2000
2199
  // (have a `contains` property); a plain entity delete is left untouched.
2001
- if (shapeHasContainsProperty(query.shape)) {
2002
- const cascade = buildOwnedCascade(subjectTerm, `c${idx}_`);
2200
+ if (hasContains && cascadeTypes.length > 0) {
2201
+ const cascade = buildOwnedCascade(subjectTerm, `c${idx}_`, undefined, undefined, cascadeTypes);
2003
2202
  deletePatterns.push(...cascade.deletePatterns);
2004
2203
  blocks.push(...cascade.whereOptionals);
2005
2204
  }
@@ -2079,13 +2278,39 @@ function blankNodeBlocks(shapeId, parentVar, path, counter, deletePatterns, bloc
2079
2278
  }
2080
2279
  }
2081
2280
  /**
2082
- * The blank-node cleanup blocks of a bulk delete, already joined with the root's own
2083
- * triples as ONE UNION so their matches add up instead of multiplying; `null` when the
2084
- * shape has no blank-node property (the root wildcard then stays in the required BGP).
2281
+ * The cleanup blocks of a bulk delete, already joined with the root's own triples as ONE
2282
+ * UNION so their matches add up instead of multiplying; `null` when there is nothing to
2283
+ * clean up (the root wildcard then stays in the required BGP).
2284
+ *
2285
+ * - The blank-node blocks of the shape's blank-node properties.
2286
+ * - For a shape that owns something (a `contains` property), the same owned cleanup as a
2287
+ * delete by id: the immediately-owned children of every `contains` property with a value
2288
+ * shape, and the dependent cascade, rooted at the subject variable. Each owned block is led
2289
+ * by the subject's type triple. With a linear join it is redundant; it keeps a branch that
2290
+ * is evaluated on its own from running `?a0 (c…)* ?own` with both ends unbound, which
2291
+ * matches every node in the graph.
2085
2292
  */
2086
- function bulkDeleteUnion(shapeId, subjectVar, rootWildcard, deletePatterns) {
2293
+ function bulkDeleteUnion(shapeId, subjectVar, rootWildcard, typeTriple, deletePatterns) {
2087
2294
  const blocks = [];
2088
2295
  blankNodeBlocks(shapeId, subjectVar, { triples: [], bnVars: [] }, { next: 1 }, deletePatterns, blocks);
2296
+ if (shapeHasContainsProperty(shapeId)) {
2297
+ const subject = varTerm(subjectVar);
2298
+ ownedValueShapePredicates(shapeId).forEach((predicate, n) => {
2299
+ const child = varTerm(`d${n}`);
2300
+ const childOwn = tripleOf(child, varTerm(`d${n}_p`), varTerm(`d${n}_o`));
2301
+ deletePatterns.push(childOwn);
2302
+ blocks.push({
2303
+ type: 'bgp',
2304
+ triples: [typeTriple, tripleOf(subject, iriTerm(predicate), child), childOwn],
2305
+ });
2306
+ });
2307
+ const cascadeTypes = reachableDependentTypes(shapeId);
2308
+ if (cascadeTypes.length > 0) {
2309
+ const cascade = buildOwnedCascade(subject, 'c_', typeTriple, undefined, cascadeTypes);
2310
+ deletePatterns.push(...cascade.deletePatterns);
2311
+ blocks.push(...cascade.whereOptionals);
2312
+ }
2313
+ }
2089
2314
  if (blocks.length === 0)
2090
2315
  return null;
2091
2316
  return unionOf([{ type: 'bgp', triples: [rootWildcard] }, ...blocks]);
@@ -2093,8 +2318,10 @@ function bulkDeleteUnion(shapeId, subjectVar, rootWildcard, deletePatterns) {
2093
2318
  /**
2094
2319
  * Converts an IRDeleteAllMutation to a SparqlDeleteInsertPlan.
2095
2320
  *
2096
- * Generates DELETE { ?a0 ?p ?o . [blank node wildcards] }
2097
- * WHERE { ?a0 a <Shape> . ?a0 ?p ?o . OPTIONAL { [blank node traversals] } }
2321
+ * Generates DELETE { ?a0 ?p ?o . [blank-node and owned-cleanup wildcards] }
2322
+ * WHERE { ?a0 a <Shape> . { ?a0 ?p ?o } UNION [one block per blank node,
2323
+ * owned child and dependent type] }
2324
+ * (see `bulkDeleteUnion`). Unlike a delete by id, incoming references are not deleted.
2098
2325
  */
2099
2326
  export function deleteAllToAlgebra(query, _options) {
2100
2327
  const subjectVar = 'a0';
@@ -2105,7 +2332,7 @@ export function deleteAllToAlgebra(query, _options) {
2105
2332
  // WHERE: type triple, then the root wildcard and the blank-node cleanup
2106
2333
  const typeTriple = tripleOf(varTerm(subjectVar), iriTerm(RDF_TYPE), iriTerm(resolveShapeScanIri(query.shape)));
2107
2334
  const rootWildcard = tripleOf(varTerm(subjectVar), varTerm('p'), varTerm('o'));
2108
- const union = bulkDeleteUnion(query.shape, subjectVar, rootWildcard, deletePatterns);
2335
+ const union = bulkDeleteUnion(query.shape, subjectVar, rootWildcard, typeTriple, deletePatterns);
2109
2336
  const whereAlgebra = union
2110
2337
  ? { type: 'join', left: { type: 'bgp', triples: [typeTriple] }, right: union }
2111
2338
  : { type: 'bgp', triples: [typeTriple, rootWildcard] };
@@ -2119,7 +2346,9 @@ export function deleteAllToAlgebra(query, _options) {
2119
2346
  /**
2120
2347
  * Converts an IRDeleteWhereMutation to a SparqlDeleteInsertPlan.
2121
2348
  *
2122
- * Like deleteAllToAlgebra but adds filter conditions from the where clause.
2349
+ * Like deleteAllToAlgebra but adds filter conditions from the where clause. The cleanup
2350
+ * UNION (blank nodes, owned children, dependent cascade) is joined with the filtered match,
2351
+ * so only the matched subjects' children are deleted.
2123
2352
  */
2124
2353
  export function deleteWhereToAlgebra(query, _options) {
2125
2354
  const subjectVar = 'a0';
@@ -2131,7 +2360,7 @@ export function deleteWhereToAlgebra(query, _options) {
2131
2360
  // WHERE: type triple + the where clause; the root wildcard and blank-node cleanup follow
2132
2361
  const typeTriple = tripleOf(varTerm(subjectVar), iriTerm(RDF_TYPE), iriTerm(resolveShapeScanIri(query.shape)));
2133
2362
  const rootWildcard = tripleOf(varTerm(subjectVar), varTerm('p'), varTerm('o'));
2134
- const union = bulkDeleteUnion(query.shape, subjectVar, rootWildcard, deletePatterns);
2363
+ const union = bulkDeleteUnion(query.shape, subjectVar, rootWildcard, typeTriple, deletePatterns);
2135
2364
  let whereAlgebra = {
2136
2365
  type: 'bgp',
2137
2366
  triples: union ? [typeTriple] : [typeTriple, rootWildcard],