@cardinali-studio/adminkit 2.1.0-rc.4 → 2.2.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.
@@ -0,0 +1,182 @@
1
+ import type { StoredReleaseExport } from "./release.js";
2
+ export type JsonValue = null | boolean | number | string | JsonValue[] | {
3
+ [key: string]: JsonValue;
4
+ };
5
+ export type Revision = Readonly<{
6
+ revisionId: string;
7
+ documentId: string;
8
+ baseRevisionId: string | null;
9
+ expectedDraftRevisionId?: string | null;
10
+ payload: JsonValue | null;
11
+ tombstone: boolean;
12
+ schemaVersion: number;
13
+ payloadChecksum: string;
14
+ requestChecksum: string;
15
+ }>;
16
+ export type ReleaseStatus = "publishing" | "published" | "failed" | "superseded";
17
+ export declare function canonicalJson(value: unknown): string;
18
+ export declare function sha256Hex(value: string): Promise<string>;
19
+ export declare function canonicalChecksum(value: unknown): Promise<string>;
20
+ /** The client may carry this checksum for retry correlation, but the API
21
+ * recomputes it from the complete normalized Save command before D1 sees it. */
22
+ export declare function contentSaveRequestChecksum(input: {
23
+ revisionId: string;
24
+ documentId: string;
25
+ baseRevisionId: string | null;
26
+ expectedDraftRevisionId?: string | null;
27
+ kind: string;
28
+ locale: string;
29
+ translationGroupId?: string | null;
30
+ payload: JsonValue | null;
31
+ tombstone: boolean;
32
+ schemaVersion: number;
33
+ }): Promise<string>;
34
+ /** Stable UUIDv5-shaped identifier for import identities (site + legacy key + locale). */
35
+ export declare function deterministicId(...parts: string[]): Promise<string>;
36
+ export declare function assertIdempotent<T extends {
37
+ requestChecksum: string;
38
+ }>(existing: T | undefined, requestChecksum: string): {
39
+ kind: "create";
40
+ } | {
41
+ kind: "repeat";
42
+ value: T;
43
+ };
44
+ export declare class UpgraderChain {
45
+ #private;
46
+ register(fromVersion: number, upgrade: (payload: JsonValue) => JsonValue): void;
47
+ upgrade(payload: JsonValue, fromVersion: number, targetVersion: number): JsonValue;
48
+ }
49
+ export type DocumentDraft = {
50
+ documentId: string;
51
+ draftRevisionId: string | null;
52
+ abandoned: boolean;
53
+ };
54
+ /** Structural subset of the Workers D1 API. Keeping it local avoids leaking a
55
+ * Workers-only type into browser consumers of the package. */
56
+ export interface D1Result<T = unknown> {
57
+ results?: T[];
58
+ meta?: {
59
+ changes?: number;
60
+ };
61
+ success: boolean;
62
+ }
63
+ export interface D1Statement {
64
+ bind(...values: unknown[]): D1Statement;
65
+ first<T = unknown>(): Promise<T | null>;
66
+ all<T = unknown>(): Promise<D1Result<T>>;
67
+ run<T = unknown>(): Promise<D1Result<T>>;
68
+ }
69
+ export interface D1DatabaseLike {
70
+ prepare(query: string): D1Statement;
71
+ batch<T = unknown>(statements: D1Statement[]): Promise<D1Result<T>[]>;
72
+ }
73
+ export type StoredRevision = {
74
+ revisionId: string;
75
+ documentId: string;
76
+ baseRevisionId: string | null;
77
+ payload: string | null;
78
+ tombstone: boolean;
79
+ schemaVersion: number;
80
+ payloadChecksum: string;
81
+ createdAt: number;
82
+ };
83
+ export type EffectiveDocument = {
84
+ documentId: string;
85
+ kind: string;
86
+ locale: string;
87
+ draftRevisionId: string | null;
88
+ publishedRevisionId: string | null;
89
+ publishedPayload: string | null;
90
+ publishedTombstone: boolean | null;
91
+ };
92
+ export type SaveInput = Revision & {
93
+ /** The current DRAFT pointer used for CAS. It is distinct from the immutable
94
+ * revision ancestry: a first draft may be based on a published revision while
95
+ * the live draft pointer is still null. */
96
+ expectedDraftRevisionId?: string | null;
97
+ kind: string;
98
+ locale: string;
99
+ translationGroupId?: string | null;
100
+ now: number;
101
+ authorSubject: string;
102
+ authorLabel: string;
103
+ };
104
+ export type ReleaseInput = {
105
+ releaseId: string;
106
+ requestChecksum: string;
107
+ manifestChecksum: string;
108
+ schemaPackageVersion: string;
109
+ materializerVersion: string;
110
+ authorSubject: string;
111
+ now: number;
112
+ entries: readonly {
113
+ documentId: string;
114
+ revisionId: string;
115
+ }[];
116
+ };
117
+ export type ContentImportRun = {
118
+ importId: string;
119
+ requestChecksum: string;
120
+ sourceMainSha: string;
121
+ sourceDraftSha: string;
122
+ importerVersion: string;
123
+ schemaVersion: string;
124
+ canonicalChecksum: string;
125
+ countsJson: string;
126
+ result: string;
127
+ diagnosticId?: string | null;
128
+ now: number;
129
+ };
130
+ /** D1 persistence boundary. Every mutation is expressed as a guarded SQL batch:
131
+ * zero affected rows means stale state, never a best-effort overwrite. */
132
+ export declare class D1ContentRepository {
133
+ private readonly db;
134
+ constructor(db: D1DatabaseLike);
135
+ initializeSiteState(schemaGeneration: number, now: number): Promise<void>;
136
+ /** Import audit is resource-local and immutable: a rerun of the same fetched
137
+ * remote SHA pair is harmless; a reused ID with different evidence is not. */
138
+ recordImport(input: ContentImportRun): Promise<"created" | "repeated">;
139
+ getImport(importId: string): Promise<{
140
+ requestChecksum: string;
141
+ } | null>;
142
+ getEffectiveDocument(documentId: string): Promise<EffectiveDocument | null>;
143
+ getRevision(revisionId: string): Promise<StoredRevision | null>;
144
+ /** Exact immutable candidate/successful release only. This intentionally has
145
+ * no "latest" variant: a deployment attempt must name what it exports. */
146
+ getReleaseExport(releaseId: string): Promise<StoredReleaseExport | null>;
147
+ save(input: SaveInput): Promise<"created" | "repeated">;
148
+ discard(documentId: string, expectedDraftRevisionId: string | null, baselineRevisionId: string | null, now: number): Promise<void>;
149
+ publish(input: ReleaseInput): Promise<"created" | "repeated">;
150
+ finalize(releaseId: string, now: number): Promise<void>;
151
+ }
152
+ /** Pure strict-CAS kernel. The D1 adapter uses these guards around its SQL operations. */
153
+ export declare class ContentKernel {
154
+ readonly documents: Map<string, DocumentDraft>;
155
+ readonly revisions: Map<string, Readonly<{
156
+ revisionId: string;
157
+ documentId: string;
158
+ baseRevisionId: string | null;
159
+ expectedDraftRevisionId?: string | null;
160
+ payload: JsonValue | null;
161
+ tombstone: boolean;
162
+ schemaVersion: number;
163
+ payloadChecksum: string;
164
+ requestChecksum: string;
165
+ }>>;
166
+ readonly releases: Map<string, {
167
+ requestChecksum: string;
168
+ baseReleaseId: string | null;
169
+ status: ReleaseStatus;
170
+ entries: Map<string, string>;
171
+ }>;
172
+ currentReleaseId: string | null;
173
+ save(revision: Revision): Revision;
174
+ discard(documentId: string, expectedDraftRevisionId: string | null, baselineRevisionId: string | null): void;
175
+ publish(releaseId: string, requestChecksum: string, entries: Iterable<[string, string]>): void;
176
+ /** Creates the immutable full manifest from the current published release
177
+ * plus only the explicitly selected current drafts. Other drafts remain
178
+ * drafts; a candidate never becomes a partial published view by accident. */
179
+ publishSelected(releaseId: string, requestChecksum: string, selected: Iterable<[string, string]>): void;
180
+ private createRelease;
181
+ finalize(releaseId: string): void;
182
+ }
@@ -0,0 +1,320 @@
1
+ export function canonicalJson(value) {
2
+ if (value === null || typeof value === "boolean" || typeof value === "string")
3
+ return JSON.stringify(value);
4
+ if (typeof value === "number") {
5
+ if (!Number.isFinite(value))
6
+ throw new TypeError("JSON numbers must be finite");
7
+ return JSON.stringify(value);
8
+ }
9
+ if (Array.isArray(value))
10
+ return `[${value.map(canonicalJson).join(",")}]`;
11
+ if (typeof value !== "object")
12
+ throw new TypeError("Value is not canonical JSON");
13
+ const record = value;
14
+ return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`).join(",")}}`;
15
+ }
16
+ export async function sha256Hex(value) {
17
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
18
+ return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
19
+ }
20
+ export async function canonicalChecksum(value) {
21
+ return sha256Hex(canonicalJson(value));
22
+ }
23
+ /** The client may carry this checksum for retry correlation, but the API
24
+ * recomputes it from the complete normalized Save command before D1 sees it. */
25
+ export async function contentSaveRequestChecksum(input) {
26
+ return canonicalChecksum({
27
+ action: "content.save",
28
+ revisionId: input.revisionId,
29
+ documentId: input.documentId,
30
+ baseRevisionId: input.baseRevisionId,
31
+ expectedDraftRevisionId: input.expectedDraftRevisionId === undefined ? input.baseRevisionId : input.expectedDraftRevisionId,
32
+ kind: input.kind,
33
+ locale: input.locale,
34
+ translationGroupId: input.translationGroupId ?? null,
35
+ payload: input.payload,
36
+ tombstone: input.tombstone,
37
+ schemaVersion: input.schemaVersion,
38
+ });
39
+ }
40
+ /** Stable UUIDv5-shaped identifier for import identities (site + legacy key + locale). */
41
+ export async function deterministicId(...parts) {
42
+ if (!parts.length || parts.some((part) => !part))
43
+ throw new Error("invalid_deterministic_id_input");
44
+ const hex = await sha256Hex(parts.map((part) => `${part.length}:${part}`).join("|"));
45
+ const bytes = Array.from({ length: 16 }, (_, index) => hex.slice(index * 2, index * 2 + 2));
46
+ bytes[6] = `${(Number.parseInt(bytes[6], 16) & 0x0f | 0x50).toString(16).padStart(2, "0")}`;
47
+ bytes[8] = `${(Number.parseInt(bytes[8], 16) & 0x3f | 0x80).toString(16).padStart(2, "0")}`;
48
+ const compact = bytes.join("");
49
+ return `${compact.slice(0, 8)}-${compact.slice(8, 12)}-${compact.slice(12, 16)}-${compact.slice(16, 20)}-${compact.slice(20, 32)}`;
50
+ }
51
+ export function assertIdempotent(existing, requestChecksum) {
52
+ if (!existing)
53
+ return { kind: "create" };
54
+ if (existing.requestChecksum !== requestChecksum)
55
+ throw new Error("duplicate_request");
56
+ return { kind: "repeat", value: existing };
57
+ }
58
+ export class UpgraderChain {
59
+ #steps = new Map();
60
+ register(fromVersion, upgrade) {
61
+ if (!Number.isInteger(fromVersion) || fromVersion < 1 || this.#steps.has(fromVersion)) {
62
+ throw new Error("invalid_upgrader");
63
+ }
64
+ this.#steps.set(fromVersion, upgrade);
65
+ }
66
+ upgrade(payload, fromVersion, targetVersion) {
67
+ if (!Number.isInteger(fromVersion) || !Number.isInteger(targetVersion) || fromVersion > targetVersion) {
68
+ throw new Error("invalid_schema_version");
69
+ }
70
+ let value = payload;
71
+ for (let version = fromVersion; version < targetVersion; version += 1) {
72
+ const step = this.#steps.get(version);
73
+ if (!step)
74
+ throw new Error("unsupported_schema_version");
75
+ value = step(value);
76
+ }
77
+ return value;
78
+ }
79
+ }
80
+ /** D1 persistence boundary. Every mutation is expressed as a guarded SQL batch:
81
+ * zero affected rows means stale state, never a best-effort overwrite. */
82
+ export class D1ContentRepository {
83
+ db;
84
+ constructor(db) {
85
+ this.db = db;
86
+ }
87
+ async initializeSiteState(schemaGeneration, now) {
88
+ const result = await this.db.prepare("INSERT INTO content_site_state (singleton, current_release_id, schema_generation, updated_at) VALUES (1, NULL, ?, ?) ON CONFLICT(singleton) DO NOTHING")
89
+ .bind(schemaGeneration, now).run();
90
+ if (!result.success)
91
+ throw new Error("content_unavailable");
92
+ }
93
+ /** Import audit is resource-local and immutable: a rerun of the same fetched
94
+ * remote SHA pair is harmless; a reused ID with different evidence is not. */
95
+ async recordImport(input) {
96
+ const existing = await this.db.prepare("SELECT request_checksum AS requestChecksum FROM content_import_runs WHERE import_id = ?")
97
+ .bind(input.importId).first();
98
+ const idempotency = assertIdempotent(existing ?? undefined, input.requestChecksum);
99
+ if (idempotency.kind === "repeat")
100
+ return "repeated";
101
+ const result = await this.db.prepare(`INSERT INTO content_import_runs
102
+ (import_id, request_checksum, source_main_sha, source_draft_sha, importer_version, schema_version,
103
+ canonical_checksum, counts_json, result, diagnostic_id, created_at)
104
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
105
+ .bind(input.importId, input.requestChecksum, input.sourceMainSha, input.sourceDraftSha, input.importerVersion, input.schemaVersion, input.canonicalChecksum, input.countsJson, input.result, input.diagnosticId ?? null, input.now).run();
106
+ if (result.meta?.changes !== 1)
107
+ throw new Error("content_unavailable");
108
+ return "created";
109
+ }
110
+ async getImport(importId) {
111
+ return this.db.prepare("SELECT request_checksum AS requestChecksum FROM content_import_runs WHERE import_id = ?")
112
+ .bind(importId).first();
113
+ }
114
+ async getEffectiveDocument(documentId) {
115
+ return this.db.prepare(`SELECT d.document_id AS documentId, d.kind, d.locale, d.draft_revision_id AS draftRevisionId,
116
+ published.revision_id AS publishedRevisionId, published.payload_json AS publishedPayload, published.is_tombstone AS publishedTombstone
117
+ FROM content_documents d
118
+ LEFT JOIN content_site_state state ON state.singleton = 1
119
+ LEFT JOIN content_release_entries entry ON entry.release_id = state.current_release_id AND entry.document_id = d.document_id
120
+ LEFT JOIN content_revisions published ON published.document_id = entry.document_id AND published.revision_id = entry.revision_id
121
+ WHERE d.document_id = ?`).bind(documentId).first();
122
+ }
123
+ async getRevision(revisionId) {
124
+ return this.db.prepare(`SELECT revision_id AS revisionId, document_id AS documentId, base_revision_id AS baseRevisionId,
125
+ payload_json AS payload, is_tombstone AS tombstone, schema_version AS schemaVersion,
126
+ payload_checksum AS payloadChecksum, created_at AS createdAt
127
+ FROM content_revisions WHERE revision_id = ?`).bind(revisionId).first();
128
+ }
129
+ /** Exact immutable candidate/successful release only. This intentionally has
130
+ * no "latest" variant: a deployment attempt must name what it exports. */
131
+ async getReleaseExport(releaseId) {
132
+ const rows = (await this.db.prepare(`SELECT release.release_id AS releaseId, release.status,
133
+ release.schema_package_version AS schemaPackageVersion, release.materializer_version AS materializerVersion,
134
+ release.manifest_checksum AS manifestChecksum, entry.document_id AS documentId, entry.revision_id AS revisionId,
135
+ document.kind, document.locale, revision.schema_version AS schemaVersion, revision.is_tombstone AS tombstone,
136
+ revision.payload_json AS payloadJson, revision.payload_checksum AS payloadChecksum
137
+ FROM content_releases release
138
+ JOIN content_release_entries entry ON entry.release_id = release.release_id
139
+ JOIN content_documents document ON document.document_id = entry.document_id
140
+ JOIN content_revisions revision ON revision.document_id = entry.document_id AND revision.revision_id = entry.revision_id
141
+ WHERE release.release_id = ? AND release.status IN ('publishing', 'published')
142
+ ORDER BY entry.document_id`).bind(releaseId).all()).results ?? [];
143
+ if (!rows.length)
144
+ return null;
145
+ const [first] = rows;
146
+ return {
147
+ releaseId: first.releaseId, status: first.status, schemaPackageVersion: first.schemaPackageVersion,
148
+ materializerVersion: first.materializerVersion, manifestChecksum: first.manifestChecksum,
149
+ entries: rows.map(({ documentId, revisionId, kind, locale, schemaVersion, tombstone, payloadJson, payloadChecksum }) => ({ documentId, revisionId, kind, locale, schemaVersion, tombstone: Boolean(tombstone), payloadJson, payloadChecksum })),
150
+ };
151
+ }
152
+ async save(input) {
153
+ const existing = await this.db.prepare("SELECT revision_id, request_checksum FROM content_revisions WHERE revision_id = ?").bind(input.revisionId).first();
154
+ const idempotency = assertIdempotent(existing ? { requestChecksum: existing.request_checksum } : undefined, input.requestChecksum);
155
+ if (idempotency.kind === "repeat")
156
+ return "repeated";
157
+ if ((input.payload === null) === !input.tombstone)
158
+ throw new Error("invalid_document");
159
+ const document = await this.db.prepare("SELECT document_id, draft_revision_id, abandoned_at FROM content_documents WHERE document_id = ?").bind(input.documentId).first();
160
+ if (document && (document.kind !== input.kind || document.locale !== input.locale))
161
+ throw new Error("invalid_document");
162
+ const expectedDraftRevisionId = input.expectedDraftRevisionId === undefined ? input.baseRevisionId : input.expectedDraftRevisionId;
163
+ if (document && document.draft_revision_id !== expectedDraftRevisionId)
164
+ throw new Error("stale_revision");
165
+ const payload = input.payload === null ? null : canonicalJson(input.payload);
166
+ const hasBase = expectedDraftRevisionId !== null;
167
+ const guard = hasBase ? "draft_revision_id = ?" : "draft_revision_id IS NULL";
168
+ const createDocument = this.db.prepare(`INSERT INTO content_documents (document_id, kind, locale, translation_group_id, draft_revision_id, created_at, updated_at)
169
+ SELECT ?, ?, ?, ?, NULL, ?, ? WHERE NOT EXISTS (SELECT 1 FROM content_documents WHERE document_id = ?)`)
170
+ .bind(input.documentId, input.kind, input.locale, input.translationGroupId ?? null, input.now, input.now, input.documentId);
171
+ const revision = 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)
172
+ SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? FROM content_documents WHERE document_id = ? AND ${guard}`)
173
+ .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] : []));
174
+ const advance = this.db.prepare(`UPDATE content_documents SET draft_revision_id = ?, updated_at = ?, abandoned_at = NULL WHERE document_id = ? AND ${guard}`)
175
+ .bind(input.revisionId, input.now, input.documentId, ...(hasBase ? [expectedDraftRevisionId] : []));
176
+ const results = await this.db.batch([createDocument, revision, advance]);
177
+ if (results[1]?.meta?.changes !== 1 || results[2]?.meta?.changes !== 1)
178
+ throw new Error("stale_revision");
179
+ return "created";
180
+ }
181
+ async discard(documentId, expectedDraftRevisionId, baselineRevisionId, now) {
182
+ const expected = expectedDraftRevisionId === null ? "draft_revision_id IS NULL" : "draft_revision_id = ?";
183
+ const set = baselineRevisionId === null ? null : baselineRevisionId;
184
+ 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}`)
185
+ .bind(set, set, now, now, documentId, ...(expectedDraftRevisionId === null ? [] : [expectedDraftRevisionId]));
186
+ const [result] = await this.db.batch([update]);
187
+ if (result?.meta?.changes === 1)
188
+ return;
189
+ const current = await this.db.prepare("SELECT document_id, draft_revision_id, abandoned_at FROM content_documents WHERE document_id = ?").bind(documentId).first();
190
+ if (current?.draft_revision_id === baselineRevisionId)
191
+ return;
192
+ throw new Error("stale_revision");
193
+ }
194
+ async publish(input) {
195
+ const existing = await this.db.prepare("SELECT release_id, request_checksum, status, base_release_id FROM content_releases WHERE release_id = ?").bind(input.releaseId).first();
196
+ const idempotency = assertIdempotent(existing ? { requestChecksum: existing.request_checksum } : undefined, input.requestChecksum);
197
+ if (idempotency.kind === "repeat")
198
+ return "repeated";
199
+ const active = await this.db.prepare("SELECT release_id FROM content_releases WHERE active_slot = 1").first();
200
+ if (active)
201
+ throw new Error("release_in_progress");
202
+ const state = await this.db.prepare("SELECT current_release_id FROM content_site_state WHERE singleton = 1").first();
203
+ if (!state)
204
+ throw new Error("content_unavailable");
205
+ const inherited = state.current_release_id
206
+ ? (await this.db.prepare("SELECT document_id, revision_id FROM content_release_entries WHERE release_id = ?").bind(state.current_release_id).all()).results ?? []
207
+ : [];
208
+ const manifest = new Map(inherited.map((entry) => [entry.document_id, entry.revision_id]));
209
+ const selected = [...input.entries];
210
+ if (!selected.length)
211
+ throw new Error("invalid_document");
212
+ for (const entry of selected)
213
+ manifest.set(entry.documentId, entry.revisionId);
214
+ const selectionGuard = selected.map(() => "EXISTS (SELECT 1 FROM content_documents WHERE document_id = ? AND draft_revision_id = ?)").join(" AND ");
215
+ /* The candidate insert itself is the CAS. If an editor advanced any selected
216
+ * draft, or another release advanced the base release, it inserts zero rows
217
+ * and no candidate/entry can be left behind. */
218
+ 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)
219
+ SELECT ?, current_release_id, ?, 'publishing', 1, ?, ?, ?, ?, ? FROM content_site_state
220
+ WHERE singleton = 1 AND current_release_id IS ? AND ${selectionGuard}`)
221
+ .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]));
222
+ const statements = [candidate];
223
+ for (const [documentId, revisionId] of manifest)
224
+ 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 = ?)")
225
+ .bind(input.releaseId, documentId, revisionId, documentId, revisionId));
226
+ const results = await this.db.batch(statements);
227
+ if (results[0]?.meta?.changes !== 1)
228
+ throw new Error("stale_revision");
229
+ if (results.slice(1).some((result) => result.meta?.changes !== 1))
230
+ throw new Error("invalid_document");
231
+ return "created";
232
+ }
233
+ async finalize(releaseId, now) {
234
+ const release = await this.db.prepare("SELECT release_id, request_checksum, status, base_release_id FROM content_releases WHERE release_id = ?").bind(releaseId).first();
235
+ if (!release || release.status !== "publishing")
236
+ throw new Error("release_superseded");
237
+ const promote = this.db.prepare("UPDATE content_releases SET status = 'published', active_slot = NULL, finished_at = ? WHERE release_id = ? AND status = 'publishing' AND base_release_id IS (SELECT current_release_id FROM content_site_state WHERE singleton = 1)").bind(now, releaseId);
238
+ const pointer = this.db.prepare("UPDATE content_site_state SET current_release_id = ?, updated_at = ? WHERE singleton = 1 AND EXISTS (SELECT 1 FROM content_releases WHERE release_id = ? AND status = 'published')").bind(releaseId, now, releaseId);
239
+ const clearMatchingDrafts = this.db.prepare("UPDATE content_documents SET draft_revision_id = NULL, updated_at = ? WHERE draft_revision_id IN (SELECT revision_id FROM content_release_entries WHERE release_id = ?) AND document_id IN (SELECT document_id FROM content_release_entries WHERE release_id = ?)").bind(now, releaseId, releaseId);
240
+ const results = await this.db.batch([promote, pointer, clearMatchingDrafts]);
241
+ if (results[0]?.meta?.changes !== 1 || results[1]?.meta?.changes !== 1)
242
+ throw new Error("release_superseded");
243
+ }
244
+ }
245
+ /** Pure strict-CAS kernel. The D1 adapter uses these guards around its SQL operations. */
246
+ export class ContentKernel {
247
+ documents = new Map();
248
+ revisions = new Map();
249
+ releases = new Map();
250
+ currentReleaseId = null;
251
+ save(revision) {
252
+ const prior = this.revisions.get(revision.revisionId);
253
+ const idempotency = assertIdempotent(prior, revision.requestChecksum);
254
+ if (idempotency.kind === "repeat")
255
+ return idempotency.value;
256
+ if ((revision.payload === null) === !revision.tombstone)
257
+ throw new Error("invalid_document");
258
+ const document = this.documents.get(revision.documentId) ?? { documentId: revision.documentId, draftRevisionId: null, abandoned: false };
259
+ if (document.draftRevisionId !== (revision.expectedDraftRevisionId === undefined ? revision.baseRevisionId : revision.expectedDraftRevisionId))
260
+ throw new Error("stale_revision");
261
+ this.revisions.set(revision.revisionId, revision);
262
+ this.documents.set(revision.documentId, { ...document, draftRevisionId: revision.revisionId, abandoned: false });
263
+ return revision;
264
+ }
265
+ discard(documentId, expectedDraftRevisionId, baselineRevisionId) {
266
+ const document = this.documents.get(documentId);
267
+ if (!document || document.draftRevisionId === baselineRevisionId)
268
+ return;
269
+ if (document.draftRevisionId !== expectedDraftRevisionId)
270
+ throw new Error("stale_revision");
271
+ this.documents.set(documentId, { ...document, draftRevisionId: baselineRevisionId, abandoned: baselineRevisionId === null });
272
+ }
273
+ publish(releaseId, requestChecksum, entries) {
274
+ this.createRelease(releaseId, requestChecksum, new Map(entries));
275
+ }
276
+ /** Creates the immutable full manifest from the current published release
277
+ * plus only the explicitly selected current drafts. Other drafts remain
278
+ * drafts; a candidate never becomes a partial published view by accident. */
279
+ publishSelected(releaseId, requestChecksum, selected) {
280
+ const current = this.currentReleaseId ? this.releases.get(this.currentReleaseId) : undefined;
281
+ const manifest = new Map(current?.entries ?? []);
282
+ for (const [documentId, revisionId] of selected) {
283
+ const document = this.documents.get(documentId);
284
+ if (!document || document.draftRevisionId !== revisionId)
285
+ throw new Error("stale_revision");
286
+ manifest.set(documentId, revisionId);
287
+ }
288
+ this.createRelease(releaseId, requestChecksum, manifest);
289
+ }
290
+ createRelease(releaseId, requestChecksum, manifest) {
291
+ const existing = this.releases.get(releaseId);
292
+ const idempotency = assertIdempotent(existing, requestChecksum);
293
+ if (idempotency.kind === "repeat")
294
+ return;
295
+ if ([...this.releases.values()].some((release) => release.status === "publishing"))
296
+ throw new Error("release_in_progress");
297
+ for (const [documentId, revisionId] of manifest) {
298
+ const revision = this.revisions.get(revisionId);
299
+ if (!revision || revision.documentId !== documentId)
300
+ throw new Error("invalid_document");
301
+ }
302
+ this.releases.set(releaseId, { requestChecksum, baseReleaseId: this.currentReleaseId, status: "publishing", entries: manifest });
303
+ }
304
+ finalize(releaseId) {
305
+ const release = this.releases.get(releaseId);
306
+ if (!release || release.status !== "publishing")
307
+ throw new Error("release_superseded");
308
+ if (release.baseReleaseId !== this.currentReleaseId) {
309
+ release.status = "superseded";
310
+ throw new Error("release_superseded");
311
+ }
312
+ release.status = "published";
313
+ this.currentReleaseId = releaseId;
314
+ for (const [documentId, revisionId] of release.entries) {
315
+ const document = this.documents.get(documentId);
316
+ if (document?.draftRevisionId === revisionId)
317
+ this.documents.set(documentId, { ...document, draftRevisionId: null });
318
+ }
319
+ }
320
+ }
@@ -0,0 +1,48 @@
1
+ import { type JsonValue } from "./persistence.js";
2
+ export type ReleaseExportEntry = {
3
+ documentId: string;
4
+ revisionId: string;
5
+ kind: string;
6
+ locale: string;
7
+ schemaVersion: number;
8
+ tombstone: boolean;
9
+ payload: JsonValue | null;
10
+ payloadChecksum: string;
11
+ };
12
+ export type ImmutableReleaseExport = {
13
+ releaseId: string;
14
+ schemaPackageVersion: string;
15
+ materializerVersion: string;
16
+ manifestChecksum: string;
17
+ entries: readonly ReleaseExportEntry[];
18
+ };
19
+ /** Raw D1 snapshot. The repository returns this exact release only; converting
20
+ * it to an export revalidates every payload/checksum before a CI consumer sees
21
+ * it. */
22
+ export type StoredReleaseExport = {
23
+ releaseId: string;
24
+ status: "publishing" | "published";
25
+ schemaPackageVersion: string;
26
+ materializerVersion: string;
27
+ manifestChecksum: string;
28
+ entries: readonly (Omit<ReleaseExportEntry, "payload"> & {
29
+ payloadJson: string | null;
30
+ })[];
31
+ };
32
+ /** A release export is an immutable, sorted full snapshot. It is never a
33
+ * convenience view of current drafts, so a CI attempt can reproduce exactly
34
+ * what the release candidate promised. */
35
+ export declare function createReleaseExport(input: Omit<ImmutableReleaseExport, "manifestChecksum">): Promise<ImmutableReleaseExport>;
36
+ export declare function releaseExportFromStored(snapshot: StoredReleaseExport): Promise<ImmutableReleaseExport>;
37
+ /** Safe to put in `/.well-known/adminkit-deployment.json`: no payload, D1 id,
38
+ * release id, or credential appears in the digest or output. */
39
+ export declare function deploymentFingerprint(input: {
40
+ codeSha: string;
41
+ manifestChecksum: string;
42
+ schemaPackageVersion: string;
43
+ materializerVersion: string;
44
+ }): Promise<{
45
+ version: 1;
46
+ fingerprint: string;
47
+ }>;
48
+ export declare function deploymentFingerprintJson(fingerprint: Awaited<ReturnType<typeof deploymentFingerprint>>): string;
@@ -0,0 +1,57 @@
1
+ import { canonicalChecksum, canonicalJson } from "./persistence.js";
2
+ /** A release export is an immutable, sorted full snapshot. It is never a
3
+ * convenience view of current drafts, so a CI attempt can reproduce exactly
4
+ * what the release candidate promised. */
5
+ export async function createReleaseExport(input) {
6
+ if (!input.releaseId || !input.schemaPackageVersion || !input.materializerVersion)
7
+ throw new Error("invalid_release_export");
8
+ const entries = [...input.entries].sort((left, right) => left.documentId.localeCompare(right.documentId));
9
+ if (!entries.length || new Set(entries.map((entry) => entry.documentId)).size !== entries.length)
10
+ throw new Error("invalid_release_export");
11
+ for (const entry of entries) {
12
+ if (!entry.documentId || !entry.revisionId || !entry.kind || !entry.locale || !Number.isInteger(entry.schemaVersion) || entry.schemaVersion < 1) {
13
+ throw new Error("invalid_release_export");
14
+ }
15
+ if ((entry.payload === null) !== entry.tombstone)
16
+ throw new Error("invalid_release_export");
17
+ if (entry.payloadChecksum !== await canonicalChecksum(entry.payload))
18
+ throw new Error("invalid_release_export");
19
+ }
20
+ const manifestChecksum = await canonicalChecksum({
21
+ schemaPackageVersion: input.schemaPackageVersion,
22
+ materializerVersion: input.materializerVersion,
23
+ entries: entries.map(({ documentId, revisionId, kind, locale, schemaVersion, tombstone, payloadChecksum }) => ({ documentId, revisionId, kind, locale, schemaVersion, tombstone, payloadChecksum })),
24
+ });
25
+ return { ...input, entries, manifestChecksum };
26
+ }
27
+ export async function releaseExportFromStored(snapshot) {
28
+ const release = await createReleaseExport({
29
+ releaseId: snapshot.releaseId,
30
+ schemaPackageVersion: snapshot.schemaPackageVersion,
31
+ materializerVersion: snapshot.materializerVersion,
32
+ entries: snapshot.entries.map((entry) => ({
33
+ documentId: entry.documentId, revisionId: entry.revisionId, kind: entry.kind, locale: entry.locale,
34
+ schemaVersion: entry.schemaVersion, tombstone: entry.tombstone,
35
+ payload: entry.payloadJson === null ? null : JSON.parse(entry.payloadJson), payloadChecksum: entry.payloadChecksum,
36
+ })),
37
+ });
38
+ if (release.manifestChecksum !== snapshot.manifestChecksum)
39
+ throw new Error("invalid_release_export");
40
+ return release;
41
+ }
42
+ /** Safe to put in `/.well-known/adminkit-deployment.json`: no payload, D1 id,
43
+ * release id, or credential appears in the digest or output. */
44
+ export async function deploymentFingerprint(input) {
45
+ if (Object.values(input).some((value) => !value))
46
+ throw new Error("invalid_fingerprint");
47
+ const fingerprint = await canonicalChecksum({
48
+ codeSha: input.codeSha,
49
+ manifestChecksum: input.manifestChecksum,
50
+ schemaPackageVersion: input.schemaPackageVersion,
51
+ materializerVersion: input.materializerVersion,
52
+ });
53
+ return { version: 1, fingerprint };
54
+ }
55
+ export function deploymentFingerprintJson(fingerprint) {
56
+ return canonicalJson(fingerprint);
57
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cardinali-studio/adminkit",
3
- "version": "2.1.0-rc.4",
3
+ "version": "2.2.0",
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",
@@ -31,13 +31,17 @@
31
31
  "types": "./dist/testing.d.ts",
32
32
  "default": "./dist/testing.js"
33
33
  },
