@michaelthielemann/kestrel 1.7.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 (62) hide show
  1. package/README.md +8 -4
  2. package/layers/access/server/utils/grant-registry.ts +1 -1
  3. package/layers/admin/app/components/BlocksBody.vue +2 -1
  4. package/layers/admin/app/components/CollectionEditor.vue +124 -22
  5. package/layers/admin/app/components/CollectionList.vue +8 -4
  6. package/layers/admin/app/components/EditorStatus.vue +11 -0
  7. package/layers/admin/app/components/SeoFields.vue +1 -0
  8. package/layers/admin/app/components/SingletonEditor.vue +6 -6
  9. package/layers/admin/app/composables/useCollectionOps.ts +15 -3
  10. package/layers/admin/app/composables/useEditForm.ts +6 -3
  11. package/layers/admin/app/composables/useListColumns.ts +1 -1
  12. package/layers/admin/app/composables/usePublishStatus.ts +9 -0
  13. package/layers/admin/app/pages/admin/[collection]/[id].vue +7 -7
  14. package/layers/admin/app/pages/admin/[collection]/publish-preview.nuxt.test.ts +142 -0
  15. package/layers/admin/app/utils/editor-expose.ts +8 -0
  16. package/layers/core/modules/auto-discovery/extract-block.ts +5 -2
  17. package/layers/core/modules/kestrel/index.ts +1 -0
  18. package/layers/core/server/schema/introspect.ts +1 -1
  19. package/layers/core/server/schema/sync.ts +1 -1
  20. package/layers/core/server/utils/crud.ts +5 -4
  21. package/layers/core/server/utils/kestrel-config.ts +7 -0
  22. package/layers/fields/server/field-registry/index.ts +2 -1
  23. package/layers/fields/server/field-registry/sanitize.ts +6 -3
  24. package/layers/media/app/components/MediaLibrary.vue +4 -1
  25. package/layers/media/app/components/MediaToolbar.vue +1 -1
  26. package/layers/media/app/components/field/Media.vue +2 -0
  27. package/layers/media/app/composables/useMediaLibrary.ts +2 -1
  28. package/layers/public/app/pages/[...slug].vue +27 -6
  29. package/layers/public/app/pages/__kestrel/preview.vue +15 -3
  30. package/layers/public/app/utils/preview-protocol.ts +36 -0
  31. package/layers/public/server/api/preview.get.ts +28 -0
  32. package/layers/public/server/api/preview.post.ts +93 -0
  33. package/layers/public/server/api/publish-status.get.ts +23 -9
  34. package/layers/public/server/api/publish.post.ts +84 -0
  35. package/layers/public/server/plugins/zz.publish.ts +12 -3
  36. package/layers/public/server/tasks/publish/run.ts +2 -1
  37. package/layers/public/server/utils/preview-token.ts +109 -0
  38. package/layers/public/server/utils/publish/invalidation.ts +24 -0
  39. package/layers/public/server/utils/publish/pending.ts +74 -0
  40. package/layers/public/server/utils/publish/publish-runtime.ts +28 -0
  41. package/layers/public/server/utils/publish/publish-status.ts +18 -0
  42. package/layers/public/server/utils/publish/publisher.ts +74 -9
  43. package/layers/ui/app/components/field/Datetime.vue +2 -0
  44. package/layers/ui/app/components/field/Repeater.vue +2 -0
  45. package/layers/ui/app/components/ui/Checkbox.vue +1 -0
  46. package/layers/ui/app/components/ui/CheckboxGroup.vue +1 -0
  47. package/layers/ui/app/components/ui/Combobox.vue +2 -0
  48. package/layers/ui/app/components/ui/Field.vue +1 -0
  49. package/layers/ui/app/components/ui/Fieldset.vue +2 -1
  50. package/layers/ui/app/components/ui/Icon.vue +2 -2
  51. package/layers/ui/app/components/ui/NumberInput.vue +2 -0
  52. package/layers/ui/app/components/ui/Richtext.vue +25 -3
  53. package/layers/ui/app/components/ui/Select.vue +1 -0
  54. package/layers/ui/app/components/ui/TextInput.vue +1 -0
  55. package/layers/ui/app/components/ui/Textarea.vue +1 -0
  56. package/layers/ui/app/components/ui/TimeInput.vue +1 -0
  57. package/layers/ui/app/i18n/de.ts +10 -0
  58. package/layers/ui/app/i18n/en.ts +10 -0
  59. package/package.json +6 -1
  60. package/scripts/kestrel.mjs +7 -3
  61. package/scripts/lib/scaffold.mjs +23 -1
  62. package/templates/starter/app/blocks/Prose.vue +1 -0
