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.
- 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/src/instance.js +41 -8
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: [] }
|
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
|
-
|
|
306
|
-
|
|
307
|
-
if (
|
|
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
|
}
|