@michaelthielemann/kestrel 1.6.0 → 2.0.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.
Files changed (79) hide show
  1. package/README.md +8 -4
  2. package/layers/access/server/utils/grant-registry.ts +1 -1
  3. package/layers/access/server/utils/ip-allowlist.ts +4 -1
  4. package/layers/admin/app/components/BlocksBody.vue +2 -1
  5. package/layers/admin/app/components/CollectionEditor.vue +124 -22
  6. package/layers/admin/app/components/CollectionList.vue +8 -4
  7. package/layers/admin/app/components/EditorStatus.vue +11 -0
  8. package/layers/admin/app/components/SeoFields.vue +1 -0
  9. package/layers/admin/app/components/SingletonEditor.vue +6 -6
  10. package/layers/admin/app/composables/useCollectionOps.ts +15 -3
  11. package/layers/admin/app/composables/useEditForm.ts +6 -3
  12. package/layers/admin/app/composables/useListColumns.ts +1 -1
  13. package/layers/admin/app/composables/usePublishStatus.ts +9 -0
  14. package/layers/admin/app/pages/admin/[collection]/[id].vue +7 -7
  15. package/layers/admin/app/pages/admin/[collection]/publish-preview.nuxt.test.ts +142 -0
  16. package/layers/admin/app/utils/editor-expose.ts +8 -0
  17. package/layers/collections/server/plugins/02.register-relation-populate.ts +6 -3
  18. package/layers/collections/server/utils/populate-relations.ts +18 -5
  19. package/layers/core/modules/auto-discovery/extract-block.ts +5 -2
  20. package/layers/core/modules/kestrel/index.ts +1 -0
  21. package/layers/core/server/api/[collection]/[id].get.ts +4 -1
  22. package/layers/core/server/api/[collection]/index.get.ts +7 -2
  23. package/layers/core/server/api/[collection]/populate-scope.test.ts +119 -0
  24. package/layers/core/server/api/references/broken.get.ts +7 -1
  25. package/layers/core/server/schema/dialect.ts +5 -3
  26. package/layers/core/server/schema/introspect.ts +4 -3
  27. package/layers/core/server/schema/sync.ts +1 -1
  28. package/layers/core/server/utils/crud.ts +11 -10
  29. package/layers/core/server/utils/kestrel-config.ts +10 -3
  30. package/layers/core/server/utils/populate.ts +3 -1
  31. package/layers/fields/server/field-registry/index.ts +2 -1
  32. package/layers/fields/server/field-registry/sanitize.ts +6 -3
  33. package/layers/media/app/components/MediaLibrary.vue +4 -1
  34. package/layers/media/app/components/MediaToolbar.vue +1 -1
  35. package/layers/media/app/components/field/Media.vue +2 -0
  36. package/layers/media/app/composables/useMediaLibrary.ts +2 -1
  37. package/layers/media/server/utils/backfill.ts +24 -12
  38. package/layers/media/server/utils/variants.ts +27 -12
  39. package/layers/public/app/pages/[...slug].vue +35 -7
  40. package/layers/public/app/pages/__kestrel/preview.vue +15 -3
  41. package/layers/public/app/utils/preview-protocol.ts +36 -0
  42. package/layers/public/server/api/preview.get.ts +28 -0
  43. package/layers/public/server/api/preview.post.ts +93 -0
  44. package/layers/public/server/api/publish-status.get.ts +23 -9
  45. package/layers/public/server/api/publish.post.ts +84 -0
  46. package/layers/public/server/api/route.get.ts +26 -6
  47. package/layers/public/server/plugins/02.register-links.ts +4 -4
  48. package/layers/public/server/plugins/zz.publish.ts +13 -4
  49. package/layers/public/server/routes/llms.txt.get.ts +5 -2
  50. package/layers/public/server/routes/sitemap.xml.get.ts +5 -2
  51. package/layers/public/server/tasks/publish/run.ts +2 -1
  52. package/layers/public/server/utils/link-resolve.ts +5 -2
  53. package/layers/public/server/utils/page-resolve.ts +23 -9
  54. package/layers/public/server/utils/preview-token.ts +109 -0
  55. package/layers/public/server/utils/publish/invalidation.ts +43 -4
  56. package/layers/public/server/utils/publish/pending.ts +74 -0
  57. package/layers/public/server/utils/publish/publish-runtime.ts +28 -0
  58. package/layers/public/server/utils/publish/publish-status.ts +18 -0
  59. package/layers/public/server/utils/publish/publisher.ts +74 -9
  60. package/layers/ui/app/components/field/Datetime.vue +2 -0
  61. package/layers/ui/app/components/field/Repeater.vue +2 -0
  62. package/layers/ui/app/components/ui/Checkbox.vue +1 -0
  63. package/layers/ui/app/components/ui/CheckboxGroup.vue +1 -0
  64. package/layers/ui/app/components/ui/Combobox.vue +2 -0
  65. package/layers/ui/app/components/ui/Field.vue +1 -0
  66. package/layers/ui/app/components/ui/Fieldset.vue +2 -1
  67. package/layers/ui/app/components/ui/Icon.vue +2 -2
  68. package/layers/ui/app/components/ui/NumberInput.vue +2 -0
  69. package/layers/ui/app/components/ui/Richtext.vue +25 -3
  70. package/layers/ui/app/components/ui/Select.vue +1 -0
  71. package/layers/ui/app/components/ui/TextInput.vue +1 -0
  72. package/layers/ui/app/components/ui/Textarea.vue +1 -0
  73. package/layers/ui/app/components/ui/TimeInput.vue +1 -0
  74. package/layers/ui/app/i18n/de.ts +10 -0
  75. package/layers/ui/app/i18n/en.ts +10 -0
  76. package/package.json +9 -4
  77. package/scripts/kestrel.mjs +48 -19
  78. package/scripts/lib/scaffold.mjs +39 -8
  79. package/templates/starter/app/blocks/Prose.vue +1 -0
