@cardinali-studio/adminkit 2.2.9 → 2.2.11

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/dist/api.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { type JsonValue } from "./persistence.js";
2
+ import type { MediaOperationStatus } from "./media.js";
2
3
  export declare class ApiError extends Error {
3
4
  code: string;
4
5
  status: number;
@@ -143,6 +144,7 @@ export declare const api: {
143
144
  kind: string;
144
145
  updatedAt: number;
145
146
  }[];
147
+ mediaOperations?: MediaOperationStatus[];
146
148
  activeReleaseId?: string | null;
147
149
  }>;
148
150
  contentPublish: () => Promise<{
@@ -219,6 +221,19 @@ export declare const api: {
219
221
  }[];
220
222
  warning?: string;
221
223
  }>;
224
+ mediaOperations: () => Promise<{
225
+ ok: true;
226
+ operations: MediaOperationStatus[];
227
+ }>;
228
+ resumeMediaOperation: (operationId: string, releaseId: string) => Promise<{
229
+ ok: true;
230
+ completed: number;
231
+ failed: number;
232
+ }>;
233
+ cancelMediaOperation: (operationId: string) => Promise<{
234
+ ok: true;
235
+ cancelled: true;
236
+ }>;
222
237
  usage: (target: {
223
238
  keys?: string[];
224
239
  prefixes?: string[];
package/dist/api.js CHANGED
@@ -78,14 +78,38 @@ export const api = {
78
78
  /** `label` è il nome per esteso; lo slug della chiave lo calcola il server e lo restituisce. */
79
79
  createFolder: (parent, name, opts = {}) => post("/admin/api/assets/folder", { parent, name, ...opts }),
80
80
  /** Con `skipRefs` copia senza cancellare gli originali e restituisce la mappa degli URL. */
81
- move: (keys, dest, opts = {}) => post("/admin/api/assets/move", { keys, dest, ...opts }),
81
+ move: async (keys, dest, opts = {}) => {
82
+ const operationId = crypto.randomUUID();
83
+ const destinationPrefix = dest.trim().endsWith("/") ? dest.trim() : `${dest.trim()}/`;
84
+ const sourceKeys = [...new Set(keys)].filter((key) => {
85
+ const slash = key.lastIndexOf("/");
86
+ const parent = slash < 0 ? "" : key.slice(0, slash + 1);
87
+ return parent !== destinationPrefix;
88
+ });
89
+ if (!sourceKeys.length)
90
+ return {
91
+ ok: true, moved: 0, finalized: 0, urlMap: {}, pagesUpdated: [], failed: [],
92
+ };
93
+ const requestChecksum = await canonicalChecksum({ action: "media.operation", operationId, type: "move", sourceKeys, sourcePrefix: null, destinationPrefix });
94
+ return post("/admin/api/assets/move", { keys: sourceKeys, dest: destinationPrefix, operationId, requestChecksum, ...opts });
95
+ },
82
96
  /** Riscrive i riferimenti, poi elimina gli originali le cui copie sono state verificate. */
83
97
  moveApplyRefs: (applyRefs) => post("/admin/api/assets/move", { applyRefs }),
98
+ mediaOperations: () => call("/admin/api/assets/media"),
99
+ resumeMediaOperation: (operationId, releaseId) => post("/admin/api/assets/media", { action: "resume", operationId, releaseId }),
100
+ cancelMediaOperation: (operationId) => post("/admin/api/assets/media", { action: "cancel", operationId }),
84
101
  usage: (target) => post("/admin/api/assets/usage", target),
85
102
  /* `refsOnly` pulisce solo i riferimenti nelle pagine (un commit, niente file spostati);
86
103
  * `skipRefs` sposta i file e non tocca il repo. Servono a spezzare una cancellazione grande
87
104
  * in più richieste senza fare un commit — cioè una build — per ogni gruppo. */
88
- remove: (target, opts = {}) => post("/admin/api/assets/delete", { ...target, ...opts }),
105
+ remove: async (target, opts = {}) => {
106
+ const operationId = crypto.randomUUID();
107
+ const sourceKeys = target.prefixes?.length ? null : [...new Set(target.keys ?? [])];
108
+ const requestChecksum = sourceKeys?.length
109
+ ? await canonicalChecksum({ action: "media.operation", operationId, type: "delete", sourceKeys, sourcePrefix: null, destinationPrefix: null })
110
+ : undefined;
111
+ return post("/admin/api/assets/delete", { ...target, operationId, ...(requestChecksum ? { requestChecksum } : {}), ...opts });
112
+ },
89
113
  // ---- richieste dal form di contatto
90
114
  requests: () => call("/admin/api/requests"),
91
115
  markRequest: (id, handled) => call("/admin/api/requests", {
@@ -22,7 +22,12 @@ export function usePublish() {
22
22
  setAhead(d.ahead ?? 0);
23
23
  setCommits((d.documents ?? []).map((document) => ({
24
24
  message: `Modifica salvata (${document.kind})`, date: document.updatedAt ? new Date(document.updatedAt).toISOString() : null,
25
- })));
25
+ })).concat((d.mediaOperations ?? [])
26
+ .filter((operation) => operation.state === "ready")
27
+ .map((operation) => ({
28
+ message: operation.type === "move" ? "Spostamento Media pronto" : "Eliminazione Media pronta",
29
+ date: operation.updatedAt ? new Date(operation.updatedAt).toISOString() : null,
30
+ }))));
26
31
  setConfigured(true);
27
32
  setError(null);
28
33
  }
@@ -89,6 +89,7 @@ export declare class D1DeploymentAttemptRepository {
89
89
  finalize(input: {
90
90
  attemptId: string;
91
91
  releaseId: string;
92
+ intentKind: DeploymentIntent;
92
93
  tokenChecksum: string;
93
94
  now: number;
94
95
  }): Promise<void>;
@@ -163,6 +163,19 @@ export class D1DeploymentAttemptRepository {
163
163
  * batch. Every mutating statement repeats the capability guard, so a stale
164
164
  * claim cannot publish content even if a prior read was valid. */
165
165
  async finalize(input) {
166
+ if (input.intentKind === "code") {
167
+ const result = await this.db.prepare(`UPDATE production_deployment_attempts
168
+ SET phase = 'succeeded', active_slot = NULL, updated_at = ?, finished_at = ?
169
+ WHERE attempt_id = ? AND release_id = ? AND intent_kind = 'code' AND phase = 'verifying_canonical'
170
+ AND ci_claim_token_checksum = ? AND ci_claim_expires_at > ?
171
+ AND EXISTS (SELECT 1 FROM content_site_state state JOIN content_releases release
172
+ ON release.release_id = state.current_release_id
173
+ WHERE state.singleton = 1 AND state.current_release_id = ? AND release.status = 'published')`)
174
+ .bind(input.now, input.now, input.attemptId, input.releaseId, input.tokenChecksum, input.now, input.releaseId).run();
175
+ if (result.meta?.changes !== 1)
176
+ throw new Error("attempt_unavailable");
177
+ return;
178
+ }
166
179
  const guard = `EXISTS (SELECT 1 FROM production_deployment_attempts WHERE attempt_id = ? AND release_id = ?
167
180
  AND phase = 'verifying_canonical' AND ci_claim_token_checksum = ? AND ci_claim_expires_at > ?)`;
168
181
  const statements = [
@@ -23,6 +23,15 @@ import { SORT_LABELS } from "./useMediaLibrary.js";
23
23
  import { DRAG_TYPE, DRAG_FOLDERS, readDragged, FolderMenu } from "./MediaFolders.js";
24
24
  import { Lightbox } from "./Lightbox.js";
25
25
  import { UploadDialog } from "./UploadDialog.js";
26
+ const JOURNAL_LABELS = {
27
+ preparing: "Preparo le copie Media.",
28
+ ready: "Media pronta: pubblica per applicare la modifica.",
29
+ blocked_in_use: "Operazione bloccata: una bozza o la versione online usa ancora la sorgente.",
30
+ bound: "Pubblicazione con Media in corso.",
31
+ finalizing: "Pubblicazione riuscita: completo la pulizia Media.",
32
+ completed_with_warning: "Pubblicato con Media da completare.",
33
+ failed: "Operazione Media interrotta prima della pubblicazione.",
34
+ };
26
35
  /* Scaricare più file significa più download: il browser ne consente una raffica breve dallo
27
36
  * stesso gesto dell'utente, e non c'è modo di fare uno zip senza un endpoint che lo costruisca. */
28
37
  function download(urls) {
@@ -117,7 +126,7 @@ export function MediaGrid({ lib, onPick, onCancel }) {
117
126
  void lib.upload(e.dataTransfer.files);
118
127
  }, children: [dragDepth > 0 && (_jsx("div", { className: "mg-dropzone", children: _jsxs("span", { children: ["Rilascia per caricare in ", _jsx("strong", { children: lib.prefix ? lib.labelOf(lib.prefix) : "Senza cartella" })] }) })), _jsx("header", { className: "mg-bar", children: sel.size === 0 ? (_jsxs(_Fragment, { children: [(manage || lib.multiple) && (_jsxs("label", { className: "mg-all", children: [_jsx("input", { type: "checkbox", checked: false, ref: (el) => { if (el)
119
128
  el.indeterminate = false; }, onChange: () => lib.setAll(true) }), _jsx("span", { children: "Seleziona tutto" })] })), _jsx("span", { className: "ak-spacer" }), _jsxs("label", { className: "ak-hint", children: ["ordina per", " ", _jsx("select", { className: "mg-sort", value: lib.sortKey, onChange: (e) => lib.setSort(e.target.value), children: Object.entries(SORT_LABELS).map(([k, l]) => _jsx("option", { value: k, children: l }, k)) }), _jsx("button", { className: "ak-icon", onClick: lib.flipDir, title: "Inverti l'ordine", children: lib.sortDir === "asc" ? "↑" : "↓" })] })] })) : (_jsxs(_Fragment, { children: [_jsxs("label", { className: "mg-all", children: [_jsx("input", { type: "checkbox", checked: allOn, ref: (el) => { if (el)
120
- el.indeterminate = !allOn; }, onChange: () => lib.setAll(!allOn) }), _jsxs("span", { children: [sel.size, " selezionate"] })] }), !allOn && lib.shown.length > sel.size && (_jsxs("button", { className: "mg-act is-link", onClick: () => lib.setAll(true), children: ["Seleziona tutte le ", lib.shown.length] })), manage && (_jsxs(_Fragment, { children: [lib.shown.some((f) => sel.has(f.key)) && (_jsx("button", { className: "mg-act", onClick: () => download(lib.shown.filter((f) => sel.has(f.key)).map((f) => ({ url: f.url, name: f.name }))), children: "Scarica" })), _jsx(MoveMenu, { lib: lib, keys: [...sel] }), _jsx("button", { className: "mg-act is-danger", onClick: () => void lib.remove([...sel]), children: "Elimina" })] })), _jsx("span", { className: "ak-spacer" }), _jsx("button", { className: "mg-act", onClick: () => lib.setAll(false), children: "Deseleziona" })] })) }), lib.error && _jsx("p", { className: "ak-error", onClick: lib.clearError, children: lib.error }), lib.progress && (_jsxs("p", { className: "mg-progress", children: [lib.progress.verb, " ", lib.progress.done, "/", lib.progress.total, "\u2026 \u2014 non chiudere la pagina"] })), lib.prefix !== "" && (manage || lib.subfolders.length > 0) && (_jsxs("section", { className: "mg-section", children: [_jsxs("div", { className: "mg-section-head", children: [_jsx("h2", { className: "mg-section-title", children: "Cartelle" }), manage && (_jsx("button", { className: "mg-act", onClick: () => void lib.newFolder(lib.prefix), children: "+ Cartella" }))] }), lib.subfolders.length > 0 && (_jsx("div", { className: "mg", children: lib.subfolders.map((f) => (_jsx(FolderCell, { lib: lib, folder: f, manage: manage }, f.prefix))) }))] })), lib.prefix !== "" && (manage || lib.subfolders.length > 0) && lib.shown.length > 0 && (_jsx("h2", { className: "mg-section-title", children: "Foto" })), _jsx("div", { className: "mg", children: lib.shown.map((f, i) => {
129
+ el.indeterminate = !allOn; }, onChange: () => lib.setAll(!allOn) }), _jsxs("span", { children: [sel.size, " selezionate"] })] }), !allOn && lib.shown.length > sel.size && (_jsxs("button", { className: "mg-act is-link", onClick: () => lib.setAll(true), children: ["Seleziona tutte le ", lib.shown.length] })), manage && (_jsxs(_Fragment, { children: [lib.shown.some((f) => sel.has(f.key)) && (_jsx("button", { className: "mg-act", onClick: () => download(lib.shown.filter((f) => sel.has(f.key)).map((f) => ({ url: f.url, name: f.name }))), children: "Scarica" })), _jsx(MoveMenu, { lib: lib, keys: [...sel] }), _jsx("button", { className: "mg-act is-danger", onClick: () => void lib.remove([...sel]), children: "Elimina" })] })), _jsx("span", { className: "ak-spacer" }), _jsx("button", { className: "mg-act", onClick: () => lib.setAll(false), children: "Deseleziona" })] })) }), lib.error && _jsx("p", { className: "ak-error", onClick: lib.clearError, children: lib.error }), manage && lib.mediaOperations.length > 0 && (_jsx("section", { className: "mg-journal", "aria-live": "polite", "aria-label": "Stato operazioni Media", children: lib.mediaOperations.map((operation) => (_jsxs("div", { className: `mg-journal-row is-${operation.state}`, children: [_jsx("span", { className: "mg-journal-badge", children: operation.type === "move" ? "Spostamento" : "Eliminazione" }), _jsx("span", { children: JOURNAL_LABELS[operation.state] ?? "Operazione Media in attesa." }), operation.failedEntries > 0 && _jsxs("span", { className: "ak-hint", children: [operation.failedEntries, " file da riprovare"] }), _jsx("span", { className: "ak-spacer" }), operation.state === "completed_with_warning" && operation.releaseId && (_jsx("button", { className: "mg-act", disabled: lib.busy, onClick: () => void lib.resumeMedia(operation), children: "Riprendi" })), ["preparing", "ready", "blocked_in_use", "failed"].includes(operation.state) && !operation.releaseId && (_jsx("button", { className: "mg-act is-danger", disabled: lib.busy, onClick: () => void lib.cancelMedia(operation), children: "Annulla" }))] }, operation.operationId))) })), lib.progress && (_jsxs("p", { className: "mg-progress", children: [lib.progress.verb, " ", lib.progress.done, "/", lib.progress.total, "\u2026 \u2014 non chiudere la pagina"] })), lib.prefix !== "" && (manage || lib.subfolders.length > 0) && (_jsxs("section", { className: "mg-section", children: [_jsxs("div", { className: "mg-section-head", children: [_jsx("h2", { className: "mg-section-title", children: "Cartelle" }), manage && (_jsx("button", { className: "mg-act", onClick: () => void lib.newFolder(lib.prefix), children: "+ Cartella" }))] }), lib.subfolders.length > 0 && (_jsx("div", { className: "mg", children: lib.subfolders.map((f) => (_jsx(FolderCell, { lib: lib, folder: f, manage: manage }, f.prefix))) }))] })), lib.prefix !== "" && (manage || lib.subfolders.length > 0) && lib.shown.length > 0 && (_jsx("h2", { className: "mg-section-title", children: "Foto" })), _jsx("div", { className: "mg", children: lib.shown.map((f, i) => {
121
130
  const on = sel.has(f.key);
122
131
  return (_jsxs("figure", { className: "mg-item" + (on ? " is-on" : ""), draggable: manage, onDragStart: (e) => {
123
132
  // Si trascina la SELEZIONE se la foto ne fa parte, altrimenti solo quella.
@@ -1,4 +1,5 @@
1
1
  import { type AssetFile, type AssetFolder } from "../../api.js";
2
+ import type { MediaOperationStatus } from "../../media.js";
2
3
  export type SortKey = "uploaded" | "capturedAt" | "name" | "size";
3
4
  export declare const SORT_LABELS: Record<SortKey, string>;
4
5
  export interface MediaLibrary {
@@ -11,6 +12,7 @@ export interface MediaLibrary {
11
12
  selected: Set<string>;
12
13
  busy: boolean;
13
14
  error: string | null;
15
+ mediaOperations: MediaOperationStatus[];
14
16
  /** Avanzamento di un'operazione lunga. `verb` è quello che si sta facendo ("Carico",
15
17
  * "Cestino", "Sposto"): senza, un'attesa di trenta secondi non dice cosa sta succedendo e
16
18
  * chi guarda ricarica la pagina — interrompendo l'operazione a metà. */
@@ -43,6 +45,8 @@ export interface MediaLibrary {
43
45
  renameFolder(prefix: string, label: string): Promise<void>;
44
46
  /** Cestina la cartella e tutto ciò che contiene. */
45
47
  removeFolder(prefix: string): Promise<void>;
48
+ resumeMedia(operation: MediaOperationStatus): Promise<void>;
49
+ cancelMedia(operation: MediaOperationStatus): Promise<void>;
46
50
  /** Il nome da mostrare per un prefisso: l'etichetta se c'è, altrimenti lo slug. */
47
51
  labelOf(prefix: string): string;
48
52
  clearError(): void;
@@ -11,6 +11,7 @@
11
11
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
12
12
  import { api, uploadQueue, ApiError } from "../../api.js";
13
13
  import { storageKey } from "../../config.js";
14
+ import { contentPersistenceMode } from "../../app/site.js";
14
15
  /* Quanti file per richiesta nelle operazioni lunghe (cestina, sposta). Ogni file costa al
15
16
  * server tre operazioni su R2 — legge, riscrive altrove, cancella — su oggetti da qualche MB.
16
17
  * Dieci tiene una richiesta sotto i pochi secondi, che è la soglia oltre la quale chi guarda
@@ -34,6 +35,7 @@ export function useMediaLibrary({ mode, multiple = true, active = true, remember
34
35
  const [busy, setBusy] = useState(false);
35
36
  const [loaded, setLoaded] = useState(false);
36
37
  const [error, setError] = useState(null);
38
+ const [mediaOperations, setMediaOperations] = useState([]);
37
39
  const [progress, setProgress] = useState(null);
38
40
  const [uploadOpen, setUploadOpen] = useState(false);
39
41
  const [viewing, setViewing] = useState(null);
@@ -54,7 +56,13 @@ export function useMediaLibrary({ mode, multiple = true, active = true, remember
54
56
  setBusy(true);
55
57
  setError(null);
56
58
  try {
57
- const [a, f] = await Promise.all([api.assets(p), api.folders()]);
59
+ const [a, f, journal] = await Promise.all([
60
+ api.assets(p),
61
+ api.folders(),
62
+ mode === "manage" && contentPersistenceMode() === "d1"
63
+ ? api.mediaOperations()
64
+ : Promise.resolve({ ok: true, operations: [] }),
65
+ ]);
58
66
  if (fallback && p && !(f.folders ?? []).some((x) => p === x.prefix || p.startsWith(x.prefix))) {
59
67
  setBusy(false);
60
68
  return load("");
@@ -68,6 +76,7 @@ export function useMediaLibrary({ mode, multiple = true, active = true, remember
68
76
  setFiles(a.files ?? []);
69
77
  setSubfolders(a.folders ?? []);
70
78
  setFolders(f.folders ?? []);
79
+ setMediaOperations(journal.operations ?? []);
71
80
  setPrefix(p);
72
81
  setSelected(new Set());
73
82
  setViewing(null);
@@ -88,7 +97,7 @@ export function useMediaLibrary({ mode, multiple = true, active = true, remember
88
97
  finally {
89
98
  setBusy(false);
90
99
  }
91
- }, [remember]);
100
+ }, [remember, mode]);
92
101
  /* Caricamento PIGRO come per la home: chi apre l'admin sulle pagine non paga l'elenco del
93
102
  * bucket. `loaded` e non `files.length`, altrimenti una cartella vuota ritenterebbe all'infinito.
94
103
  *
@@ -469,6 +478,44 @@ export function useMediaLibrary({ mode, multiple = true, active = true, remember
469
478
  }, [prefix, load, moveFolder]);
470
479
  /** Cestina una cartella con tutto quello che contiene. È `remove` con una lista di uno. */
471
480
  const removeFolder = useCallback((target) => remove([target]), [remove]);
481
+ const resumeMedia = useCallback(async (operation) => {
482
+ if (!operation.releaseId)
483
+ return;
484
+ setBusy(true);
485
+ setError(null);
486
+ let failure = null;
487
+ try {
488
+ await api.resumeMediaOperation(operation.operationId, operation.releaseId);
489
+ }
490
+ catch (e) {
491
+ failure = e instanceof ApiError ? e.message : String(e);
492
+ }
493
+ finally {
494
+ setBusy(false);
495
+ await load(prefix);
496
+ if (failure)
497
+ setError(failure);
498
+ }
499
+ }, [load, prefix]);
500
+ const cancelMedia = useCallback(async (operation) => {
501
+ if (!confirm("Annullare questa operazione Media? Le copie già preparate resteranno disponibili nella libreria."))
502
+ return;
503
+ setBusy(true);
504
+ setError(null);
505
+ let failure = null;
506
+ try {
507
+ await api.cancelMediaOperation(operation.operationId);
508
+ }
509
+ catch (e) {
510
+ failure = e instanceof ApiError ? e.message : String(e);
511
+ }
512
+ finally {
513
+ setBusy(false);
514
+ await load(prefix);
515
+ if (failure)
516
+ setError(failure);
517
+ }
518
+ }, [load, prefix]);
472
519
  const step = useCallback((delta) => {
473
520
  setViewing((i) => {
474
521
  if (i === null)
@@ -478,7 +525,7 @@ export function useMediaLibrary({ mode, multiple = true, active = true, remember
478
525
  });
479
526
  }, [shown.length]);
480
527
  return {
481
- mode, multiple, prefix, folders, subfolders, shown, selected, busy, error, progress,
528
+ mode, multiple, prefix, folders, subfolders, shown, selected, busy, error, mediaOperations, progress,
482
529
  sortKey, sortDir, uploadOpen, viewing,
483
530
  go: (p) => void load(p),
484
531
  reload: () => void load(prefix),
@@ -489,7 +536,7 @@ export function useMediaLibrary({ mode, multiple = true, active = true, remember
489
536
  setUploadOpen,
490
537
  view: setViewing,
491
538
  step,
492
- move, remove, newFolder, renameFolder, removeFolder, labelOf,
539
+ move, remove, newFolder, renameFolder, removeFolder, resumeMedia, cancelMedia, labelOf,
493
540
  clearError: () => setError(null),
494
541
  };
495
542
  }
@@ -0,0 +1,317 @@
1
+ /* D1-mode Media bridge. It deliberately owns no site schema: the site
2
+ * validator remains the authority for every draft revision generated by an
3
+ * explicit Media operation. Git is never consulted from this module. */
4
+ import {
5
+ canonicalChecksum, canonicalJson, contentSaveRequestChecksum, D1ContentRepository,
6
+ } from "../../persistence.js";
7
+ import {
8
+ D1MediaOperationRepository, prepareMediaCopy,
9
+ } from "../../media.js";
10
+ import { validateContentDocument } from "../site.js";
11
+ import { isKey, moveToTrash, sanitizeName } from "./assets.js";
12
+
13
+ const MEDIA_PREFIX = "/media/";
14
+
15
+ function mediaUrl(key) { return MEDIA_PREFIX + key.split("/").map(encodeURIComponent).join("/"); }
16
+
17
+ function editor(data) {
18
+ const payload = data?.cloudflareAccess?.JWT?.payload;
19
+ if (!payload?.sub) throw new Error("unauthenticated");
20
+ return { subject: String(payload.sub), label: String(payload.email || payload.name || "Admin").slice(0, 200) };
21
+ }
22
+
23
+ function operationId(value) {
24
+ if (value === undefined || value === null || value === "") return crypto.randomUUID();
25
+ if (typeof value !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)) throw new Error("invalid_media_operation");
26
+ return value;
27
+ }
28
+
29
+ function replaceBoundedUrl(value, from, to) {
30
+ let cursor = 0;
31
+ let next = "";
32
+ while (cursor < value.length) {
33
+ const found = value.indexOf(from, cursor);
34
+ if (found < 0) return next + value.slice(cursor);
35
+ const after = value[found + from.length];
36
+ /* A media key is a path, not a string prefix. Query/fragment suffixes and
37
+ * further URL/path characters belong to a different reference and must
38
+ * not be rewritten accidentally. */
39
+ if (after && /[A-Za-z0-9._~%!$&'()*+,;=:@/?#-]/.test(after)) {
40
+ next += value.slice(cursor, found + from.length);
41
+ cursor = found + from.length;
42
+ continue;
43
+ }
44
+ next += value.slice(cursor, found) + to;
45
+ cursor = found + from.length;
46
+ }
47
+ return next;
48
+ }
49
+
50
+ function replaceText(value, replacements, removals) {
51
+ let next = value;
52
+ for (const [from, to] of Object.entries(replacements)) next = replaceBoundedUrl(next, from, to);
53
+ for (const from of removals) next = replaceBoundedUrl(next, from, "");
54
+ return next;
55
+ }
56
+
57
+ /** JSON Media values are intentionally rewritten only as complete public URLs
58
+ * (or a URL embedded in Markdown). Arrays lose an exact removed URL; scalar
59
+ * slots become empty strings and are then validated by the site's schema. */
60
+ function rewriteValue(value, replacements, removals, inArray = false) {
61
+ if (typeof value === "string") {
62
+ if (removals.has(value) && inArray) return { changed: true, removed: true };
63
+ const next = replaceText(value, replacements, removals);
64
+ return { changed: next !== value, value: next };
65
+ }
66
+ if (Array.isArray(value)) {
67
+ let changed = false;
68
+ const next = [];
69
+ for (const item of value) {
70
+ const result = rewriteValue(item, replacements, removals, true);
71
+ changed ||= result.changed;
72
+ if (!result.removed) next.push(result.value);
73
+ }
74
+ return { changed, value: next };
75
+ }
76
+ if (value && typeof value === "object") {
77
+ let changed = false;
78
+ const next = {};
79
+ for (const [key, item] of Object.entries(value)) {
80
+ const result = rewriteValue(item, replacements, removals, false);
81
+ changed ||= result.changed;
82
+ if (!result.removed) next[key] = result.value;
83
+ }
84
+ return { changed, value: next };
85
+ }
86
+ return { changed: false, value };
87
+ }
88
+
89
+ async function effectiveDocuments(db) {
90
+ return ((await db.prepare(`SELECT document.document_id AS documentId, document.kind, document.locale,
91
+ document.draft_revision_id AS draftRevisionId, published.revision_id AS publishedRevisionId,
92
+ draft.payload_json AS draftPayload, draft.is_tombstone AS draftTombstone,
93
+ published.payload_json AS publishedPayload, published.is_tombstone AS publishedTombstone,
94
+ COALESCE(draft.schema_version, published.schema_version) AS schemaVersion
95
+ FROM content_documents document
96
+ LEFT JOIN content_revisions draft ON draft.document_id = document.document_id AND draft.revision_id = document.draft_revision_id
97
+ LEFT JOIN content_site_state state ON state.singleton = 1
98
+ LEFT JOIN content_release_entries entry ON entry.release_id = state.current_release_id AND entry.document_id = document.document_id
99
+ LEFT JOIN content_revisions published ON published.document_id = entry.document_id AND published.revision_id = entry.revision_id
100
+ WHERE document.abandoned_at IS NULL ORDER BY document.document_id`).all()).results ?? []);
101
+ }
102
+
103
+ async function rewriteDocuments({ db, data, replacements = {}, removals = [] }) {
104
+ const replacementUrls = Object.fromEntries(Object.entries(replacements).map(([from, to]) => [mediaUrl(from), mediaUrl(to)]));
105
+ const removalUrls = new Set(removals.map(mediaUrl));
106
+ const commands = [];
107
+ for (const row of await effectiveDocuments(db)) {
108
+ const useDraft = row.draftRevisionId !== null;
109
+ const raw = useDraft ? row.draftPayload : row.publishedPayload;
110
+ if (!raw || (useDraft ? row.draftTombstone : row.publishedTombstone)) continue;
111
+ let payload;
112
+ try { payload = JSON.parse(raw); } catch { throw new Error("invalid_document"); }
113
+ const rewritten = rewriteValue(payload, replacementUrls, removalUrls);
114
+ if (!rewritten.changed) continue;
115
+ const normalized = validateContentDocument({
116
+ documentId: row.documentId, kind: row.kind, locale: row.locale, schemaVersion: row.schemaVersion,
117
+ tombstone: false, payload: rewritten.value,
118
+ });
119
+ commands.push({
120
+ revisionId: crypto.randomUUID(), documentId: row.documentId,
121
+ baseRevisionId: useDraft ? row.draftRevisionId : row.publishedRevisionId,
122
+ expectedDraftRevisionId: row.draftRevisionId, kind: row.kind, locale: row.locale,
123
+ schemaVersion: row.schemaVersion, tombstone: false, payload: normalized.payload,
124
+ });
125
+ }
126
+ const who = editor(data);
127
+ const repository = new D1ContentRepository(db);
128
+ /* Every command was fully parsed and site-validated before the first write.
129
+ * A stale CAS leaves sources untouched; an already-created R2 destination is
130
+ * deliberately a reusable visible asset, never an orphan cleanup target. */
131
+ const now = Date.now();
132
+ await repository.saveMany(await Promise.all(commands.map(async (command) => ({
133
+ ...command, payloadChecksum: await canonicalChecksum(command.payload), requestChecksum: await contentSaveRequestChecksum(command),
134
+ now, authorSubject: who.subject, authorLabel: who.label,
135
+ }))));
136
+ return { documents: commands.map((command) => ({ documentId: command.documentId, kind: command.kind })) };
137
+ }
138
+
139
+ function keys(input) {
140
+ const unique = [...new Set((Array.isArray(input) ? input : []).filter(isKey).filter((key) => !key.startsWith("_trash/")))];
141
+ if (!unique.length) throw new Error("invalid_media_operation");
142
+ return unique;
143
+ }
144
+
145
+ async function uniqueOperationKey(bucket, prefix, filename, reserved) {
146
+ const name = sanitizeName(filename);
147
+ const dot = name.lastIndexOf(".");
148
+ const base = dot > 0 ? name.slice(0, dot) : name;
149
+ const extension = dot > 0 ? name.slice(dot) : "";
150
+ for (let suffix = 1; suffix <= 999; suffix += 1) {
151
+ const candidate = suffix === 1 ? `${prefix}${name}` : `${prefix}${base}-${suffix}${extension}`;
152
+ if (reserved.has(candidate) || await bucket.head(candidate)) continue;
153
+ reserved.add(candidate);
154
+ return candidate;
155
+ }
156
+ throw new Error("media_destination_collision");
157
+ }
158
+
159
+ async function createOperation({ db, data, type, id, entries, sourcePrefix = null, destinationPrefix = null, clientChecksum = null }) {
160
+ const who = editor(data);
161
+ const requestChecksum = await canonicalChecksum({
162
+ action: "media.operation", operationId: id, type,
163
+ sourceKeys: entries.map((entry) => entry.sourceKey), sourcePrefix, destinationPrefix,
164
+ });
165
+ if (clientChecksum && clientChecksum !== requestChecksum) throw new Error("invalid_media_operation");
166
+ const journal = new D1MediaOperationRepository(db);
167
+ const created = await journal.create({ operationId: id, requestChecksum, type, entries, sourcePrefix, destinationPrefix, authorSubject: who.subject, now: Date.now() });
168
+ return { journal, created };
169
+ }
170
+
171
+ async function inspectSource(bucket, entry) {
172
+ const object = await bucket.get(entry.sourceKey);
173
+ if (!object) throw new Error("media_source_missing");
174
+ const bytes = await object.arrayBuffer();
175
+ const digest = await crypto.subtle.digest("SHA-256", bytes);
176
+ const objectChecksum = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
177
+ return {
178
+ ...entry,
179
+ objectChecksum,
180
+ etag: object.etag ?? null,
181
+ metadataJson: canonicalJson({ httpMetadata: object.httpMetadata ?? {}, customMetadata: object.customMetadata ?? {} }),
182
+ };
183
+ }
184
+
185
+ export async function d1MoveMedia({ env, data, body }) {
186
+ if (!env.CONTENT_DB || !env.MEDIA) throw new Error("content_unavailable");
187
+ let destination = String(body.dest || "").trim();
188
+ if (destination && !destination.endsWith("/")) destination += "/";
189
+ if (!destination || !/^(?:[A-Za-z0-9._-]+\/)+$/.test(destination) || destination.startsWith("_trash/")) throw new Error("invalid_media_operation");
190
+ const sourceKeys = keys(body.keys).filter((key) => key.slice(0, key.lastIndexOf("/") + 1) !== destination);
191
+ const id = operationId(body.operationId);
192
+ if (!sourceKeys.length) return { operationId: id, repeated: false, moved: 0, urlMap: {}, documents: [] };
193
+ const entries = [];
194
+ const reserved = new Set();
195
+ for (const sourceKey of sourceKeys) {
196
+ const base = sourceKey.split("/").pop();
197
+ const destinationKey = await uniqueOperationKey(env.MEDIA, destination, base, reserved);
198
+ entries.push({ entryId: crypto.randomUUID(), sourceKey, destinationKey });
199
+ }
200
+ const { journal, created } = await createOperation({ db: env.CONTENT_DB, data, type: "move", id, entries, sourcePrefix: null, destinationPrefix: destination, clientChecksum: body.requestChecksum });
201
+ if (created === "repeated") {
202
+ const stored = await journal.entries(id);
203
+ return {
204
+ operationId: id, repeated: true, moved: stored.length,
205
+ urlMap: Object.fromEntries(stored.filter((entry) => entry.destinationKey).map((entry) => [mediaUrl(entry.sourceKey), mediaUrl(entry.destinationKey)])),
206
+ documents: [],
207
+ };
208
+ }
209
+ try {
210
+ for (const entry of entries) await prepareMediaCopy({ bucket: env.MEDIA, journal, operationId: id, entryId: entry.entryId, sourceKey: entry.sourceKey, destinationKey: entry.destinationKey, now: Date.now() });
211
+ const rewritten = await rewriteDocuments({ db: env.CONTENT_DB, data, replacements: Object.fromEntries(entries.map((entry) => [entry.sourceKey, entry.destinationKey])) });
212
+ return { operationId: id, repeated: false, moved: entries.length, urlMap: Object.fromEntries(entries.map((entry) => [mediaUrl(entry.sourceKey), mediaUrl(entry.destinationKey)])), documents: rewritten.documents };
213
+ } catch (error) {
214
+ await journal.cancel(id, Date.now()).catch(() => null);
215
+ throw error;
216
+ }
217
+ }
218
+
219
+ export async function d1DeleteMedia({ env, data, body }) {
220
+ if (!env.CONTENT_DB || !env.MEDIA) throw new Error("content_unavailable");
221
+ const sourceKeys = keys(body.keys);
222
+ const id = operationId(body.operationId);
223
+ const entries = sourceKeys.map((sourceKey) => ({ entryId: crypto.randomUUID(), sourceKey }));
224
+ const { journal, created } = await createOperation({ db: env.CONTENT_DB, data, type: "delete", id, entries, clientChecksum: body.requestChecksum });
225
+ if (created === "repeated") return { operationId: id, repeated: true, trashed: (await journal.entries(id)).length, documents: [] };
226
+ try {
227
+ const inspected = await Promise.all(entries.map((entry) => inspectSource(env.MEDIA, entry)));
228
+ const rewritten = await rewriteDocuments({ db: env.CONTENT_DB, data, removals: sourceKeys });
229
+ for (const entry of inspected) await journal.readyDeleteEntry({ ...entry, operationId: id, now: Date.now() });
230
+ return { operationId: id, repeated: false, trashed: entries.length, documents: rewritten.documents };
231
+ } catch (error) {
232
+ await journal.cancel(id, Date.now()).catch(() => null);
233
+ throw error;
234
+ }
235
+ }
236
+
237
+ export async function d1PendingSourceKeys(db) {
238
+ return new Set(await new D1MediaOperationRepository(db).activeSourceKeys());
239
+ }
240
+
241
+ /** Usage is read from the public release, every current draft, and an
242
+ * in-flight immutable candidate. It never reads Git in D1 mode. */
243
+ export async function d1MediaUsage(db, sourceKeys) {
244
+ const keys = [...new Set(sourceKeys.filter(isKey))];
245
+ if (!keys.length) return { usage: {}, pages: [], count: 0, selectedCount: 0, blockers: [] };
246
+ const rows = (await db.prepare(`SELECT document.kind, document.document_id AS documentId, revision.payload_json AS payload
247
+ FROM content_documents document
248
+ JOIN content_revisions revision ON revision.document_id = document.document_id AND revision.revision_id = document.draft_revision_id
249
+ UNION
250
+ SELECT document.kind, document.document_id AS documentId, revision.payload_json AS payload
251
+ FROM content_site_state state
252
+ JOIN content_release_entries entry ON entry.release_id = state.current_release_id
253
+ JOIN content_documents document ON document.document_id = entry.document_id
254
+ JOIN content_revisions revision ON revision.document_id = entry.document_id AND revision.revision_id = entry.revision_id
255
+ UNION
256
+ SELECT document.kind, document.document_id AS documentId, revision.payload_json AS payload
257
+ FROM content_releases release
258
+ JOIN content_release_entries entry ON entry.release_id = release.release_id
259
+ JOIN content_documents document ON document.document_id = entry.document_id
260
+ JOIN content_revisions revision ON revision.document_id = entry.document_id AND revision.revision_id = entry.revision_id
261
+ WHERE release.status = 'publishing'`).all()).results ?? [];
262
+ const usage = {};
263
+ const pages = new Set();
264
+ for (const key of keys) {
265
+ const url = mediaUrl(key);
266
+ const locations = [...new Set(rows.filter((row) => typeof row.payload === "string" && row.payload.includes(url))
267
+ .map((row) => `${row.kind} · ${row.documentId}`))];
268
+ if (locations.length) { usage[key] = locations; locations.forEach((location) => pages.add(location)); }
269
+ }
270
+ return { usage, pages: [...pages], count: Object.keys(usage).length, selectedCount: keys.length, blockers: [] };
271
+ }
272
+
273
+ export async function finalizeD1MediaRelease({ env, releaseId, now = Date.now() }) {
274
+ if (!env.CONTENT_DB || !env.MEDIA) return { completed: 0, failed: 0, skipped: "binding non configurati" };
275
+ const journal = new D1MediaOperationRepository(env.CONTENT_DB);
276
+ const work = await journal.finalizationWork(releaseId);
277
+ const byOperation = new Map();
278
+ for (const entry of work) {
279
+ const group = byOperation.get(entry.operationId) || [];
280
+ group.push(entry); byOperation.set(entry.operationId, group);
281
+ }
282
+ let completed = 0, failed = 0;
283
+ for (const [id, entries] of byOperation) {
284
+ const first = entries[0];
285
+ if (first.operationState === "completed_with_warning") await journal.resumeFinalization(id, releaseId, now);
286
+ else await journal.beginFinalization(id, releaseId, now);
287
+ for (const entry of entries) {
288
+ try {
289
+ if (entry.type === "delete") await moveToTrash(env.MEDIA, entry.sourceKey);
290
+ else await env.MEDIA.delete(entry.sourceKey);
291
+ await journal.recordFinalizedEntry({ operationId: id, entryId: entry.entryId, now, outcome: "completed" });
292
+ completed += 1;
293
+ } catch {
294
+ await journal.recordFinalizedEntry({ operationId: id, entryId: entry.entryId, now, outcome: "failed", safeError: "La pulizia del Media verrà ritentata." });
295
+ failed += 1;
296
+ }
297
+ }
298
+ }
299
+ return { completed, failed };
300
+ }
301
+
302
+ export async function resumeD1MediaOperation({ env, data, operationId: id, releaseId }) {
303
+ if (!env.CONTENT_DB || !env.MEDIA) throw new Error("content_unavailable");
304
+ editor(data);
305
+ if (typeof id !== "string" || typeof releaseId !== "string") throw new Error("invalid_media_operation");
306
+ const journal = new D1MediaOperationRepository(env.CONTENT_DB);
307
+ await journal.resumeFinalization(id, releaseId, Date.now());
308
+ return finalizeD1MediaRelease({ env, releaseId, now: Date.now() });
309
+ }
310
+
311
+ export async function cancelD1MediaOperation({ env, data, operationId: id }) {
312
+ if (!env.CONTENT_DB) throw new Error("content_unavailable");
313
+ editor(data);
314
+ if (typeof id !== "string") throw new Error("invalid_media_operation");
315
+ await new D1MediaOperationRepository(env.CONTENT_DB).cancel(id, Date.now());
316
+ return { cancelled: true };
317
+ }
@@ -23,6 +23,8 @@ import { mediaUrl } from "../../_shared/refs.js";
23
23
  import { rewriteMediaReferences } from "../../_shared/media-content.js";
24
24
  import { getBase, DRAFT } from "../../_shared/github.js";
25
25
  import { finalizePendingMedia, recordPendingMedia } from "../../_shared/pending-media.js";
26
+ import { contentPersistenceMode } from "../../site.js";
27
+ import { d1DeleteMedia, d1PendingSourceKeys } from "../../_shared/d1-media.js";
26
28
 
27
29
  const enc = new TextEncoder();
28
30
 
@@ -90,7 +92,7 @@ export async function verifyRefsPreflight(env, token, keys, currentCommit) {
90
92
  return keys.every((key) => allowed.has(key));
91
93
  }
92
94
 
93
- export async function onRequestPost({ request, env }) {
95
+ export async function onRequestPost({ request, env, data }) {
94
96
  if (!env.MEDIA) return json({ ok: false, error: "Binding MEDIA non configurato" }, 500);
95
97
  let body;
96
98
  try { body = await request.json(); } catch { return json({ ok: false, error: "JSON non valido" }, 400); }
@@ -115,6 +117,22 @@ export async function onRequestPost({ request, env }) {
115
117
  const keyList = [...keys].filter((k) => !k.startsWith("_trash/"));
116
118
  if (!keyList.length && !keepKeys.length) return json({ ok: true, trashed: 0, pagesUpdated: [] });
117
119
 
120
+ if (contentPersistenceMode() === "d1") {
121
+ try {
122
+ /* A legacy client may repeat its old refsOnly/skipRefs sequence. Once a
123
+ * D1 operation owns a source, later chunks can only remove .keep files;
124
+ * they cannot manufacture a second destructive operation. */
125
+ const pending = await d1PendingSourceKeys(env.CONTENT_DB);
126
+ const operable = keyList.filter((key) => !pending.has(key));
127
+ const result = operable.length ? await d1DeleteMedia({ env, data, body: { ...body, keys: operable } }) : null;
128
+ for (const keepKey of keepKeys) await env.MEDIA.delete(keepKey);
129
+ return json({ ok: true, trashed: result?.trashed ?? keyList.length, operationId: result?.operationId, pagesUpdated: result?.documents.map((document) => document.kind) ?? [], deferred: Boolean(result), d1: true, warning: result ? "I file resteranno disponibili fino alla pubblicazione." : undefined });
130
+ } catch (error) {
131
+ const code = error?.message === "unauthenticated" ? "unauthenticated" : error?.message === "media_source_busy" ? "media_source_busy" : "media_operation_unavailable";
132
+ return json({ ok: false, code, error: code === "media_source_busy" ? "Questo Media è già in un'operazione in corso." : "Non riesco a registrare l'operazione Media D1." }, code === "unauthenticated" ? 401 : 409);
133
+ }
134
+ }
135
+
118
136
  const refsOnly = body.refsOnly === true;
119
137
  const skipRefs = body.skipRefs === true;
120
138
  if (keyList.length && (!env.GITHUB_TOKEN || !env.GITHUB_REPO)) {
@@ -4,11 +4,13 @@
4
4
  */
5
5
  import { json, TRASH, listKeysUnder } from "../../_shared/assets.js";
6
6
  import { pendingSourceKeys } from "../../_shared/pending-media.js";
7
+ import { contentPersistenceMode } from "../../site.js";
8
+ import { d1PendingSourceKeys } from "../../_shared/d1-media.js";
7
9
 
8
10
  export async function onRequestGet({ env }) {
9
11
  if (!env.MEDIA) return json({ ok: false, error: "Binding MEDIA non configurato" }, 500);
10
12
  const keys = await listKeysUnder(env.MEDIA, "");
11
- const pending = await pendingSourceKeys(env.MEDIA);
13
+ const pending = contentPersistenceMode() === "d1" ? await d1PendingSourceKeys(env.CONTENT_DB) : await pendingSourceKeys(env.MEDIA);
12
14
  const counts = {};
13
15
  for (const k of keys) {
14
16
  if (k.startsWith(TRASH) || k.startsWith("_pending-media/") || pending.has(k)) continue;
@@ -0,0 +1,30 @@
1
+ /* D1 Media operation status and bounded recovery. These routes exist only
2
+ * after D1 cutover; Git-mode Media keeps its established pending-marker flow. */
3
+ import { json } from "../../_shared/assets.js";
4
+ import { D1MediaOperationRepository } from "../../../media.js";
5
+ import { contentPersistenceMode } from "../../site.js";
6
+ import { cancelD1MediaOperation, resumeD1MediaOperation } from "../../_shared/d1-media.js";
7
+
8
+ function unavailable() { return json({ ok: false, code: "feature_inactive", error: "Journal Media D1 non attivo." }, 404); }
9
+ function failure(error) {
10
+ const code = error?.message === "unauthenticated" ? "unauthenticated" : error?.message === "media_operation_unavailable" ? "media_operation_unavailable" : "media_operation_invalid";
11
+ return json({ ok: false, code, error: code === "unauthenticated" ? "Sessione non valida: ricarica la pagina per rientrare." : "L'operazione Media non può essere completata." }, code === "unauthenticated" ? 401 : 409);
12
+ }
13
+
14
+ export async function onRequestGet({ env }) {
15
+ if (contentPersistenceMode() !== "d1") return unavailable();
16
+ if (!env.CONTENT_DB) return json({ ok: false, code: "content_unavailable", error: "Persistenza contenuti non disponibile." }, 503);
17
+ try { return json({ ok: true, operations: await new D1MediaOperationRepository(env.CONTENT_DB).statuses() }); }
18
+ catch (error) { return failure(error); }
19
+ }
20
+
21
+ export async function onRequestPost({ request, env, data }) {
22
+ if (contentPersistenceMode() !== "d1") return unavailable();
23
+ let body;
24
+ try { body = await request.json(); } catch { return json({ ok: false, code: "media_operation_invalid", error: "JSON non valido." }, 400); }
25
+ try {
26
+ if (body?.action === "cancel") return json({ ok: true, ...(await cancelD1MediaOperation({ env, data, operationId: body.operationId })) });
27
+ if (body?.action === "resume") return json({ ok: true, ...(await resumeD1MediaOperation({ env, data, operationId: body.operationId, releaseId: body.releaseId })) });
28
+ throw new Error("invalid_media_operation");
29
+ } catch (error) { return failure(error); }
30
+ }
@@ -14,6 +14,8 @@ import { mediaUrl } from "../../_shared/refs.js";
14
14
  import { rewriteMediaReferences } from "../../_shared/media-content.js";
15
15
  import { getBase } from "../../_shared/github.js";
16
16
  import { finalizePendingMedia, recordPendingMedia } from "../../_shared/pending-media.js";
17
+ import { contentPersistenceMode } from "../../site.js";
18
+ import { d1MoveMedia } from "../../_shared/d1-media.js";
17
19
 
18
20
  function mediaKey(url) {
19
21
  if (typeof url !== "string" || !url.startsWith("/media/")) return null;
@@ -128,8 +130,27 @@ async function confirmRewrittenCommit(env, commitSha) {
128
130
  }
129
131
  }
130
132
 
131
- export async function onRequestPost({ request, env }) {
133
+ export async function onRequestPost({ request, env, data }) {
132
134
  if (!env.MEDIA) return json({ ok: false, error: "Binding MEDIA non configurato" }, 500);
135
+ let body;
136
+ try { body = await request.json(); } catch { return json({ ok: false, error: "JSON non valido" }, 400); }
137
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
138
+ return json({ ok: false, error: "Corpo richiesta non valido" }, 400);
139
+ }
140
+
141
+ if (contentPersistenceMode() === "d1") {
142
+ /* The former two-step Git protocol must never become a second editorial
143
+ * writer after cutover. A stale browser's applyRefs call is harmless: the
144
+ * first D1 command already created the revisions and journal intent. */
145
+ if (Object.hasOwn(body, "applyRefs")) return json({ ok: true, moved: 0, pagesUpdated: [], failed: [], d1: true });
146
+ try {
147
+ const result = await d1MoveMedia({ env, data, body });
148
+ return json({ ok: true, ...result, pagesUpdated: result.documents.map((document) => document.kind), failed: [], warning: "Le sorgenti restano disponibili fino alla pubblicazione." });
149
+ } catch (error) {
150
+ const code = error?.message === "unauthenticated" ? "unauthenticated" : error?.message === "media_source_busy" ? "media_source_busy" : "media_operation_unavailable";
151
+ return json({ ok: false, code, error: code === "media_source_busy" ? "Questo Media è già in un'operazione in corso." : "Non riesco a registrare l'operazione Media D1." }, code === "unauthenticated" ? 401 : 409);
152
+ }
153
+ }
133
154
  if (!env.GITHUB_TOKEN || !env.GITHUB_REPO) {
134
155
  return json({
135
156
  ok: false,
@@ -137,11 +158,6 @@ export async function onRequestPost({ request, env }) {
137
158
  error: "Non posso spostare media senza aggiornare i riferimenti nel sito.",
138
159
  }, 503);
139
160
  }
140
- let body;
141
- try { body = await request.json(); } catch { return json({ ok: false, error: "JSON non valido" }, 400); }
142
- if (!body || typeof body !== "object" || Array.isArray(body)) {
143
- return json({ ok: false, error: "Corpo richiesta non valido" }, 400);
144
- }
145
161
 
146
162
  /* Seconda fase: le copie esistono già. Prima le verifichiamo TUTTE, poi aggiorniamo il repo,
147
163
  * infine cancelliamo gli originali. */
@@ -7,10 +7,11 @@ import { mediaUrl } from "../../_shared/refs.js";
7
7
  import {
8
8
  readMediaSources, summarizeMediaUsage, planMediaRewrite,
9
9
  } from "../../_shared/media-content.js";
10
+ import { contentPersistenceMode } from "../../site.js";
11
+ import { d1MediaUsage } from "../../_shared/d1-media.js";
10
12
 
11
13
  export async function onRequestPost({ request, env }) {
12
14
  if (!env.MEDIA) return json({ ok: false, error: "Binding MEDIA non configurato" }, 500);
13
- if (!env.GITHUB_TOKEN || !env.GITHUB_REPO) return json({ ok: false, error: "backend GitHub non configurato" }, 500);
14
15
  let body;
15
16
  try { body = await request.json(); } catch { return json({ ok: false, error: "JSON non valido" }, 400); }
16
17
  if (!body || typeof body !== "object" || Array.isArray(body)) {
@@ -27,6 +28,9 @@ export async function onRequestPost({ request, env }) {
27
28
  return json({ ok: true, usage: {}, pages: [], count: 0, selectedCount: 0, blockers: [] });
28
29
  }
29
30
 
31
+ if (contentPersistenceMode() === "d1") return json({ ok: true, ...(await d1MediaUsage(env.CONTENT_DB, keyList)) });
32
+ if (!env.GITHUB_TOKEN || !env.GITHUB_REPO) return json({ ok: false, error: "backend GitHub non configurato" }, 500);
33
+
30
34
  const { sources } = await readMediaSources(env);
31
35
  const summary = summarizeMediaUsage(sources, keyList, mediaUrl);
32
36
  const plan = planMediaRewrite(sources, { removeUrls: keyList.map(mediaUrl) });
@@ -4,6 +4,8 @@
4
4
  import { isPrefix, json, listKeysUnder } from "../_shared/assets.js";
5
5
  import { sizeFromBucket } from "../_shared/imagesize.js";
6
6
  import { pendingSourceKeys } from "../_shared/pending-media.js";
7
+ import { contentPersistenceMode } from "../site.js";
8
+ import { d1PendingSourceKeys } from "../_shared/d1-media.js";
7
9
 
8
10
  const IMG = /\.(webp|jpe?g|png|avif|gif)$/i;
9
11
 
@@ -12,7 +14,7 @@ export async function onRequestGet({ request, env }) {
12
14
  const url = new URL(request.url);
13
15
  let prefix = (url.searchParams.get("prefix") || "").trim();
14
16
  if (!isPrefix(prefix)) return json({ ok: false, error: `prefisso non valido: ${prefix}` }, 400);
15
- const pending = await pendingSourceKeys(env.MEDIA);
17
+ const pending = contentPersistenceMode() === "d1" ? await d1PendingSourceKeys(env.CONTENT_DB) : await pendingSourceKeys(env.MEDIA);
16
18
 
17
19
  /* `recursive=1` guarda TUTTO quello che c'è sotto, a qualunque profondità: serve a chi deve
18
20
  * spostare o cancellare una cartella intera, che altrimenti si porterebbe dietro solo il primo
@@ -4,11 +4,13 @@ import {
4
4
  D1DeploymentAttemptRepository, claimTokenChecksum, constantTimeSecretEqual,
5
5
  isTerminalDeploymentPhase,
6
6
  } from "../../deployment.js";
7
- import { releaseExportFromStored } from "../../release.js";
7
+ import { deploymentFingerprint, releaseExportFromStored } from "../../release.js";
8
+ import { finalizeD1MediaRelease } from "../_shared/d1-media.js";
8
9
 
9
10
  const MAX_BODY_BYTES = 8_000;
10
11
  const claimLifetimeMs = 20 * 60 * 1_000;
11
12
  const id = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
13
+ const sha = /^[0-9a-f]{40}$/i;
12
14
  const phases = new Set(["validating", "materializing", "building", "deploying", "verifying_deployment_url", "verifying_canonical"]);
13
15
  const failure = {
14
16
  materialization_failed: { stage: "materializing", message: "La generazione del sito non è riuscita." },
@@ -94,6 +96,27 @@ export async function handleInternalContentCi({ request, env }) {
94
96
  const now = Date.now();
95
97
  const { attempts, content } = repositories(env);
96
98
 
99
+ if (path === "/internal/deployment/register-code" && request.method === "POST") {
100
+ const input = await body(request);
101
+ if (!sha.test(input.codeSha || "")) throw new Error("invalid_request");
102
+ const state = await env.CONTENT_DB.prepare("SELECT current_release_id AS currentReleaseId FROM content_site_state WHERE singleton = 1")
103
+ .first();
104
+ if (!state?.currentReleaseId) throw new Error("content_unavailable");
105
+ const snapshot = await content.getReleaseExport(state.currentReleaseId);
106
+ if (!snapshot || snapshot.status !== "published") throw new Error("content_unavailable");
107
+ const fingerprint = await deploymentFingerprint({
108
+ codeSha: input.codeSha, manifestChecksum: snapshot.manifestChecksum,
109
+ schemaPackageVersion: snapshot.schemaPackageVersion, materializerVersion: snapshot.materializerVersion,
110
+ });
111
+ const attemptId = crypto.randomUUID();
112
+ await attempts.register({
113
+ attemptId, intentKind: "code", requestedReleaseId: snapshot.releaseId, requestedCodeSha: input.codeSha,
114
+ releaseId: snapshot.releaseId, codeSha: input.codeSha, expectedFingerprint: fingerprint.fingerprint, now,
115
+ });
116
+ return response({ ok: true, attemptId, releaseId: snapshot.releaseId, codeSha: input.codeSha,
117
+ expectedFingerprint: fingerprint.fingerprint });
118
+ }
119
+
97
120
  if (path === "/internal/deployment/claim" && request.method === "POST") {
98
121
  const identity = requiredIdentity(await body(request));
99
122
  const attempt = await attempts.get(identity.attemptId);
@@ -124,8 +147,14 @@ export async function handleInternalContentCi({ request, env }) {
124
147
  return response({ ok: true, attempt: { ...identity, phase: input.phase } });
125
148
  }
126
149
  if (path === "/internal/deployment/finalize" && request.method === "POST") {
127
- await attempts.finalize({ ...identity, tokenChecksum: checksum, now });
128
- return response({ ok: true, attempt: { ...identity, phase: "succeeded" } });
150
+ await attempts.finalize({ ...identity, intentKind: attempt.intentKind, tokenChecksum: checksum, now });
151
+ /* The release is already authoritative once this returns. Media cleanup
152
+ * is deliberately best-effort and journaled: a warning must not pretend
153
+ * to roll back a verified public deployment. */
154
+ const media = attempt.intentKind === "content"
155
+ ? await finalizeD1MediaRelease({ env, releaseId: identity.releaseId, now }).catch(() => ({ completed: 0, failed: 0, warning: true }))
156
+ : undefined;
157
+ return response({ ok: true, attempt: { ...identity, phase: "succeeded" }, ...(media ? { media } : {}) });
129
158
  }
130
159
  if (path === "/internal/deployment/fail" && request.method === "POST") {
131
160
  const detail = failure[input.code];
@@ -30,6 +30,7 @@ import * as assetsDelete from "./api/assets/delete.js";
30
30
  import * as assetsFolder from "./api/assets/folder.js";
31
31
  import * as assetsFolders from "./api/assets/folders.js";
32
32
  import * as assetsMove from "./api/assets/move.js";
33
+ import * as assetsMedia from "./api/assets/media.js";
33
34
  import * as assetsPreview from "./api/assets/preview.js";
34
35
  import * as assetsTrash from "./api/assets/trash.js";
35
36
  import * as assetsTrashDelete from "./api/assets/trash/delete.js";
@@ -66,6 +67,7 @@ const ROUTES = {
66
67
  "assets/folder": { mod: null, h: assetsFolder },
67
68
  "assets/folders": { mod: null, h: assetsFolders },
68
69
  "assets/move": { mod: null, h: assetsMove },
70
+ "assets/media": { mod: null, h: assetsMedia },
69
71
  "assets/preview": { mod: null, h: assetsPreview },
70
72
  "assets/trash": { mod: null, h: assetsTrash },
71
73
  "assets/trash/delete": { mod: null, h: assetsTrashDelete },
package/dist/media.d.ts CHANGED
@@ -52,6 +52,30 @@ export type CreateMediaOperation = Readonly<{
52
52
  now: number;
53
53
  entries: readonly MediaOperationEntryInput[];
54
54
  }>;
55
+ export type BoundMediaEntry = Readonly<{
56
+ operationId: string;
57
+ type: MediaOperationType;
58
+ operationState: MediaOperationState;
59
+ entryId: string;
60
+ sourceKey: string;
61
+ destinationKey: string | null;
62
+ finalizationState: "pending" | "completed" | "failed" | "cancelled";
63
+ }>;
64
+ export type MediaOperationStatus = Readonly<{
65
+ operationId: string;
66
+ type: MediaOperationType;
67
+ state: MediaOperationState;
68
+ releaseId: string | null;
69
+ updatedAt: number;
70
+ failedEntries: number;
71
+ }>;
72
+ export type StoredMediaOperationEntry = Readonly<{
73
+ entryId: string;
74
+ sourceKey: string;
75
+ destinationKey: string | null;
76
+ preparationState: string;
77
+ finalizationState: string;
78
+ }>;
55
79
  /** SQL journal adapter. R2 copy/delete happens around this adapter; it never
56
80
  * infers an object as removable simply because a caller stopped mentioning it. */
57
81
  export declare class D1MediaOperationRepository {
@@ -83,6 +107,34 @@ export declare class D1MediaOperationRepository {
83
107
  }): Promise<"pending" | "settled">;
84
108
  blockReintroducedSources(sourceKeys: readonly string[], now: number): Promise<number>;
85
109
  cancel(operationId: string, now: number): Promise<void>;
110
+ /** Sources are hidden from the normal library only while an unresolved
111
+ * operation owns them. The query deliberately never derives removability
112
+ * from this list. */
113
+ activeSourceKeys(): Promise<readonly string[]>;
114
+ /** Candidates bind every ready operation whose source has been rewritten;
115
+ * the release repository repeats the frozen-manifest no-source guard in the
116
+ * same transaction as the binding. */
117
+ readyOperationIds(): Promise<readonly string[]>;
118
+ /** A Discard block is reversible only when the current effective documents
119
+ * no longer contain any owned source. Re-evaluation changes journal state;
120
+ * it never synthesizes or rewrites content. */
121
+ refreshBlockedOperations(now: number): Promise<number>;
122
+ statuses(): Promise<readonly MediaOperationStatus[]>;
123
+ entries(operationId: string): Promise<readonly StoredMediaOperationEntry[]>;
124
+ /** Work is named by its immutable release binding: there is intentionally no
125
+ * "latest operation" cleanup path. */
126
+ finalizationWork(releaseId: string): Promise<readonly BoundMediaEntry[]>;
127
+ /** A warning is a live release with incomplete housekeeping. Retrying resets
128
+ * only failed effects, never an already-completed entry. */
129
+ resumeFinalization(operationId: string, releaseId: string, now: number): Promise<void>;
130
+ readyDeleteEntry(input: {
131
+ operationId: string;
132
+ entryId: string;
133
+ now: number;
134
+ objectChecksum: string;
135
+ etag: string | null;
136
+ metadataJson: string | null;
137
+ }): Promise<"pending" | "ready">;
86
138
  }
87
139
  type R2Metadata = Record<string, string>;
88
140
  export type MediaR2Object = {
package/dist/media.js CHANGED
@@ -164,7 +164,7 @@ export class D1MediaOperationRepository {
164
164
  }
165
165
  async beginFinalization(operationId, releaseId, now) {
166
166
  const result = await this.db.prepare(`UPDATE media_operations SET state = 'finalizing', updated_at = ?
167
- WHERE operation_id = ? AND state = 'bound' AND EXISTS (SELECT 1 FROM content_release_media_operations
167
+ WHERE operation_id = ? AND state IN ('bound', 'finalizing') AND EXISTS (SELECT 1 FROM content_release_media_operations
168
168
  WHERE operation_id = ? AND release_id = ?) AND EXISTS (SELECT 1 FROM content_releases
169
169
  WHERE release_id = ? AND status = 'published')`)
170
170
  .bind(now, operationId, operationId, releaseId, releaseId).run();
@@ -213,6 +213,108 @@ export class D1MediaOperationRepository {
213
213
  if (results[0]?.meta?.changes !== 1)
214
214
  throw new Error("media_operation_unavailable");
215
215
  }
216
+ /** Sources are hidden from the normal library only while an unresolved
217
+ * operation owns them. The query deliberately never derives removability
218
+ * from this list. */
219
+ async activeSourceKeys() {
220
+ const rows = (await this.db.prepare(`SELECT DISTINCT entry.source_key AS sourceKey
221
+ FROM media_operation_entries entry
222
+ JOIN media_operations operation ON operation.operation_id = entry.operation_id
223
+ WHERE operation.state IN ('ready', 'bound', 'finalizing', 'completed_with_warning')
224
+ AND entry.finalization_state NOT IN ('completed', 'cancelled')
225
+ ORDER BY entry.source_key`).all()).results ?? [];
226
+ return rows.map((row) => row.sourceKey);
227
+ }
228
+ /** Candidates bind every ready operation whose source has been rewritten;
229
+ * the release repository repeats the frozen-manifest no-source guard in the
230
+ * same transaction as the binding. */
231
+ async readyOperationIds() {
232
+ await this.refreshBlockedOperations(Date.now());
233
+ const rows = (await this.db.prepare("SELECT operation_id AS operationId FROM media_operations WHERE state = 'ready' ORDER BY operation_id")
234
+ .all()).results ?? [];
235
+ return rows.map((row) => row.operationId);
236
+ }
237
+ /** A Discard block is reversible only when the current effective documents
238
+ * no longer contain any owned source. Re-evaluation changes journal state;
239
+ * it never synthesizes or rewrites content. */
240
+ async refreshBlockedOperations(now) {
241
+ const result = await this.db.prepare(`UPDATE media_operations SET state = 'ready', updated_at = ?
242
+ WHERE state = 'blocked_in_use' AND NOT EXISTS (SELECT 1 FROM media_operation_entries source
243
+ JOIN content_documents document
244
+ LEFT JOIN content_revisions draft ON draft.document_id = document.document_id AND draft.revision_id = document.draft_revision_id
245
+ LEFT JOIN content_site_state site_state ON site_state.singleton = 1
246
+ LEFT JOIN content_release_entries published_entry ON published_entry.release_id = site_state.current_release_id
247
+ AND published_entry.document_id = document.document_id
248
+ LEFT JOIN content_revisions published ON published.document_id = published_entry.document_id
249
+ AND published.revision_id = published_entry.revision_id
250
+ WHERE source.operation_id = media_operations.operation_id
251
+ AND instr(COALESCE(draft.payload_json, published.payload_json, ''), '/media/' || source.source_key) > 0)`)
252
+ .bind(now).run();
253
+ return Number(result.meta?.changes ?? 0);
254
+ }
255
+ async statuses() {
256
+ return (await this.db.prepare(`SELECT operation.operation_id AS operationId, operation.type, operation.state,
257
+ binding.release_id AS releaseId, operation.updated_at AS updatedAt,
258
+ SUM(CASE WHEN entry.finalization_state = 'failed' THEN 1 ELSE 0 END) AS failedEntries
259
+ FROM media_operations operation
260
+ LEFT JOIN content_release_media_operations binding ON binding.operation_id = operation.operation_id
261
+ JOIN media_operation_entries entry ON entry.operation_id = operation.operation_id
262
+ WHERE operation.state NOT IN ('completed', 'cancelled')
263
+ GROUP BY operation.operation_id, operation.type, operation.state, binding.release_id, operation.updated_at
264
+ ORDER BY operation.updated_at DESC, operation.operation_id`).all()).results ?? [];
265
+ }
266
+ async entries(operationId) {
267
+ return (await this.db.prepare(`SELECT entry_id AS entryId, source_key AS sourceKey,
268
+ destination_key AS destinationKey, preparation_state AS preparationState,
269
+ finalization_state AS finalizationState
270
+ FROM media_operation_entries WHERE operation_id = ? ORDER BY entry_id`)
271
+ .bind(operationId).all()).results ?? [];
272
+ }
273
+ /** Work is named by its immutable release binding: there is intentionally no
274
+ * "latest operation" cleanup path. */
275
+ async finalizationWork(releaseId) {
276
+ const rows = (await this.db.prepare(`SELECT operation.operation_id AS operationId, operation.type, operation.state AS operationState,
277
+ entry.entry_id AS entryId, entry.source_key AS sourceKey, entry.destination_key AS destinationKey,
278
+ entry.finalization_state AS finalizationState
279
+ FROM content_release_media_operations binding
280
+ JOIN media_operations operation ON operation.operation_id = binding.operation_id
281
+ JOIN media_operation_entries entry ON entry.operation_id = operation.operation_id
282
+ WHERE binding.release_id = ? AND operation.state IN ('bound', 'finalizing', 'completed_with_warning')
283
+ AND entry.finalization_state IN ('pending', 'failed')
284
+ ORDER BY operation.operation_id, entry.entry_id`).bind(releaseId).all()).results ?? [];
285
+ return rows;
286
+ }
287
+ /** A warning is a live release with incomplete housekeeping. Retrying resets
288
+ * only failed effects, never an already-completed entry. */
289
+ async resumeFinalization(operationId, releaseId, now) {
290
+ const resume = this.db.prepare(`UPDATE media_operations SET state = 'finalizing', updated_at = ?, retry_count = retry_count + 1
291
+ WHERE operation_id = ? AND state = 'completed_with_warning'
292
+ AND EXISTS (SELECT 1 FROM content_release_media_operations WHERE operation_id = ? AND release_id = ?)
293
+ AND EXISTS (SELECT 1 FROM content_releases WHERE release_id = ? AND status = 'published')`)
294
+ .bind(now, operationId, operationId, releaseId, releaseId);
295
+ const reset = this.db.prepare(`UPDATE media_operation_entries SET finalization_state = 'pending', safe_error = NULL
296
+ WHERE operation_id = ? AND finalization_state = 'failed'
297
+ AND EXISTS (SELECT 1 FROM media_operations WHERE operation_id = ? AND state = 'finalizing')`)
298
+ .bind(operationId, operationId);
299
+ const results = await this.db.batch([resume, reset]);
300
+ if (results[0]?.meta?.changes !== 1)
301
+ throw new Error("media_operation_unavailable");
302
+ }
303
+ async readyDeleteEntry(input) {
304
+ const entry = this.db.prepare(`UPDATE media_operation_entries
305
+ SET object_checksum = ?, etag = ?, metadata_json = ?, preparation_state = 'ready'
306
+ WHERE operation_id = ? AND entry_id = ? AND preparation_state = 'pending'
307
+ AND EXISTS (SELECT 1 FROM media_operations WHERE operation_id = ? AND type = 'delete' AND state = 'preparing')`)
308
+ .bind(input.objectChecksum, input.etag, input.metadataJson, input.operationId, input.entryId, input.operationId);
309
+ const ready = this.db.prepare(`UPDATE media_operations SET state = 'ready', updated_at = ?
310
+ WHERE operation_id = ? AND type = 'delete' AND state = 'preparing' AND NOT EXISTS (SELECT 1 FROM media_operation_entries
311
+ WHERE operation_id = ? AND preparation_state != 'ready')`)
312
+ .bind(input.now, input.operationId, input.operationId);
313
+ const results = await this.db.batch([entry, ready]);
314
+ if (results[0]?.meta?.changes !== 1)
315
+ throw new Error("media_operation_unavailable");
316
+ return results[1]?.meta?.changes === 1 ? "ready" : "pending";
317
+ }
216
318
  }
217
319
  const MEDIA_CHECKSUM_METADATA = "adminkit-sha256";
218
320
  async function bytesChecksum(bytes) {
@@ -115,6 +115,10 @@ export type ReleaseInput = {
115
115
  documentId: string;
116
116
  revisionId: string;
117
117
  }[];
118
+ /** Operations are bound in the same D1 transaction as the immutable
119
+ * manifest. The SQL guard rejects any candidate still containing a source
120
+ * URL, so a caller cannot make cleanup race a public reference. */
121
+ mediaOperationIds?: readonly string[];
118
122
  };
119
123
  export type ReleaseDeploymentInput = ReleaseInput & {
120
124
  attempt: {
@@ -157,6 +161,11 @@ export declare class D1ContentRepository {
157
161
  * no "latest" variant: a deployment attempt must name what it exports. */
158
162
  getReleaseExport(releaseId: string): Promise<StoredReleaseExport | null>;
159
163
  save(input: SaveInput): Promise<"created" | "repeated">;
164
+ /** Multi-document editorial gestures (such as a Media rewrite) must not
165
+ * leave half their draft revisions behind when a later CAS discovers an
166
+ * editor's concurrent save. D1 batches are transactional, so we validate
167
+ * every identity and build every guarded statement before issuing one batch. */
168
+ saveMany(inputs: readonly SaveInput[]): Promise<void>;
160
169
  discard(documentId: string, expectedDraftRevisionId: string | null, baselineRevisionId: string | null, now: number): Promise<void>;
161
170
  publish(input: ReleaseInput): Promise<"created" | "repeated">;
162
171
  /** Candidate release and deployment-lane reservation are inseparable: a
@@ -183,12 +183,79 @@ export class D1ContentRepository {
183
183
  throw new Error("stale_revision");
184
184
  return "created";
185
185
  }
186
+ /** Multi-document editorial gestures (such as a Media rewrite) must not
187
+ * leave half their draft revisions behind when a later CAS discovers an
188
+ * editor's concurrent save. D1 batches are transactional, so we validate
189
+ * every identity and build every guarded statement before issuing one batch. */
190
+ async saveMany(inputs) {
191
+ if (!inputs.length || new Set(inputs.map((input) => input.documentId)).size !== inputs.length
192
+ || new Set(inputs.map((input) => input.revisionId)).size !== inputs.length)
193
+ throw new Error("invalid_document");
194
+ const statements = [];
195
+ const guards = [];
196
+ for (const input of inputs) {
197
+ const existing = await this.db.prepare("SELECT revision_id, request_checksum FROM content_revisions WHERE revision_id = ?").bind(input.revisionId).first();
198
+ if (existing) {
199
+ assertIdempotent({ requestChecksum: existing.request_checksum }, input.requestChecksum);
200
+ continue;
201
+ }
202
+ if ((input.payload === null) === !input.tombstone)
203
+ throw new Error("invalid_document");
204
+ const document = await this.db.prepare("SELECT document_id, kind, locale, draft_revision_id, abandoned_at FROM content_documents WHERE document_id = ?").bind(input.documentId).first();
205
+ if (document && (document.kind !== input.kind || document.locale !== input.locale))
206
+ throw new Error("invalid_document");
207
+ const expectedDraftRevisionId = input.expectedDraftRevisionId === undefined ? input.baseRevisionId : input.expectedDraftRevisionId;
208
+ if (document && document.draft_revision_id !== expectedDraftRevisionId)
209
+ throw new Error("stale_revision");
210
+ const payload = input.payload === null ? null : canonicalJson(input.payload);
211
+ guards.push({ documentId: input.documentId, kind: input.kind, locale: input.locale, expectedDraftRevisionId, revisionId: input.revisionId });
212
+ const hasBase = expectedDraftRevisionId !== null;
213
+ const guard = hasBase ? "draft_revision_id = ?" : "draft_revision_id IS NULL";
214
+ statements.push(this.db.prepare(`INSERT INTO content_documents (document_id, kind, locale, translation_group_id, draft_revision_id, created_at, updated_at)
215
+ SELECT ?, ?, ?, ?, NULL, ?, ? WHERE NOT EXISTS (SELECT 1 FROM content_documents WHERE document_id = ?)`)
216
+ .bind(input.documentId, input.kind, input.locale, input.translationGroupId ?? null, input.now, input.now, input.documentId));
217
+ statements.push(this.db.prepare(`INSERT INTO content_revisions (revision_id, document_id, base_revision_id, payload_json, is_tombstone, schema_version, payload_checksum, request_checksum, author_subject, author_label, created_at)
218
+ SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? FROM content_documents WHERE document_id = ? AND ${guard}`)
219
+ .bind(input.revisionId, input.documentId, input.baseRevisionId, payload, Number(input.tombstone), input.schemaVersion, input.payloadChecksum, input.requestChecksum, input.authorSubject, input.authorLabel, input.now, input.documentId, ...(hasBase ? [expectedDraftRevisionId] : [])));
220
+ statements.push(this.db.prepare(`UPDATE content_documents SET draft_revision_id = ?, updated_at = ?, abandoned_at = NULL WHERE document_id = ? AND ${guard}`)
221
+ .bind(input.revisionId, input.now, input.documentId, ...(hasBase ? [expectedDraftRevisionId] : [])));
222
+ }
223
+ if (!statements.length)
224
+ return;
225
+ const guardSql = guards.map(() => `(NOT EXISTS (SELECT 1 FROM content_documents WHERE document_id = ?) AND ? IS NULL
226
+ OR EXISTS (SELECT 1 FROM content_documents WHERE document_id = ? AND kind = ? AND locale = ? AND draft_revision_id IS ?))
227
+ AND NOT EXISTS (SELECT 1 FROM content_revisions WHERE revision_id = ?)`).join(" AND ");
228
+ /* A zero-row CAS is not a SQL error and therefore would not roll a D1
229
+ * batch back. This statement turns any stale precondition into a CHECK
230
+ * violation before the first revision write; singleton=2 can never be a
231
+ * valid site-state row. */
232
+ const abortOnStale = this.db.prepare(`INSERT INTO content_site_state
233
+ (singleton, current_release_id, schema_generation, updated_at)
234
+ SELECT 2, NULL, 0, ? WHERE NOT (${guardSql})`)
235
+ .bind(inputs[0].now, ...guards.flatMap((guard) => [guard.documentId, guard.expectedDraftRevisionId,
236
+ guard.documentId, guard.kind, guard.locale, guard.expectedDraftRevisionId, guard.revisionId]));
237
+ const results = await this.db.batch([abortOnStale, ...statements]);
238
+ for (let index = 1; index < results.length; index += 3) {
239
+ if (results[index + 1]?.meta?.changes !== 1 || results[index + 2]?.meta?.changes !== 1)
240
+ throw new Error("stale_revision");
241
+ }
242
+ }
186
243
  async discard(documentId, expectedDraftRevisionId, baselineRevisionId, now) {
187
244
  const expected = expectedDraftRevisionId === null ? "draft_revision_id IS NULL" : "draft_revision_id = ?";
188
245
  const set = baselineRevisionId === null ? null : baselineRevisionId;
189
246
  const update = this.db.prepare(`UPDATE content_documents SET draft_revision_id = ?, abandoned_at = CASE WHEN ? IS NULL THEN ? ELSE NULL END, updated_at = ? WHERE document_id = ? AND ${expected}`)
190
247
  .bind(set, set, now, now, documentId, ...(expectedDraftRevisionId === null ? [] : [expectedDraftRevisionId]));
191
- const [result] = await this.db.batch([update]);
248
+ /* Restoring a baseline can reintroduce a source that a ready destructive
249
+ * operation was about to remove. This lives in the same transaction as the
250
+ * pointer CAS: Discard still succeeds, but the operation becomes visibly
251
+ * blocked rather than deleting a newly referenced public asset. */
252
+ const blockReintroduced = this.db.prepare(`UPDATE media_operations SET state = 'blocked_in_use', updated_at = ?
253
+ WHERE state = 'ready' AND ? IS NOT NULL AND EXISTS (SELECT 1 FROM media_operation_entries entry
254
+ JOIN content_revisions baseline ON baseline.revision_id = ? AND baseline.document_id = ?
255
+ WHERE entry.operation_id = media_operations.operation_id AND baseline.payload_json IS NOT NULL
256
+ AND instr(baseline.payload_json, '/media/' || entry.source_key) > 0)`)
257
+ .bind(now, baselineRevisionId, baselineRevisionId, documentId);
258
+ const [result] = await this.db.batch([update, blockReintroduced]);
192
259
  if (result?.meta?.changes === 1)
193
260
  return;
194
261
  const current = await this.db.prepare("SELECT document_id, draft_revision_id, abandoned_at FROM content_documents WHERE document_id = ?").bind(documentId).first();
@@ -252,20 +319,56 @@ export class D1ContentRepository {
252
319
  : [];
253
320
  const manifest = new Map(inherited.map((entry) => [entry.document_id, entry.revision_id]));
254
321
  const selected = [...input.entries];
255
- if (!selected.length)
322
+ const mediaOperationIds = [...new Set(input.mediaOperationIds ?? [])];
323
+ if (!selected.length && !mediaOperationIds.length)
256
324
  throw new Error("invalid_document");
257
325
  for (const entry of selected)
258
326
  manifest.set(entry.documentId, entry.revisionId);
259
- const selectionGuard = selected.map(() => "EXISTS (SELECT 1 FROM content_documents WHERE document_id = ? AND draft_revision_id = ?)").join(" AND ");
327
+ const selectionGuard = selected.length ? selected.map(() => "EXISTS (SELECT 1 FROM content_documents WHERE document_id = ? AND draft_revision_id = ?)").join(" AND ") : "1 = 1";
260
328
  const candidate = this.db.prepare(`INSERT INTO content_releases (release_id, base_release_id, request_checksum, status, active_slot, manifest_checksum, schema_package_version, materializer_version, created_by_subject, created_at)
261
329
  SELECT ?, current_release_id, ?, 'publishing', 1, ?, ?, ?, ?, ? FROM content_site_state
262
330
  WHERE singleton = 1 AND current_release_id IS ? AND NOT EXISTS (SELECT 1 FROM content_releases WHERE active_slot = 1)
263
331
  AND NOT EXISTS (SELECT 1 FROM production_deployment_attempts WHERE active_slot = 1) AND ${selectionGuard}`)
264
332
  .bind(input.releaseId, input.requestChecksum, input.manifestChecksum, input.schemaPackageVersion, input.materializerVersion, input.authorSubject, input.now, state.current_release_id, ...selected.flatMap((entry) => [entry.documentId, entry.revisionId]));
265
- const statements = [candidate];
333
+ const statements = [];
334
+ if (mediaOperationIds.length) {
335
+ const revisionIds = [...manifest.values()];
336
+ const eligible = mediaOperationIds.map(() => `(
337
+ EXISTS (SELECT 1 FROM media_operations WHERE operation_id = ? AND state = 'ready')
338
+ AND NOT EXISTS (SELECT 1 FROM media_operation_entries source
339
+ JOIN content_revisions revision ON revision.revision_id IN (${revisionIds.map(() => "?").join(",")})
340
+ WHERE source.operation_id = ? AND revision.payload_json IS NOT NULL
341
+ AND instr(revision.payload_json, '/media/' || source.source_key) > 0))`).join(" AND ");
342
+ /* As with multi-document Save, zero affected rows do not abort a D1
343
+ * batch. Convert an ineligible Media binding into a CHECK violation
344
+ * before the candidate exists, so no release/attempt can commit alone. */
345
+ statements.push(this.db.prepare(`INSERT INTO content_site_state
346
+ (singleton, current_release_id, schema_generation, updated_at)
347
+ SELECT 2, NULL, 0, ? WHERE NOT (${eligible})`)
348
+ .bind(input.now, ...mediaOperationIds.flatMap((operationId) => [operationId, ...revisionIds, operationId])));
349
+ }
350
+ const candidateIndex = statements.length;
351
+ statements.push(candidate);
266
352
  for (const [documentId, revisionId] of manifest)
267
353
  statements.push(this.db.prepare("INSERT INTO content_release_entries (release_id, document_id, revision_id) SELECT ?, ?, ? WHERE EXISTS (SELECT 1 FROM content_revisions WHERE document_id = ? AND revision_id = ?)")
268
354
  .bind(input.releaseId, documentId, revisionId, documentId, revisionId));
355
+ for (const operationId of mediaOperationIds) {
356
+ /* `instr` is intentionally evaluated against the frozen release entries,
357
+ * not drafts or a mutable current pointer. A URL is JSON-escaped neither
358
+ * by `/` nor by the canonical serializer, so the exact R2 public prefix
359
+ * remains a reliable conservative guard. */
360
+ statements.push(this.db.prepare(`UPDATE media_operations SET state = 'bound', updated_at = ?
361
+ WHERE operation_id = ? AND state = 'ready'
362
+ AND NOT EXISTS (SELECT 1 FROM media_operation_entries source
363
+ JOIN content_release_entries entry ON entry.release_id = ?
364
+ JOIN content_revisions revision ON revision.document_id = entry.document_id AND revision.revision_id = entry.revision_id
365
+ WHERE source.operation_id = media_operations.operation_id
366
+ AND revision.payload_json IS NOT NULL AND instr(revision.payload_json, '/media/' || source.source_key) > 0)`)
367
+ .bind(input.now, operationId, input.releaseId));
368
+ statements.push(this.db.prepare(`INSERT INTO content_release_media_operations (release_id, operation_id)
369
+ SELECT ?, ? WHERE EXISTS (SELECT 1 FROM media_operations WHERE operation_id = ? AND state = 'bound')`)
370
+ .bind(input.releaseId, operationId, operationId));
371
+ }
269
372
  statements.push(this.db.prepare(`INSERT INTO production_deployment_attempts
270
373
  (attempt_id, intent_kind, requested_release_id, requested_code_sha, code_sha, release_id, expected_fingerprint, phase, active_slot, created_at, updated_at)
271
374
  SELECT ?, ?, ?, ?, ?, ?, ?, 'waiting', 1, ?, ?
@@ -273,7 +376,7 @@ export class D1ContentRepository {
273
376
  AND EXISTS (SELECT 1 FROM content_releases WHERE release_id = ? AND status = 'publishing')`)
274
377
  .bind(input.attempt.attemptId, input.attempt.intentKind, input.attempt.requestedReleaseId, input.attempt.requestedCodeSha, input.attempt.codeSha, input.releaseId, input.attempt.expectedFingerprint, input.now, input.now, input.releaseId));
275
378
  const results = await this.db.batch(statements);
276
- if (results[0]?.meta?.changes !== 1 || results.slice(1, -1).some((result) => result.meta?.changes !== 1)
379
+ if (results[candidateIndex]?.meta?.changes !== 1 || results.slice(candidateIndex + 1, -1).some((result) => result.meta?.changes !== 1)
277
380
  || results.at(-1)?.meta?.changes !== 1)
278
381
  throw new Error("deployment_lane_busy");
279
382
  return "created";
package/dist/theme.css CHANGED
@@ -370,6 +370,18 @@ a.ak-btn { text-decoration: none; display: inline-flex; align-items: center; }
370
370
  background: var(--ak-panel); border: 1px solid var(--ak-accent);
371
371
  font: 500 var(--ak-fs-small)/1.4 var(--ak-font); color: var(--ak-ink);
372
372
  }
373
+ .mg-journal { display: flex; flex-direction: column; gap: 6px; }
374
+ .mg-journal-row {
375
+ display: flex; align-items: center; gap: 9px; flex-wrap: wrap;
376
+ margin: 0; padding: 8px 11px; border: 1px solid var(--ak-line); border-radius: var(--ak-radius);
377
+ background: var(--ak-panel); font: 500 var(--ak-fs-small)/1.4 var(--ak-font); color: var(--ak-ink);
378
+ }
379
+ .mg-journal-row.is-blocked_in_use, .mg-journal-row.is-completed_with_warning { border-color: var(--ak-accent); }
380
+ .mg-journal-row.is-failed { border-color: var(--ak-danger); }
381
+ .mg-journal-badge {
382
+ padding: 3px 6px; border-radius: 999px; background: var(--ak-bg);
383
+ color: var(--ak-ink-quiet); font: 600 10px/1 var(--ak-font); text-transform: uppercase; letter-spacing: .05em;
384
+ }
373
385
 
374
386
  /* Celle di ingombro uguale, immagine INTERA dentro. Prima erano quadrati con `object-fit: cover`:
375
387
  un verticale sembrava un quadrato e per riconoscere una foto bisognava aprirla. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cardinali-studio/adminkit",
3
- "version": "2.2.9",
3
+ "version": "2.2.11",
4
4
  "type": "module",
5
5
  "description": "Motore riusabile di un pannello di amministrazione per siti Hugo su Cloudflare Pages: codec del front matter guidato dallo schema, form React, libreria media su R2. NIENTE di specifico di un sito qui dentro.",
6
6
  "license": "UNLICENSED",