mikser-io 11.14.0 → 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.0",
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: [] }
package/src/instance.js CHANGED
@@ -31,7 +31,8 @@ import { createHash } from 'node:crypto'
31
31
  import { tmpdir } from 'node:os'
32
32
  import path from 'node:path'
33
33
  import { existsSync, unlinkSync } from 'node:fs'
34
- import { chmod } from 'node:fs/promises'
34
+ import { chmod, stat } from 'node:fs/promises'
35
+ import { checksum } from './utils/index.js'
35
36
 
36
37
  import runtime from './runtime.js'
37
38
  import { onLoaded } from './lifecycle.js'
@@ -288,23 +289,55 @@ function configMismatch(theirs) {
288
289
  // configCoverage lists every local module the config graph pulled in, so a
289
290
  // stat over that list catches an edit to an imported module — which a
290
291
  // client-side checksum of the entry file would miss entirely.
292
+ // Has a config file CHANGED since this instance started?
293
+ //
294
+ // Content, not mtime. A `git rebase`, `git checkout` or `rsync` rewrites a
295
+ // file with byte-identical content and a new mtime, and the instance then
296
+ // refused every forwarded command — "this instance's config changed on disk
297
+ // since it started" — while `git diff HEAD` reported nothing. Twice in one
298
+ // afternoon on one machine.
299
+ //
300
+ // On production the cost is not a restart. The config checksum is what the
301
+ // cache is stamped with, so a config the engine believes has moved buys a
302
+ // full cold re-derive of every asset, which is how a no-op rebase turns into
303
+ // twenty minutes of ffmpeg.
304
+ //
305
+ // mtime is kept as a PRE-FILTER, not as the answer: it is a stat against a
306
+ // read, and in the ordinary case nothing has moved and nothing is read. Only
307
+ // a file whose mtime shifted gets hashed, and only a file whose CONTENT
308
+ // shifted is reported. A file rewritten identically has its stamp refreshed
309
+ // so the next check is cheap again.
310
+ async function configFingerprint(file) {
311
+ try {
312
+ return { mtime: (await stat(file)).mtimeMs, hash: await checksum(file) }
313
+ } catch {
314
+ // Deleted, or unreadable. A stable sentinel, so a file that is still
315
+ // missing on the next check does not read as having changed again.
316
+ return { mtime: 0, hash: null }
317
+ }
318
+ }
319
+
291
320
  export async function configStale() {
292
321
  const covered = runtime.options.configCoverage?.files ?? []
293
322
  if (!covered.length) return null
294
- const { stat } = await import('node:fs/promises')
295
323
  const stamps = runtime.options.configStamps
296
324
  if (!stamps) {
297
325
  // First call: record, do not judge. Nothing to compare against yet.
298
326
  runtime.options.configStamps = Object.fromEntries(
299
- await Promise.all(covered.map(async (file) => {
300
- try { return [file, (await stat(file)).mtimeMs] } catch { return [file, 0] }
301
- })))
327
+ await Promise.all(covered.map(async (file) => [file, await configFingerprint(file)])))
302
328
  return null
303
329
  }
304
330
  for (const file of covered) {
305
- let now = 0
306
- try { now = (await stat(file)).mtimeMs } catch { /* deleted counts as changed */ }
307
- if (stamps[file] !== now) return file
331
+ const was = stamps[file]
332
+ const now = await configFingerprint(file)
333
+ if (was?.mtime === now.mtime) continue // nothing touched it
334
+ if (was?.hash === now.hash) {
335
+ // Touched and identical — a checkout, a rebase, a sync. Not a
336
+ // change, and re-stamped so this file is a stat again next time.
337
+ stamps[file] = now
338
+ continue
339
+ }
340
+ return file
308
341
  }
309
342
  return null
310
343
  }