@@ -2,6 +2,7 @@ import { eq, getTableColumns } from 'drizzle-orm'
2
2
  import type { AnySQLiteTable } from 'drizzle-orm/sqlite-core'
3
3
  import { publishStatus } from '../database/publish-status'
4
4
  import { routeForRecord } from '../utils/publish/route-for-record'
5
+ import { hasPendingChanges } from '../utils/publish/pending'
5
6
 
6
7
  /**
7
8
  * Admin-only read of the LIVE publish state of a record's static page (`?collection=&id=`). Admin-only by
@@ -17,28 +18,41 @@ export default defineEventHandler((event) => {
17
18
  // (`driver`) and whether the runtime publisher actually produces files HERE (prod + `output.auto`). In dev
18
19
  // (or with auto off) nothing is ever generated, so the lamp shows a calm "Not built" instead of a stuck
19
20
  // "Generating". Read straight from runtimeConfig — no need to pull in the whole publisher module.
20
- const output = (useRuntimeConfig().kestrel as { output?: { driver?: 'local' | 's3'; auto?: boolean } }).output
21
- const env = { driver: (output?.driver ?? 'local') as 'local' | 's3', generates: !import.meta.dev && !!output?.auto }
21
+ const output = (useRuntimeConfig().kestrel as { output?: { driver?: 'local' | 's3'; auto?: boolean; publishOnSave?: boolean } }).output
22
+ // `publishOnSave` also tells the editor whether to offer a Publish button at all: with the split turned
23
+ // off there is nothing left for it to do.
24
+ const publishOnSave = !!output?.publishOnSave
25
+ const env = { driver: (output?.driver ?? 'local') as 'local' | 's3', generates: !import.meta.dev && !!output?.auto, publishOnSave }
22
26
 
23
27
  const q = getQuery(event)
24
28
  const name = typeof q.collection === 'string' ? q.collection : ''
25
29
  const id = Number(typeof q.id === 'string' ? q.id : NaN)
26
30
  const c = getCollection(name)
27
- if (!c || !c.def.pageLike || !Number.isInteger(id) || id <= 0) return { route: null, status: null, ...env }
31
+ if (!c || !c.def.pageLike || !Number.isInteger(id) || id <= 0) return { route: null, status: null, pending: false, neverPublished: false, ...env }
28
32
 
29
33
  const db = useDb()
30
34
  const table = c.table as AnySQLiteTable
31
35
  const cols = getTableColumns(table) as Record<string, never>
32
- const row = db.select().from(table).where(eq(cols.id, id)).get() as { path?: unknown; locale?: unknown } | undefined
36
+ const row = db.select().from(table).where(eq(cols.id, id)).get() as { path?: unknown; locale?: unknown; updatedAt?: unknown } | undefined
33
37
  const route = routeForRecord(row, true, primaryLocale(), prefixPrimaryLocale())
34
- if (!route) return { route: null, status: null, ...env }
38
+ if (!route) return { route: null, status: null, pending: false, neverPublished: false, ...env }
35
39
 
40
+ // Saved after it was last published: with publishing deferred to an explicit action, that is the normal
41
+ // working state of a page being edited — the live file is the previous version until someone publishes.
42
+ const savedAt = row?.updatedAt instanceof Date ? row.updatedAt.getTime() : null
36
43
  try {
37
44
  const st = db.select().from(publishStatus).where(eq(publishStatus.route, route)).get()
38
- if (!st) return { route, status: null, ...env }
39
- return { route, status: st.status, error: st.error, updatedAt: st.updatedAt, target: st.target, ...env }
45
+ // A routable page with no row was never published. Before the split that was indistinguishable from
46
+ // "a publish is running" a save always enqueued one but now nothing is in flight and nothing will be
47
+ // until someone presses Publish, so the lamp must not claim progress that is not happening.
48
+ if (!st) return { route, status: null, pending: false, neverPublished: true, ...env }
49
+ // With the split off, a save republishes on its own, so a newer save means a republish is in flight —
50
+ // reporting that as "unpublished changes" would ask the user to act on something already happening.
51
+ const pending = !publishOnSave && hasPendingChanges(savedAt, st.updatedAt instanceof Date ? st.updatedAt.getTime() : null)
52
+ return { route, status: st.status, error: st.error, updatedAt: st.updatedAt, target: st.target, pending, neverPublished: false, ...env }
40
53
  } catch {
41
- // publish_status not migrated yet → treat as "no status" rather than a 500.
42
- return { route, status: null, ...env }
54
+ // publish_status not migrated yet → treat as "no status" rather than a 500. Not "never published"
55
+ // either: the table is unreadable, so the page's real state is unknown, not known to be absent.
56
+ return { route, status: null, pending: false, neverPublished: false, ...env }
43
57
  }
44
58
  })
@@ -0,0 +1,84 @@
1
+ import { inArray, getTableColumns } from 'drizzle-orm'
2
+ import type { AnySQLiteTable } from 'drizzle-orm/sqlite-core'
3
+ import { MAX_BULK_IDS } from '../../../core/app/utils/list-limits'
4
+ import { classifyWrite, planInvalidation } from '../utils/publish/invalidation'
5
+ import { staleRoutes } from '../utils/publish/deps'
6
+ import { usePublishRuntime } from '../utils/publish/publish-runtime'
7
+ import { allPublishedRoutes } from '../utils/publish/publisher'
8
+
9
+ /**
10
+ * Publish records: write their static files (and everything whose baked output embeds them) to the
11
+ * configured output — the deliberate second half of the split ADR-0008 introduced. Saving persists to the
12
+ * DB and leaves the live site alone; THIS is what changes what visitors see.
13
+ *
14
+ * body: { collection: string, ids?: number[], id?: number }
15
+ * 200: { queued, generates, routes, pruned, drafts }
16
+ *
17
+ * The record's publish INTENT (`status`) is not touched here — that is a field the editor saves like any
18
+ * other, so a page goes live by being published while published. A draft is therefore reported back
19
+ * (`drafts`) rather than silently promoted: it has no public output to write.
20
+ *
21
+ * All-or-nothing on lookup (an unknown id 404s before anything is enqueued), like the bulk write actions.
22
+ */
23
+ export default defineEventHandler(async (event) => {
24
+ requireAdmin(event) // write-authorization backstop (defense-in-depth; see require-admin.ts)
25
+ const body = await readBody(event)
26
+ const name = typeof (body as { collection?: unknown })?.collection === 'string' ? (body as { collection: string }).collection : ''
27
+ const c = getCollection(name)
28
+ if (!c) throw createError({ statusCode: 404, statusMessage: `Unknown collection: ${name}` })
29
+
30
+ const raw = (body as { ids?: unknown; id?: unknown })?.ids ?? [(body as { id?: unknown })?.id]
31
+ const ids = parseIdList(raw, MAX_BULK_IDS)
32
+
33
+ const db = useDb()
34
+ const table = c.table as AnySQLiteTable
35
+ const cols = getTableColumns(table) as Record<string, never>
36
+ const rows = db.select().from(table).where(inArray(cols.id, ids)).all() as Record<string, unknown>[]
37
+ const found = new Set(rows.map((r) => r.id as number))
38
+ const missing = ids.filter((id) => !found.has(id))
39
+ if (missing.length) throw createError({ statusCode: 404, statusMessage: `${c.def.name} not found: ${missing.join(', ')}` })
40
+
41
+ // Where the next publish would go, and whether one happens here at all: in dev (or with `output.auto`
42
+ // off) there is no runtime publisher, and saying so is more useful than a queued run that never runs.
43
+ const output = (useRuntimeConfig().kestrel as { output?: { auto?: boolean } }).output
44
+ const runtime = usePublishRuntime()
45
+ const generates = !import.meta.dev && !!output?.auto && !!runtime
46
+
47
+ // The live route set answers "is this tracked route still somebody's page?" — the question a rename
48
+ // leaves open. `failed` means the enumeration was incomplete, and an incomplete read must never drive a
49
+ // delete (the standing rule from the 2026-07-25 audit), so the prune is skipped wholesale.
50
+ const liveNow = runtime ? allPublishedRoutes() : { routes: [], failed: ['*'] }
51
+ const prunable = liveNow.failed.length === 0
52
+
53
+ const primary = primaryLocale()
54
+ const prefixPrimary = prefixPrimaryLocale()
55
+ const routes: string[] = []
56
+ const pruned: string[] = []
57
+ const drafts: number[] = []
58
+ let queued = false
59
+
60
+ for (const row of rows) {
61
+ // before === after: nothing about the record is changing, this is a re-render of its current state.
62
+ // A draft classifies as not-published, so `planInvalidation` returns a noop for it — reported below.
63
+ const ev = classifyWrite(c.def, row, row, primary, prefixPrimary)
64
+ const inv = planInvalidation(ev)
65
+ if (inv.type !== 'tags') {
66
+ drafts.push(row.id as number)
67
+ continue
68
+ }
69
+ // Abandoned URLs: routes the publisher baked FROM this record (tagged with its id) that no live page
70
+ // claims any more — in practice the old file a published rename left behind. Same rule a full publish
71
+ // applies globally, scoped to this record's tag; a referrer or listing carrying the tag is a live route
72
+ // and therefore never in this set.
73
+ const tagged = ev.id != null ? (runtime?.deps.routesForTags([`${c.def.name}:${ev.id}`]) ?? []) : []
74
+ const stale = prunable ? staleRoutes(tagged, liveNow.routes) : []
75
+ routes.push(...inv.render)
76
+ pruned.push(...stale)
77
+ if (runtime) {
78
+ runtime.queue.enqueue({ ...inv, prune: [...inv.prune, ...stale] })
79
+ queued = true
80
+ }
81
+ }
82
+
83
+ return { queued, generates, routes, pruned, drafts }
84
+ })
@@ -14,13 +14,33 @@ export default defineEventHandler((event) => {
14
14
  const isStaticRender = import.meta.prerender === true || isRendererContext()
15
15
  const publishedOnly = isStaticRender || event.context.readScope !== 'all'
16
16
  const db = useDb()
17
- const resolved = resolvePage(db, allCollections(), path, locale, publishedOnly)
17
+ const { page: resolved, failed } = resolvePage(db, allCollections(), path, locale, publishedOnly)
18
18
  // The site-wide head tier rides along on the fetch the page already awaits, so it reaches SSR and the
19
- // prerender on a path that is known to work. Looked up through the registry, not imported, so a consumer
20
- // that disables the collection gets `null` instead of a query against a table the schema never created.
21
- // `depth: 1` resolves the sharing image into `$media`; `getSingleton` captures the read, so an edit
22
- // re-publishes every route that embedded it.
19
+ // prerender on a path that is known to work. Looked up through the registry, not imported, so an
20
+ // installation whose registry never received the built-in simply has the tier off (`null`) instead of
21
+ // querying a table the schema never created. `depth: 1` resolves the sharing image into `$media`;
22
+ // `getSingleton` captures the read, so an edit re-publishes every route that embedded it.
23
23
  const siteCollection = getCollection('site')
24
- const site = siteCollection ? getSingleton(db, siteCollection, locale, false, 1) : null
24
+ let site: ReturnType<typeof getSingleton> = null
25
+ let siteUnreadable = false
26
+ if (siteCollection) {
27
+ try { site = getSingleton(db, siteCollection, locale, false, 1) }
28
+ catch (error) {
29
+ // Registered but unreadable (its migration hasn't been run) — indistinguishable in the response from
30
+ // the off state above, so it joins the incomplete-read channel rather than degrading silently.
31
+ siteUnreadable = true
32
+ console.error('[kestrel] route: the site singleton could not be read:', (error as Error)?.message ?? error)
33
+ }
34
+ }
35
+ // One rule for every incomplete read: never answer 200. The publisher classifies a 200-with-body as a
36
+ // successful render, writes it over the live file and records success — so an unreadable page collection
37
+ // would bake the catch-all's empty document over a real page (the record may well live in the collection
38
+ // that failed, which is why this is not a 404), and an unreadable head tier would strip the composed
39
+ // title, default description and sharing image from every route it touches. Both are unrecoverable
40
+ // without a full re-publish and neither leaves a mark. A 5xx keeps the existing artifact and turns the
41
+ // editor's status red. The head tier is site-wide, so it fails the request even when a page did resolve.
42
+ if (siteUnreadable || (failed.length && !resolved)) {
43
+ throw createError({ statusCode: 503, statusMessage: 'Route lookup incomplete' })
44
+ }
25
45
  return { collection: resolved?.collection ?? null, page: resolved?.page ?? null, alternates: resolved?.alternates ?? [], site }
26
46
  })