@@ -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
+ })
@@ -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
  /**
@@ -19,7 +20,7 @@ import { registerWriteListener } from '../../../core/server/utils/write-events'
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:
@@ -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)
@@ -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
+ }
@@ -84,6 +84,30 @@ export function classifyWrite(def: WriteCollection, before: Row, after: Row, pri
84
84
  return { collection: def.name, pageLike, status, id, pathChanged, statusChanged, isPublished, wasPublished, selfRoute, oldRoute, groupTag }
85
85
  }
86
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' }
109
+ }
110
+
87
111
  /**
88
112
  * Decide what a write invalidates, per the maintainer-agreed model. Three notions of "dependent":
89
113
  * - LISTINGS — pages that QUERY the collection (overviews) → captured as the `<coll>` tag.
@@ -0,0 +1,74 @@
1
+ /**
2
+ * "Saved but not published": the state a deferred-publish model needs to name. Saving writes the DB,
3
+ * publishing writes the static file, so the two stamps drift apart on purpose — a record edited after its
4
+ * page was last published keeps serving the published version until someone publishes again.
5
+ *
6
+ * The tolerance is not cosmetic: `publish_status.updated_at` is stored in whole seconds while a record's
7
+ * `updatedAt` is milliseconds, so the publish that directly followed a save can carry a stamp up to a
8
+ * second BEHIND it. Without the slack every freshly published page would report unpublished changes.
9
+ */
10
+ const TOLERANCE_MS = 1000
11
+
12
+ export function hasPendingChanges(savedAtMs: number | null | undefined, publishedAtMs: number | null | undefined, toleranceMs = TOLERANCE_MS): boolean {
13
+ // Never published (no status row) → nothing to protect: there is no older artifact this edit could
14
+ // overtake, so the route is a normal render candidate, not a pending change.
15
+ if (savedAtMs == null || publishedAtMs == null) return false
16
+ return savedAtMs > publishedAtMs + toleranceMs
17
+ }
18
+
19
+ /** The subset of `savedAt` routes whose record moved on after the route's last publish. Pure. */
20
+ export function pendingRoutes(savedAt: Map<string, number>, publishedAt: Map<string, number>): string[] {
21
+ const out: string[] = []
22
+ for (const [route, saved] of savedAt) {
23
+ if (hasPendingChanges(saved, publishedAt.get(route) ?? null)) out.push(route)
24
+ }
25
+ return out
26
+ }
27
+
28
+ export interface HeldRoutes {
29
+ /** Routes to leave un-rendered: their record is serving an older published version somewhere. */
30
+ hold: Set<string>
31
+ /** Previously-published routes that are still the live artifact of a held record — never prune these. */
32
+ keep: Set<string>
33
+ }
34
+
35
+ /**
36
+ * Withholding by RECORD rather than by route string, which is what a rename needs. `pendingRoutes` compares
37
+ * a route against its own publish stamp, so a renamed record — whose new route has no stamp at all — falls
38
+ * through the "never published, nothing to protect" carve-out: the unpublished rename gets rendered and the
39
+ * old route, still the live one, is left looking abandoned to the prune. The carve-out is about a FIRST
40
+ * deploy having no older version; a rename has one, at the previous route.
41
+ *
42
+ * So a record's prior published routes are consulted too: if the record has moved on since the newest of
43
+ * them, the new route is held back and those prior routes are protected from the prune. A record with no
44
+ * prior published route keeps the carve-out — otherwise a first deploy would produce an empty site.
45
+ *
46
+ * Pure: `routesForTag` is the deps index's lookup, passed in.
47
+ */
48
+ export function heldRoutes(
49
+ savedAt: Map<string, number>,
50
+ publishedAt: Map<string, number>,
51
+ recordTag: Map<string, string>,
52
+ routesForTag: (tag: string) => Iterable<string>,
53
+ toleranceMs = TOLERANCE_MS,
54
+ ): HeldRoutes {
55
+ const hold = new Set<string>()
56
+ const keep = new Set<string>()
57
+ for (const [route, saved] of savedAt) {
58
+ const own = publishedAt.get(route) ?? null
59
+ if (own != null) {
60
+ if (hasPendingChanges(saved, own, toleranceMs)) hold.add(route)
61
+ continue
62
+ }
63
+ // No stamp of its own: either genuinely never published, or published under a previous route.
64
+ const tag = recordTag.get(route)
65
+ if (!tag) continue
66
+ const priors = [...routesForTag(tag)].filter((r) => r !== route && publishedAt.has(r))
67
+ if (!priors.length) continue // first publish of this record — nothing to protect
68
+ const newest = Math.max(...priors.map((r) => publishedAt.get(r)!))
69
+ if (!hasPendingChanges(saved, newest, toleranceMs)) continue
70
+ hold.add(route)
71
+ for (const prior of priors) keep.add(prior)
72
+ }
73
+ return { hold, keep }
74
+ }
@@ -0,0 +1,28 @@
1
+ import type { PublishQueue } from './queue'
2
+ import type { DepsStore } from './deps'
3
+
4
+ /**
5
+ * The process-wide handle on the running publish machinery. The `zz.publish` Nitro plugin owns the queue
6
+ * and the deps index (it builds the output driver and does the logging), but the explicit publish action —
7
+ * `POST /api/publish` — has to reach both from inside a request: the queue to enqueue the run, the deps
8
+ * index to find the routes this record was baked into. Mirrors core's `write-events` registry: module
9
+ * state, set once at boot.
10
+ *
11
+ * `null` wherever the runtime publisher does not run at all (dev, or `output.auto` off). That is not an
12
+ * error — it is what the endpoint reports back so the editor can say "nothing is generated here" instead
13
+ * of pretending a publish happened.
14
+ */
15
+ export interface PublishRuntime {
16
+ queue: PublishQueue
17
+ deps: DepsStore
18
+ }
19
+
20
+ let runtime: PublishRuntime | null = null
21
+
22
+ export function setPublishRuntime(next: PublishRuntime | null): void {
23
+ runtime = next
24
+ }
25
+
26
+ export function usePublishRuntime(): PublishRuntime | null {
27
+ return runtime
28
+ }
@@ -43,6 +43,24 @@ export function renderOutcome(status: number, hasBody: boolean): 'success' | 'er
43
43
  return 'skip'