34
+ "./materializer": {
35
+ "types": "./dist/materializer.d.ts",
36
+ "default": "./dist/materializer.js"
37
+ },
34
38
  "./functions/*": "./dist/functions/*"
35
39
  },
36
40
  "files": [
37
41
  "dist"
38
42
  ],
39
43
  "scripts": {
40
- "build": "rm -rf dist && tsc -p tsconfig.json && cp src/theme.css dist/theme.css && cp src/app/app.css dist/app/app.css && cp -R src/functions dist/functions",
44
+ "build": "rm -rf dist && tsc -p tsconfig.json && cp src/theme.css dist/theme.css && cp src/app/app.css dist/app/app.css && cp -R src/functions dist/functions && cp -R src/migrations dist/migrations",
41
45
  "test": "node --test \"test/*.test.js\"",
42
46
  "check": "npm run build && npm test"
43
47
  },
@@ -45,6 +49,7 @@
45
49
  "access": "public"
46
50
  },
47
51
  "peerDependencies": {
52
+ "@cloudflare/pages-plugin-cloudflare-access": "^1",
48
53
  "@dnd-kit/core": "^6.3.1",
49
54
  "@dnd-kit/modifiers": "^9.0.0",
50
55
  "@dnd-kit/sortable": "^10.0.0",
@@ -52,8 +57,7 @@
52
57
  "react": "^19",
53
58
  "react-dom": "^19",
54
59
  "react-hook-form": "^7",
55
- "zod": "^4",
56
- "@cloudflare/pages-plugin-cloudflare-access": "^1"
60
+ "zod": "^4"
57
61
  },
58
62
  "peerDependenciesMeta": {
59
63
  "react": {
@@ -82,6 +86,7 @@
82
86
  }
83
87
  },
84
88
  "devDependencies": {
89
+ "@cloudflare/pages-plugin-cloudflare-access": "1.0.5",
85
90
  "@dnd-kit/core": "^6.3.1",
86
91
  "@dnd-kit/modifiers": "^9.0.0",
87
92
  "@dnd-kit/sortable": "^10.0.0",
@@ -92,6 +97,7 @@
92
97
  "react-dom": "^19.2.8",
93
98
  "react-hook-form": "^7.85.0",
94
99
  "typescript": "^5.9.3",
100
+ "wrangler": "4.114.0",
95
101
  "zod": "^4.4.3"
96
102
  }
97
103
  }