@@ -3,10 +3,10 @@ import { buildLinkFieldPopulators } from '../utils/populate-links'
3
3
  import { resolveInternalHref } from '../utils/link-resolve'
4
4
 
5
5
  // Resolve internal links to the target record's localized public path at read time (page-like targets;
6
- // external/email/tel pass through). NOTE: the resolver does NOT status-gate the target a link to a DRAFT
7
- // emits the draft's real (not-yet-generated) path, disclosing its slug and shipping a 404 until publish. This
8
- // is deliberate: it keeps links stable without re-rendering every referrer when a target's status flips, and
9
- // the editor warns about draft/dead links instead. (See link-resolve.ts for the rationale.)
6
+ // external/email/tel pass through). The resolver IS status-gated: a link to a DRAFT resolves to nothing and
7
+ // bakes `'#'`, so an unpublished slug never reaches published HTML. The href therefore encodes availability,
8
+ // which is why flipping a target's status re-renders its referrers; the editor warns about the resulting
9
+ // draft/dead links separately. (See link-resolve.ts.)
10
10
  //
11
11
  // Registered as `link` + `richtext` per-type populators; the shared field-tree walker dispatches them over
12
12
  // top-level fields, block props, slots, and repeater entries.
@@ -1,8 +1,9 @@
1
1
  import { createPublishQueue } from '../utils/publish/queue'
