mikser-io 11.14.1 → 11.15.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.
@@ -415,6 +415,44 @@ const entity = await findEntity({ id: '/documents/post.md' })
415
415
  const entity = await findEntity(e => e.meta?.featured === true)
416
416
  ```
417
417
 
418
+ ### `findRef(ref, scope?)`
419
+
420
+ Resolves a `$`-ref string — an id, a `meta.href`, a `meta.url`, or an id
421
+ minus its extension — to one entity, or `null`.
422
+
423
+ With a `scope`, it resolves **within** that scope first, then falls back to a
424
+ target that is **outside the scope entirely** (every scoped key absent):
425
+
426
+ ```js
427
+ // A $-ref from a Bulgarian page finds the Bulgarian target…
428
+ const target = await findRef(ref, { 'meta.lang': 'bg' })
429
+ ```
430
+
431
+ That is the multilingual case it exists for. Scoping a ref lookup by language
432
+ is load-bearing — without it a page referencing an untranslated document
433
+ silently resolves to another language's copy — but it also excludes data that
434
+ is imported *once* because it has no language: a price list, a specification
435
+ table, a taxonomy. The fallback reaches exactly those and nothing else. An
436
+ untranslated document *has* a language, just the wrong one, so it stays
437
+ unresolved and still warns.
438
+
439
+ Pass the scope as a plain sift fragment; `findRef` knows nothing about
440
+ language, and a tenant or a brand works the same way. Resolution is
441
+ deterministic: the fallback asks for the absent key rather than taking any
442
+ match and inspecting it, so a ref matching both a neutral row and a
443
+ language-specific document always resolves to the neutral one.
444
+
445
+ For **strict** scoping with no fallback, build the filter directly:
446
+
447
+ ```js
448
+ const target = await findEntity({ ...refFilter(ref), 'meta.lang': lang })
449
+ ```
450
+
451
+ Note that resolution is separate from invalidation. The `$`-ref edge in
452
+ `mikser_refs` is recorded by core language-blind, so an edited target
453
+ re-renders every page referencing it whatever scope the resolver used — which
454
+ is why a `$`-ref beats looking a row up by key in a sidecar.
455
+
418
456
  ### `findEntities(query?)`
419
457
 
420
458
  Returns an array of all entities matching the query. Returns all entities
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io",
3
- "version": "11.14.1",
3
+ "version": "11.15.0",
4
4
  "files": [
5
5
  "app.js",
6
6
  "index.js",
package/src/catalog.js CHANGED
@@ -746,10 +746,55 @@ export async function* iterateEntities(query) {
746
746
  // references (ADR-0007). Used by the api plugin's HTTP handlers,
747
747
  // mikser-io-mcp's tools, and any library-mode caller.
748
748
 
749
- async function findRef(ref) {
749
+ // Resolve a ref string to one entity, optionally WITHIN a scope.
750
+ //
751
+ // A multilingual catalog resolves a `$`-ref inside the asking document's
752
+ // language — `{ ...refFilter(ref), 'meta.lang': lang }` — and that scoping is
753
+ // load-bearing: without it a Bulgarian page referencing an untranslated
754
+ // document silently resolves to the English one, and "unresolved ref" is the
755
+ // signal that a translation is missing.
756
+ //
757
+ // It also makes shared data unreachable. Data imported ONCE because it has no
758
+ // language — a price list, a specification table, a taxonomy — carries no
759
+ // `meta.lang` at all, so a scoped filter excludes it on every build. Importing
760
+ // it per language instead means maintaining the same rows three times.
761
+ //
762
+ // So: the scope first, then a target that is outside the scope ENTIRELY. That
763
+ // narrowing is what keeps the guard. An untranslated document HAS a language,
764
+ // just the wrong one, so it still fails to resolve and still warns; only a
765
+ // target with no language at all is reachable by fallback.
766
+ //
767
+ // The fallback asks for it. The shape that suggests itself —
768
+ //
769
+ // const any = await findEntity(refFilter(ref))
770
+ // return any?.meta?.lang == null ? any : null
771
+ //
772
+ // — is a different query: it takes an ARBITRARY match among every language and
773
+ // then inspects it, so a ref matching both a language-neutral row and a
774
+ // language-specific document resolves to whichever row came back, and the
775
+ // neutral one becomes unreachable at random. Asking for the absent key is
776
+ // deterministic, and `meta.lang` is an indexed column, so it is also cheap.
777
+ //
778
+ // `scope` is a plain sift fragment and this knows nothing about language:
779
+ // `{ 'meta.lang': lang }` is one use, a tenant or a brand is another. The
780
+ // language lives at the call site, which is the only place that knows the
781
+ // asking document.
782
+ //
783
+ // A caller that wants STRICT scoping, with no fallback, writes the filter
784
+ // directly — `findEntity({ ...refFilter(ref), ...scope })` — which is exactly
785
+ // what it wrote before this existed.
786
+ export async function findRef(ref, scope) {
750
787
  if (!ref || typeof ref !== 'string') return null
751
- const matches = await findEntities(refFilter(ref))
752
- return matches[0] ?? null
788
+ const filter = refFilter(ref)
789
+ const scopeKeys = scope ? Object.keys(scope) : []
790
+ if (!scopeKeys.length) return (await findEntities(filter))[0] ?? null
791
+
792
+ const scoped = await findEntities({ ...filter, ...scope })
793
+ if (scoped.length) return scoped[0]
794
+
795
+ // Outside the scope entirely: every scoped key absent.
796
+ const unscoped = Object.fromEntries(scopeKeys.map(key => [key, { $exists: false }]))
797
+ return (await findEntities({ ...filter, ...unscoped }))[0] ?? null
753
798
  }
754
799
 
755
800
  function expandLimits() {
@@ -49,7 +49,20 @@ const TRANSLATABLE_OPS = new Set([
49
49
 
50
50
  function sqlForOp(column, op, value) {
51
51
  switch (op) {
52
- case '$eq': return { sql: `${column} = ?`, params: [value] }
52
+ case '$eq':
53
+ // NULL-safe equality. `column = NULL` is NULL in SQL, never
54
+ // true, so this pushed down as a clause that matched NOTHING —
55
+ // while sift reads `{field: null}` as "null or absent" and
56
+ // matches both. The filter was translatable, so there was no
57
+ // jsFilter left behind to correct it: the query simply returned
58
+ // no rows. `$ne` has been null-safe since it was written; this
59
+ // is the same care on the other side of it.
60
+ //
61
+ // An absent key and an explicit null are the same NULL in an
62
+ // indexed column, so IS NULL is exactly sift's answer here.
63
+ return value === null
64
+ ? { sql: `${column} IS NULL`, params: [] }
65
+ : { sql: `${column} = ?`, params: [value] }
53
66
  case '$ne': // NULL-safe inequality — IS NOT for null, != otherwise
54
67
  return value === null
55
68
  ? { sql: `${column} IS NOT NULL`, params: [] }