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.
- package/docs/api-reference.md +38 -0
- package/package.json +1 -1
- package/src/catalog.js +48 -3
- package/src/database/sift-to-sql.js +14 -1
package/docs/api-reference.md
CHANGED
|
@@ -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
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
|
-
|
|
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
|
|
752
|
-
|
|
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':
|
|
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: [] }
|