2
+ import { setPublishRuntime } from '../utils/publish/publish-runtime'
2
3
  import { DepsStore } from '../utils/publish/deps'
3
4
  import { createSqlitePersistence } from '../utils/publish/deps-persistence'
4
5
  import { outputDriver, publishInvalidation } from '../utils/publish/publisher'
5
- import { classifyWrite, planInvalidation } from '../utils/publish/invalidation'
6
+ import { classifyWrite, planWrite } from '../utils/publish/invalidation'
6
7
  import { registerWriteListener } from '../../../core/server/utils/write-events'
7
8
 
8
9
  /**
@@ -15,11 +16,11 @@ import { registerWriteListener } from '../../../core/server/utils/write-events'
15
16
  * DETACHED (`runNitroPlugins` is synchronous + unawaited; `localFetch` is already wired before plugins
16
17
  * run + before the server listens), so it never blocks boot.
17
18
  * 3. RECONCILER — an optional periodic full publish (`output.reconcileMinutes`) self-heals any missed
18
- * invalidation and picks up time-based `publishDate` publishing that no write event would trigger.
19
+ * invalidation.
19
20
  */
20
21
  export default defineNitroPlugin(() => {
21
22
  if (import.meta.dev) return
22
- const output = (useRuntimeConfig().kestrel as { output?: { auto?: boolean; reconcileMinutes?: number; verbose?: boolean } }).output
23
+ const output = (useRuntimeConfig().kestrel as { output?: { auto?: boolean; publishOnSave?: boolean; reconcileMinutes?: number; verbose?: boolean } }).output
23
24
  if (!output?.auto) return
24
25
 
25
26
  // `output.verbose`: on top of the one-line summary, itemise each incremental republish with a
@@ -48,8 +49,16 @@ export default defineNitroPlugin(() => {
48
49
  onError: (error) => console.error('[kestrel] publish run failed:', error),
49
50
  })
50
51
 
52
+ // The explicit publish action (`POST /api/publish`) enqueues through this same queue, so a publish and
53
+ // a write-driven prune are serialized by one single-flight run rather than racing each other.
54
+ setPublishRuntime({ queue, deps })
55
+
56
+ // A save writes the DB, not the site: by default only what a save must still REMOVE from the output (an
57
+ // unpublished or deleted record's page) passes through, and everything renderable waits for an explicit
58
+ // publish — see ADR-0008. `output.publishOnSave` restores the pre-2.0 model, where every write republished.
59
+ const publishOnSave = output.publishOnSave ?? false
51
60
  registerWriteListener(({ def, before, after }) => {
52
- queue.enqueue(planInvalidation(classifyWrite(def, before, after, primaryLocale(), prefixPrimaryLocale())))
61
+ queue.enqueue(planWrite(classifyWrite(def, before, after, primaryLocale(), prefixPrimaryLocale()), publishOnSave))
53
62
  })
54
63
 
55
64
  // Boot publish goes THROUGH the queue (not a direct publishFull) so it shares the single-flight guard:
@@ -44,8 +44,11 @@ export default defineEventHandler((event) => {
44
44
  let rows: Record<string, unknown>[]
45
45
  try {
46
46
  rows = db.select(proj as never).from(c.table).all() as Record<string, unknown>[]
47
- } catch {
48
- continue // table not migrated yet (e.g. a bare prerender DB)
47
+ } catch (error) {
48
+ // Skipping keeps a bare prerender DB publishable, but a drifted table drops the whole section — a
49
+ // silent gap the publisher would write straight over the live artifact.
50
+ console.error(`[kestrel] llms.txt: skipped collection ${c.def.name}:`, (error as Error)?.message ?? error)
51
+ continue
49
52
  }
50
53
  const entries: LlmsEntry[] = []
51
54
  for (const row of rows) {
@@ -39,8 +39,11 @@ export default defineEventHandler((event) => {
39
39
  let rows: Record<string, unknown>[]
40
40
  try {
41
41
  rows = db.select(proj as never).from(c.table).all() as Record<string, unknown>[]
42
- } catch {
43
- continue // table not migrated yet (e.g. a bare prerender DB)
42
+ } catch (error) {
43
+ // Skipping keeps a bare prerender DB publishable, but a drifted table de-indexes every page of the
44
+ // collection — a silent gap the publisher would write straight over the live sitemap.
45
+ console.error(`[kestrel] sitemap.xml: skipped collection ${c.def.name}:`, (error as Error)?.message ?? error)
46
+ continue
44
47
  }
45
48
  for (const row of rows) {
46
49
  if (c.def.status && row.status !== 'published') continue
@@ -5,7 +5,8 @@ import { createSqlitePersistence } from '../../utils/publish/deps-persistence'
5
5
  /**
6
6
  * The shared publish engine, exposed as a Nitro task. Renders every published page (+ sitemap/robots)
7
7
  * via the live server, mirrors `_nuxt`/assets, and always prunes the routes that left the published set
8
- * (output ≡ DB).
8
+ * (output ≡ DB). Like every full publish it HOLDS BACK routes with unpublished changes (ADR-0008) — a
9
+ * resync must not push work in progress live; those pages go out when they are published.
9
10
  *
10
11
  * Triggering (Nuxt 4.4 / Nitro 2.13 — there is NO `nuxi task run`):
11
12
  * - dev: GET http://localhost:3000/_nitro/tasks/publish:run (the dev-only task route; plumbing smoke test)
@@ -27,8 +27,11 @@ export function resolveInternalHref(collection: string, id: number, db = useDb()
27
27
  let row: Record<string, unknown> | undefined
28
28
  try {
29
29
  row = db.select().from(c.table).where(eq(cols.id, id)).get() as Record<string, unknown> | undefined
30
- } catch {
31
- return null // table not migrated yet (e.g. a bare prerender DB)
30
+ } catch (error) {
31
+ // Still null (a throw would 500 every page holding an internal link on a bare prerender DB), but the
32
+ // memo caches that null run-wide as "not linkable", so the dead link needs a trace to be diagnosable.
33
+ console.error(`[kestrel] resolveInternalHref: ${collection}:${id} unreadable:`, (error as Error)?.message ?? error)
34
+ return null
32
35
  }
33
36
  if (!isPubliclyLinkable(row, Object.hasOwn(cols, 'status'))) return null
34
37
  return pageRowHref(row, primaryLocale(), prefixPrimaryLocale())
@@ -1,12 +1,20 @@
1
1
  import { asc, eq, getTableColumns } from 'drizzle-orm'
2
2
  import { list } from '../../../core/server/utils/crud'
3
3
  import { captureRead } from '../../../core/server/utils/read-capture'
4
+ import { translationGroupTag } from './publish/invalidation'
4
5
  import type { BuiltCollection } from '../../../core/server/utils/collection-types'
5
6
  import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
6
7
 
7
8
  export interface PageAlternate { locale: string; path: string }
8
9
  export interface ResolvedPage { collection: string; page: Record<string, unknown>; alternates: PageAlternate[] }
9
10
 
11
+ /** The matched page (or null) plus the collections whose lookup threw. `failed` is non-empty ⇒ the scan
12
+ * was INCOMPLETE, so `page: null` must never be treated as an authoritative "no such page". */
13
+ export interface PageResolution {
14
+ page: ResolvedPage | null
15
+ failed: string[]
16
+ }
17
+
10
18
  /**
11
19
  * The page's published, INDEXABLE translation siblings (self included) as locale→path pairs — the hreflang
12
20
  * set the public head emits. Mirrors the sitemap's rules exactly so the two never disagree: published-only
@@ -14,12 +22,15 @@ export interface ResolvedPage { collection: string; page: Record<string, unknown
14
22
  * hreflang to a noindexed page is a conflicting signal), null-path rows skipped, and a single-member group
15
23
  * returns [] (hreflang is meaningless for a lone page). EVERY sibling in the group is `captureRead`-tagged,
16
24
  * filtered-out ones included, so an incremental publish re-renders every group member when a sibling is
17
- * renamed/published/unpublished — otherwise a baked page would keep a stale/dead hreflang href.
25
+ * renamed/published/unpublished — otherwise a baked page would keep a stale/dead hreflang href. The GROUP
26
+ * itself is tagged too: a sibling that does not exist yet has no id to have been captured, so the group tag
27
+ * is the only edge a later CREATE can match.
18
28
  */
19
29
  function publishedAlternates(db: BetterSQLite3Database, c: BuiltCollection, page: Record<string, unknown>): PageAlternate[] {
20
30
  if (c.def.mode !== 'multi' || !c.def.translatable) return []
21
31
  const group = page.translationGroup
22
32
  if (typeof group !== 'string' || !group) return []
33
+ captureRead(translationGroupTag(c.def.name, group))
23
34
  const cols = getTableColumns(c.table) as Record<string, never>
24
35
  // A status-less pageLike collection has no draft state — every sibling is "published".
25
36
  const hasStatus = Object.hasOwn(cols, 'status')
@@ -54,13 +65,15 @@ function publishedAlternates(db: BetterSQLite3Database, c: BuiltCollection, page
54
65
 
55
66
  /**
56
67
  * The first page-like record (across all collections, in registration order) whose `path` matches,
57
- * populated at depth 1 — or null. Reuses the access-scoped `list()` so media/links populate exactly as
58
- * a direct collection read would. `publishedOnly` defaults true: a static render (prerender / runtime
59
- * publisher) and an anonymous live request must never see drafts. The authenticated-admin live preview
60
- * passes false to surface a draft at its real URL. Registration order is the precedence rule when two
61
- * page-like collections happen to share a path.
68
+ * populated at depth 1 — or null, alongside the collections that could not be read at all. Reuses the
69
+ * access-scoped `list()` so media/links populate exactly as a direct collection read would.
70
+ * `publishedOnly` defaults true: a static render (prerender / runtime publisher) and an anonymous live
71
+ * request must never see drafts. The authenticated-admin live preview passes false to surface a draft at
72
+ * its real URL. Registration order is the precedence rule when two page-like collections happen to share
73
+ * a path.
62
74
  */
63
- export function resolvePage(db: BetterSQLite3Database, collections: BuiltCollection[], path: string, locale?: string, publishedOnly = true): ResolvedPage | null {
75
+ export function resolvePage(db: BetterSQLite3Database, collections: BuiltCollection[], path: string, locale?: string, publishedOnly = true): PageResolution {
76
+ const failed: string[] = []
64
77
  for (const c of collections) {
65
78
  if (!c.def.pageLike) continue
66
79
  // withTotal:false — read only the first row, skip count(). capture:false — don't tag the whole
@@ -73,14 +86,15 @@ export function resolvePage(db: BetterSQLite3Database, collections: BuiltCollect
73
86
  // Table not migrated yet (e.g. a bare prerender DB) — isolate this collection's drift from the rest,
74
87
  // but never silently: an unread collection is indistinguishable from one with no matching page, and
75
88
  // the publisher would write a 404 over every one of its routes.
89
+ failed.push(c.def.name)
76
90
  console.error(`[kestrel] resolvePage: skipped collection ${c.def.name}:`, (error as Error)?.message ?? error)
77
91
  continue
78
92
  }
79
93
  const { data } = result
80
94
  if (data.length) {
81
95
  captureRead(c.def.name, (data[0] as { id?: number }).id ?? null)
82
- return { collection: c.def.name, page: data[0]!, alternates: publishedAlternates(db, c, data[0]!) }
96
+ return { page: { collection: c.def.name, page: data[0]!, alternates: publishedAlternates(db, c, data[0]!) }, failed }
83
97
  }
84
98
  }
85
- return null
99
+ return { page: null, failed }
86
100
  }
@@ -0,0 +1,109 @@
1
+ import { randomBytes } from 'node:crypto'
2
+ import type { H3Event } from 'h3'
3
+
4
+ /**
5
+ * Short-lived tickets carrying the editor's UNSAVED form state to a real page render. The editor's own
6
+ * iframe gets unsaved content over postMessage, but an external tab has no parent window to talk to — so
7
+ * instead of saving (which would publish intent the user never expressed) it mints a ticket and opens
8
+ * `<url>?kestrel-preview-token=…`. Nothing is written to the DB; the ticket lives in this process only.
9
+ *
10
+ * The admin session is the actual gate (both endpoints are admin-only under the default-deny API guard).
11
+ * The owner binding on top is NOT per-session isolation today: Kestrel has one shared admin credential and
12
+ * `derivePrincipal` (access layer) never mints more than one admin identity, so every caller that reaches
13
+ * this store has already been narrowed by `requireAdmin` to the same principal, and `previewOwner()`
14
+ * resolves to the literal string `'admin'` every time — `t.owner === owner` cannot currently be false for
15
+ * an admin caller. The binding is kept because it is the seam that makes the check meaningful the moment
16
+ * (if ever) a per-user identity is added upstream; until then it costs nothing and documents the intent.
17
+ * Tickets stay readable until they expire — a preview tab may be reloaded — and the store bounds itself in
18
+ * both directions: a sweep on every mint, and a hard cap that evicts the oldest ticket.
19
+ *
20
+ * In-memory by design: previewing is a per-editor, per-minute affair, and a second server instance would
21
+ * simply re-mint. Nothing durable depends on it.
22
+ */
23
+ export interface PreviewPayload {
24
+ collection: string
25
+ /** The record being previewed, or null for one that has never been saved. */
26
+ id: number | null
27
+ locale?: string
28
+ /** The editor's populated values — the same tree the live-preview bridge posts into the iframe. */
29
+ values: Record<string, unknown>
30
+ }
31
+
32
+ export interface PreviewTicket {
33
+ token: string
34
+ expiresAt: number
35
+ }
36
+
37
+ export interface PreviewStore {
38
+ mint: (owner: string, payload: PreviewPayload) => PreviewTicket
39
+ read: (token: string, owner: string) => PreviewPayload | null
40
+ size: () => number
41
+ }
42
+
43
+ export interface PreviewStoreOptions {
44
+ ttlMs?: number
45
+ max?: number
46
+ now?: () => number
47
+ randomToken?: () => string
48
+ }
49
+
50
+ const TTL_MS = 10 * 60 * 1000
51
+ const MAX_TICKETS = 32
52
+
53
+ export function createPreviewStore(opts: PreviewStoreOptions = {}): PreviewStore {
54
+ const ttlMs = opts.ttlMs ?? TTL_MS
55
+ const max = opts.max ?? MAX_TICKETS
56
+ const now = opts.now ?? Date.now
57
+ const randomToken = opts.randomToken ?? (() => randomBytes(24).toString('base64url'))
58
+ // Insertion-ordered, which is what makes "evict the oldest" a single `keys().next()`.
59
+ const tickets = new Map<string, { owner: string; payload: PreviewPayload; expiresAt: number }>()
60
+
61
+ function sweep(at: number): void {
62
+ for (const [token, t] of tickets) if (t.expiresAt <= at) tickets.delete(token)
63
+ }
64
+
65
+ return {
66
+ mint(owner, payload) {
67
+ const at = now()
68
+ sweep(at)
69
+ while (tickets.size >= max) tickets.delete(tickets.keys().next().value as string)
70
+ const token = randomToken()
71
+ const expiresAt = at + ttlMs
72
+ tickets.set(token, { owner, payload, expiresAt })
73
+ return { token, expiresAt }
74
+ },
75
+ read(token, owner) {
76
+ const t = tickets.get(token)
77
+ if (!t) return null
78
+ if (t.expiresAt <= now()) {
79
+ tickets.delete(token)
80
+ return null
81
+ }
82
+ // Bound to the minting owner rather than trusted on token possession alone — inert while every admin
83
+ // caller resolves to the same owner (see the module docstring), but the check a future per-user
84
+ // identity would need is already the one being run, not one that would need to be added later.
85
+ return t.owner === owner ? t.payload : null
86
+ },
87
+ size: () => tickets.size,
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Who a ticket belongs to. In production this only ever runs after `requireAdmin(event)` has already
93
+ * thrown for anyone but the admin principal, and `derivePrincipal` (access layer) always gives that
94
+ * principal a fixed `userId: 'admin'` — so the first branch always wins and this always returns the
95
+ * literal `'admin'`. The `role` / `'anonymous'` fallbacks are unreached by any principal shape
96
+ * `derivePrincipal` produces today; kept as a defensive default rather than a non-null assertion, since
97
+ * this function has no way to enforce that invariant itself.
98
+ */
99
+ export function previewOwner(event: H3Event): string {
100
+ const principal = event.context.principal as { userId?: string | null; role?: string } | undefined
101
+ return principal?.userId ?? principal?.role ?? 'anonymous'
102
+ }
103
+
104
+ /** The process-wide store the two `/api/preview` handlers share. */
105
+ let shared: PreviewStore | null = null
106
+ export function usePreviewStore(): PreviewStore {
107
+ shared ??= createPreviewStore()
108
+ return shared
109
+ }
@@ -27,6 +27,12 @@ export interface WriteClassification {
27
27
  /** The page's own public route from the OLD row (pageLike + had a path), else null — the route whose
28
28
  * static file must be pruned on a slug change / unpublish / delete (symmetric to `selfRoute`). */
29
29
  oldRoute: string | null
30
+ groupTag: string | null
31
+ }
32
+
33
+ /** The data tag naming a translation group. `#` keeps it clear of the `<coll>:<id>` record namespace. */
34
+ export function translationGroupTag(coll: string, group: string): string {
35
+ return `${coll}#group:${group}`
30
36
  }
31
37
 
32
38
  /** What to republish for a write. Routes are resolved from `tags` against the captured deps index. */
@@ -71,13 +77,43 @@ export function classifyWrite(def: WriteCollection, before: Row, after: Row, pri
71
77
  // change (path unchanged) still moves the route — the old-locale file must be pruned.
72
78
  const pathChanged = pageLike && status === 'updated' && oldRoute !== selfRoute
73
79
 
74
- return { collection: def.name, pageLike, status, id, pathChanged, statusChanged, isPublished, wasPublished, selfRoute, oldRoute }
80
+ // `update` refuses to move a row between groups, so the surviving row's group is the group either way.
81
+ const group = row?.translationGroup
82
+ const groupTag = typeof group === 'string' && group ? translationGroupTag(def.name, group) : null
83
+
84
+ return { collection: def.name, pageLike, status, id, pathChanged, statusChanged, isPublished, wasPublished, selfRoute, oldRoute, groupTag }
85
+ }
86
+
87
+ /**
88
+ * What the write listener enqueues for a content write. `publishOnSave` (`output.publishOnSave`) is the
89
+ * documented way back to the pre-2.0 model where a save WAS a publish: with it on, a write plans exactly
90
+ * what it always did. Off (the default), only removals pass — see `planSaveInvalidation`.
91
+ */
92
+ export function planWrite(ev: WriteClassification, publishOnSave: boolean): Invalidation {
93
+ return publishOnSave ? planInvalidation(ev) : planSaveInvalidation(ev)
94
+ }
95
+
96
+ /**
97
+ * What a plain SAVE may do to the static output. Saving persists to the DB; writing a page's file is the
98
+ * explicit publish action's job (`planInvalidation`, driven by `POST /api/publish`), so a save renders
99
+ * nothing — the live site keeps serving the last published version while the editor works on the next one.
100
+ *
101
+ * REMOVAL is the asymmetry, and it is deliberate: an unpublished or deleted record must not keep a live
102
+ * page, so those two branches act immediately. Their referrer/listing re-renders come along, because a
103
+ * baked link to a page that just went offline is stale the moment it goes — the same "availability" rule
104
+ * `planInvalidation` documents, minus everything that would put NEW content on the live site.
105
+ */
106
+ export function planSaveInvalidation(ev: WriteClassification): Invalidation {
107
+ const removal = ev.status === 'deleted' || (ev.statusChanged && !ev.isPublished)
108
+ return removal ? planInvalidation(ev) : { type: 'noop' }
75
109
  }
76
110
 
77
111
  /**
78
- * Decide what a write invalidates, per the maintainer-agreed model. Two notions of "dependent":
112
+ * Decide what a write invalidates, per the maintainer-agreed model. Three notions of "dependent":
79
113
  * - LISTINGS — pages that QUERY the collection (overviews) → captured as the `<coll>` tag.
80
114
  * - EXPLICIT REFERRERS — pages that LINK/EMBED/relate-to a specific record → captured as `<coll>:<id>`.
115
+ * - TRANSLATION SIBLINGS — every member of a group bakes the group's hreflang set → captured as the
116
+ * group tag, the only edge that reaches members which rendered before this row existed.
81
117
  *
82
118
  * Two principles drive the split:
83
119
  * 1. FRESHENING (content or path changed) re-renders BOTH listings and explicit referrers (`[coll, coll:id]`)
@@ -94,7 +130,10 @@ export function classifyWrite(def: WriteCollection, before: Row, after: Row, pri
94
130
  export function planInvalidation(ev: WriteClassification): Invalidation {
95
131
  const coll = ev.collection
96
132
  const recordTag = ev.id != null ? `${coll}:${ev.id}` : null
97
- const tags = recordTag ? [coll, recordTag] : [coll]
133
+ // Unlike recordTag (dropped on create — no referrer can target a brand-new id), groupTag is included even
134
+ // there: a new sibling still changes every existing member's hreflang set.
135
+ const groupTags = ev.groupTag ? [ev.groupTag] : []
136
+ const tags = recordTag ? [coll, recordTag, ...groupTags] : [coll, ...groupTags]
98
137
  const selfRender = ev.pageLike && ev.selfRoute ? [ev.selfRoute] : []
99
138
 
100
139
  // DELETE — leaves the collection. Listings re-render, referrers too (their baked link/hreflang now points
@@ -108,7 +147,7 @@ export function planInvalidation(ev: WriteClassification): Invalidation {
108
147
  // record re-renders listings + its own route. No referrer can point at a brand-new id, so no `coll:id`.
109
148
  if (ev.status === 'created') {
110
149
  if (!ev.isPublished) return { type: 'noop' }
111
- return { type: 'tags', tags: [coll], render: selfRender, prune: [] }
150
+ return { type: 'tags', tags: [coll, ...groupTags], render: selfRender, prune: [] }
112
151
  }
113
152
 
114
153
  // UNPUBLISH — leaves the published set. Listings re-render; referrers re-render so their link falls back to