44
44
  }
45
45
 
46
+ /**
47
+ * Every route's last successful-or-failed publish time, in ms — the "last published" half of the
48
+ * saved-vs-published comparison a deferred publish needs. Same missing-table resilience as the writers:
49
+ * an unmigrated deploy yields an empty map, which reads as "nothing was ever published here" and so
50
+ * holds nothing back.
51
+ */
52
+ export function lastPublishedAt(db: BetterSQLite3Database): Map<string, number> {
53
+ const out = new Map<string, number>()
54
+ try {
55
+ for (const row of db.select({ route: publishStatus.route, updatedAt: publishStatus.updatedAt }).from(publishStatus).all()) {
56
+ if (row.updatedAt instanceof Date) out.set(row.route, row.updatedAt.getTime())
57
+ }
58
+ } catch (error) {
59
+ console.warn('[kestrel] could not read publish status:', (error as Error).message)
60
+ }
61
+ return out
62
+ }
63
+
46
64
  /** Clear a route's status row — its static file was pruned (unpublish / delete / slug change), so it is no
47
65
  * longer live. Idempotent; same missing-table resilience as `recordPublishStatus`. */
48
66
  export function clearPublishStatus(db: BetterSQLite3Database, route: string): void {
@@ -12,8 +12,9 @@ import { withResolveScope } from '../../../../core/server/utils/resolve-scope'
12
12
  import { runAsRenderer } from '../../../../access/server/utils/render-context'
13
13
  import { htmlKeyForRoute } from './route-keys'
14
14
  import { staleRoutes, type DepsStore } from './deps'
15
- import { recordPublishStatus, clearPublishStatus, renderOutcome } from './publish-status'
15
+ import { recordPublishStatus, clearPublishStatus, renderOutcome, lastPublishedAt } from './publish-status'
16
16
  import { routesToPrune, type Invalidation } from './invalidation'
17
+ import { pendingRoutes, heldRoutes } from './pending'
17
18
 
18
19
  /**
19
20
  * The runtime static publisher: renders public routes from the LIVE server (`localFetch`, the same
@@ -28,6 +29,7 @@ interface OutputRc {
28
29
  dir: string
29
30
  publicDir: string
30
31
  auto: boolean
32
+ publishOnSave: boolean
31
33
  reconcileMinutes: number
32
34
  verbose: boolean
33
35
  s3: { bucket: string; region: string; endpoint: string; prefix: string; accessKeyId: string; secretAccessKey: string; sessionToken: string }
@@ -69,6 +71,11 @@ export async function renderRoute(route: string): Promise<{ body: Buffer | null;
69
71
  * route set is INCOMPLETE, so it must never be used as the authority for what to delete. */
70
72
  export interface PublishedRoutes {
71
73
  routes: string[]
74
+ /** Each route's record `updatedAt` in ms — the "last saved" half of the saved-vs-published comparison. */
75
+ savedAt: Map<string, number>
76
+ /** Each route's owning record as its deps tag (`<coll>:<id>`) — route strings move on a rename, records
77
+ * do not, so withholding a renamed page needs the identity behind the route. */
78
+ recordTag: Map<string, string>
72
79
  /** Names of collections whose route query threw (drifted schema, locked DB) — routes are missing. */
73
80
  failed: string[]
74
81
  }
@@ -82,6 +89,8 @@ export function allPublishedRoutes(): PublishedRoutes {
82
89
  const prefixPrimary = prefixPrimaryLocale()
83
90
  const pub = publicReadableResources()
84
91
  const routes = new Set<string>([localePath('/', primary, primary, prefixPrimary)]) // `/` or `/<primary>`
92
+ const savedAt = new Map<string, number>()
93
+ const recordTag = new Map<string, string>()
85
94
  const failed: string[] = []
86
95
  for (const c of allCollections()) {
87
96
  if (!c.def.pageLike || !isPubliclyReadable(c.def.name, pub)) continue
@@ -94,6 +103,10 @@ export function allPublishedRoutes(): PublishedRoutes {
94
103
  const proj: Record<string, unknown> = { path: cols.path }
95
104
  if (c.def.translatable) proj.locale = cols.locale
96
105
  if (c.def.status) proj.status = cols.status
106
+ // `updatedAt` is a system column on every built collection, but this projection also runs against
107
+ // hand-rolled tables in tests — guard it exactly like `locale`, or its absence throws the select.
108
+ if (Object.hasOwn(cols, 'updatedAt')) proj.updatedAt = cols.updatedAt
109
+ if (Object.hasOwn(cols, 'id')) proj.id = cols.id
97
110
  let rows: Record<string, unknown>[]
98
111
  try { rows = db.select(proj as never).from(c.table).all() as Record<string, unknown>[] }
99
112
  catch (error) {
@@ -106,10 +119,17 @@ export function allPublishedRoutes(): PublishedRoutes {
106
119
  for (const row of rows) {
107
120
  if (c.def.status && row.status !== 'published') continue
108
121
  const route = pageRowHref(row, primary, prefixPrimary) // the shared (path, locale) → route rule
109
- if (route) routes.add(route)
122
+ if (!route) continue
123
+ routes.add(route)
124
+ const saved = row.updatedAt
125
+ if (saved instanceof Date) savedAt.set(route, saved.getTime())
126
+ else if (typeof saved === 'number') savedAt.set(route, saved)
127
+ // The same tag the publisher records against a rendered route, so a route can be traced back to its
128
+ // record even after a rename moved the route string.
129
+ if (typeof row.id === 'number') recordTag.set(route, `${c.def.name}:${row.id}`)
110
130
  }
111
131
  }
112
- return { routes: [...routes], failed }
132
+ return { routes: [...routes], savedAt, recordTag, failed }
113
133
  }
114
134
 
115
135
  /** Render + write the given routes (skips non-200). When `deps` is given, each render is wrapped in a
@@ -226,7 +246,7 @@ export async function publishFull(driver: StorageDriver = outputDriver(), deps?:
226
246
  // runs late (layer-then-filename order) — finish populating the registry first. Moving the read before
227
247
  // an await would silently render an empty registry. See docs/architecture.md → "Server plugins".
228
248
  await syncStaticAssets(driver, cfg.publicDir)
229
- const { routes, failed } = allPublishedRoutes()
249
+ const { routes, savedAt, recordTag, failed } = allPublishedRoutes()
230
250
  if (failed.length) {
231
251
  // Keep on doubt: with a collection missing from the enumeration, every one of its live pages looks
232
252
  // stale, so a prune would wipe it from the output. Rendering still proceeds — a stale extra file is
@@ -234,12 +254,27 @@ export async function publishFull(driver: StorageDriver = outputDriver(), deps?:
234
254
  console.error(`[kestrel] publish: prune skipped — routes of ${failed.join(', ')} could not be enumerated; existing files kept`)
235
255
  }
236
256
 
257
+ // A full run resynchronizes the output with the DB, so without this it would push every saved-but-
258
+ // unpublished edit live — exactly what deferring the publish exists to prevent. Those routes keep the
259
+ // file their last publish wrote.
260
+ // …unless the consumer opted out of the split (`output.publishOnSave`): there, a save IS a publish, so
261
+ // "saved after the last publish" means a republish is merely in flight, not deliberately withheld.
262
+ // Computed BEFORE the prune, because a held record's live file may sit at a route the DB no longer names
263
+ // (an unpublished rename), and that file is what the site is still serving. Without `deps` there is no
264
+ // way to find those prior routes, so only same-route withholding applies — the pre-rename behaviour.
265
+ const { hold, keep } = cfg.publishOnSave
266
+ ? { hold: new Set<string>(), keep: new Set<string>() }
267
+ : heldRoutes(savedAt, lastPublishedAt(useDb()), recordTag, (tag) => deps?.routesForTags([tag]) ?? [])
268
+ if (hold.size) {
269
+ console.info(`[kestrel] publish: ${hold.size} route(s) held at their published version (unpublished changes): ${[...hold].join(', ')}`)
270
+ }
271
+
237
272
  // Targeted prune: a route we previously published that is no longer in the published set — a page
238
273
  // unpublished, deleted, or whose slug changed — must lose its static file. Safe because it only deletes
239
274
  // files this publisher wrote (tracked in deps, durable across restarts). Output ≡ DB; no opt-in toggle.
240
275
  let pruned = 0
241
276
  if (deps && !failed.length) {
242
- const stale = staleRoutes(deps.routes(), routes)
277
+ const stale = staleRoutes(deps.routes(), routes).filter((route) => !keep.has(route))
243
278
  if (stale.length) {
244
279
  await prunePages(stale, driver)
245
280
  for (const route of stale) deps.forget(route)
@@ -247,11 +282,13 @@ export async function publishFull(driver: StorageDriver = outputDriver(), deps?:
247
282
  }
248
283
  }
249
284
 
285
+ const renderRoutes = routes.filter((route) => !hold.has(route))
286
+
250
287
  // Reset the discovery accumulator so this full run reconciles ONLY what it actually renders — an earlier
251
288
  // incremental (tag) publish also feeds the accumulator, and a variant it recorded whose usage was later
252
289
  // removed would otherwise survive and be re-registered here (defeating usage-driven narrowing).
253
290
  clearVariants()
254
- const written = await publishRoutes(routes, driver, deps)
291
+ const written = await publishRoutes(renderRoutes, driver, deps)
255
292
  const rendered = written.length
256
293
  await publishMeta(driver)
257
294
  // Auto-discovery: a FULL render just visited every published route, so the capture accumulator now holds
@@ -260,8 +297,10 @@ export async function publishFull(driver: StorageDriver = outputDriver(), deps?:
260
297
  // by the un-rendered routes. ONLY narrow when EVERY route rendered: a partial failure leaves the accumulator
261
298
  // incomplete, so reconciling would deregister variants still referenced by the stale (kept) published HTML,
262
299
  // which a later backfill would then delete out from under the live page. An un-enumerated collection is
263
- // the same partial-coverage case: its pages were never visited, so their variants are missing too.
264
- if (!failed.length && rendered === routes.length) saveDiscoveredVariants(useDb())
300
+ // the same partial-coverage case: its pages were never visited, so their variants are missing too — and
301
+ // so is a route held back at its published version: its live file still references the variants this run
302
+ // never saw.
303
+ if (!failed.length && !hold.size && rendered === renderRoutes.length) saveDiscoveredVariants(useDb())
265
304
  return { rendered, pruned }
266
305
  }
267
306
 
@@ -270,13 +309,39 @@ export async function publishFull(driver: StorageDriver = outputDriver(), deps?:
270
309
  * (their `<lastmod>` may have changed). */
271
310
  export interface PublishResult { rendered: string[]; pruned: string[]; counts: { rendered: number; pruned: number } }
272
311
 
312
+ /**
313
+ * Drop the routes a tag match dragged in that are holding their published version back. Withholding is a
314
+ * property of the ROUTE, not of the full publish: a route whose record was saved after its last publish
315
+ * serves that published file until someone publishes it. Without this, publishing one record re-renders
316
+ * every route tagged with the collection — and every route reads the `site` singleton — from the live DB,
317
+ * so a routine Publish writes an unrelated record's withheld body to the live site.
318
+ *
319
+ * A route named in `render` is exempt: it IS what the publish was for, and pressing Publish is what clears
320
+ * the withholding. The prune set is untouched — removal has no publish intent left to protect, so an
321
+ * unpublished or deleted record's page still goes at once (ADR-0008).
322
+ *
323
+ * The cost, deliberately accepted: a withheld route keeps the baked links and hreflang of its last
324
+ * publish, so a link to a record that has since been unpublished stays stale until the referrer itself is
325
+ * published. That is the same staleness its body already carries — a frozen route is one publish
326
+ * generation throughout, rather than a mix of two. Rendering a referrer from its published state while
327
+ * resolving fresh links needs a published snapshot per record, which is ADR-0008's "Future".
328
+ */
329
+ function withheldRemoved(inv: Extract<Invalidation, { type: 'tags' }>, routes: string[]): string[] {
330
+ if (outputConfig().publishOnSave) return routes // that mode never defers a publish in the first place
331
+ // An un-enumerable collection contributes no `savedAt` entry, so its routes are simply not withheld —
332
+ // the same direction publishFull takes, and the non-destructive one (a stale re-render, never a delete).
333
+ const held = new Set(pendingRoutes(allPublishedRoutes().savedAt, lastPublishedAt(useDb())))
334
+ const explicit = new Set(inv.render)
335
+ return routes.filter((route) => explicit.has(route) || !held.has(route))
336
+ }
337
+
273
338
  export async function publishInvalidation(inv: Invalidation, driver: StorageDriver = outputDriver(), deps?: DepsStore): Promise<PublishResult> {
274
339
  if (inv.type === 'noop') return { rendered: [], pruned: [], counts: { rendered: 0, pruned: 0 } }
275
340
  if (inv.type === 'full') {
276
341
  const r = await publishFull(driver, deps) // full: counts only (don't list every route)
277
342
  return { rendered: [], pruned: [], counts: { rendered: r.rendered, pruned: r.pruned } }
278
343
  }
279
- const routes = [...new Set([...(deps?.routesForTags(inv.tags) ?? []), ...inv.render])]
344
+ const routes = withheldRemoved(inv, [...new Set([...(deps?.routesForTags(inv.tags) ?? []), ...inv.render])])
280
345
  const rendered = await publishRoutes(routes, driver, deps)
281
346
  let pruned: string[] = []
282
347
  // Never prune a route we just wrote live — render wins a coalesced render+prune collision (see routesToPrune).
@@ -59,10 +59,12 @@ const rangeEnd = computed<string | null>({
59
59
  <UiFieldset v-else :id="id" :label="name" :error="error" :required="required">
60
60
  <template #default="f">
61
61
  <template v-if="isTime">
62
+ <!-- eslint-disable-next-line vuejs-accessibility/label-has-for -- native wrapping label around a custom UiTimeInput; no `for`/`id` pair needed, invisible to static analysis -->
62
63
  <label class="field-datetime__sub">
63
64
  <span class="field-datetime__sublabel">{{ t('field.datetime.range_start') }}</span>
64
65
  <UiTimeInput v-model="rangeStart" :disabled="disabled" v-bind="f" />
65
66
  </label>
67
+ <!-- eslint-disable-next-line vuejs-accessibility/label-has-for -- native wrapping label around a custom UiTimeInput; no `for`/`id` pair needed, invisible to static analysis -->
66
68
  <label class="field-datetime__sub">
67
69
  <span class="field-datetime__sublabel">{{ t('field.datetime.range_end') }}</span>
68
70
  <UiTimeInput v-model="rangeEnd" :disabled="disabled" v-bind="f" />
@@ -126,6 +126,7 @@ function insertRowAt(at: number) {
126
126
  {{ name }}<span v-if="required" aria-hidden="true">*</span>
127
127
  </legend>
128
128
 
129
+ <!-- eslint-disable-next-line vuejs-accessibility/no-static-element-interactions -- @dragleave is a mouse-only progressive enhancement; the move-up/move-down buttons below give the same reorder fully keyboard access -->
129
130
  <div ref="rowsEl" class="ui-repeater__rows" @dragleave="onDragLeave">
130
131
  <template v-for="(row, i) in rows" :key="keys[i]">
131
132
  <div class="ui-repeater__insert-zone">
@@ -140,6 +141,7 @@ function insertRowAt(at: number) {
140
141
  </button>
141
142
  </div>
142
143
 
144
+ <!-- eslint-disable-next-line vuejs-accessibility/no-static-element-interactions -- drag handlers are a mouse-only progressive enhancement; the move-up/move-down buttons below give the same reorder fully keyboard access -->
143
145
  <div
144
146
  class="ui-repeater__row-wrap"
145
147
  :class="{ 'ui-repeater__row-wrap--over': overIndex === i && dragIndex !== i }"
@@ -25,6 +25,7 @@ watch(() => props.indeterminate, syncIndeterminate)
25
25
  </script>
26
26
 
27
27
  <template>
28
+ <!-- eslint-disable-next-line vuejs-accessibility/form-control-has-label -- id/label land via UiField's attrs fallthrough (single root element), invisible to static analysis -->
28
29
  <input
29
30
  ref="input"
30
31
  v-model="model"
@@ -23,6 +23,7 @@ function toggle(value: string, checked: boolean) {
23
23
 
24
24
  <template>
25
25
  <div class="ui-checkbox-group" role="group">
26
+ <!-- eslint-disable-next-line vuejs-accessibility/label-has-for -- native wrapping label around a custom UiCheckbox; no `for`/`id` pair needed, invisible to static analysis -->
26
27
  <label v-for="o in options" :key="o.value" class="ui-checkbox-group__item">
27
28
  <UiCheckbox
28
29
  :model-value="selected.includes(o.value)"
@@ -117,7 +117,9 @@ function moveChip(from: number, to: number) {
117
117
  class="ui-combobox"
118
118
  >
119
119
  <ComboboxAnchor class="ui-combobox__anchor" :data-multiple="multiple || undefined" :aria-invalid="invalid || undefined">
120
+ <!-- eslint-disable-next-line vuejs-accessibility/no-static-element-interactions, vuejs-accessibility/no-redundant-roles -- @dragleave is a mouse-only progressive enhancement (the chip-move buttons below give the same reorder fully keyboard access); role="list" is NOT redundant here, `list-style: none` below strips the implicit list semantics in WebKit, see the "WebKit list-semantics fix" test -->
120
121
  <ul v-if="multiple && selected.length" ref="chips" class="ui-combobox__chips" role="list" @dragleave="onDragLeave">
122
+ <!-- eslint-disable-next-line vuejs-accessibility/no-static-element-interactions -- drag handlers are a mouse-only progressive enhancement; the chip-move buttons below give the same reorder fully keyboard access -->
121
123
  <li
122
124
  v-for="(s, i) in selected"
123
125
  :key="s.value"