@cardinali-studio/adminkit 2.1.0 → 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,136 @@
1
+ import type { D1DatabaseLike } from "./persistence.js";
2
+ export declare const mediaOperationStates: readonly ["preparing", "ready", "blocked_in_use", "bound", "finalizing", "completed", "completed_with_warning", "cancelled", "failed"];
3
+ export type MediaOperationState = typeof mediaOperationStates[number];
4
+ export type MediaOperationType = "move" | "delete";
5
+ export type MediaOperation = Readonly<{
6
+ operationId: string;
7
+ requestChecksum: string;
8
+ type: MediaOperationType;
9
+ state: MediaOperationState;
10
+ sourceKeys: readonly string[];
11
+ boundReleaseId: string | null;
12
+ }>;
13
+ export declare function assertMediaTransition(from: MediaOperationState, to: MediaOperationState): void;
14
+ /** Pure durable-intent model. Object storage effects deliberately live outside
15
+ * it: an R2 copy may be retried, but no state transition may make a public
16
+ * release point at a source key that has already been removed. */
17
+ export declare class MediaOperationKernel {
18
+ readonly operations: Map<string, Readonly<{
19
+ operationId: string;
20
+ requestChecksum: string;
21
+ type: MediaOperationType;
22
+ state: MediaOperationState;
23
+ sourceKeys: readonly string[];
24
+ boundReleaseId: string | null;
25
+ }>>;
26
+ readonly sourceOwners: Map<string, string>;
27
+ create(input: Omit<MediaOperation, "state" | "boundReleaseId">): MediaOperation;
28
+ transition(operationId: string, to: MediaOperationState): MediaOperation;
29
+ bind(operationId: string, releaseId: string): MediaOperation;
30
+ /** Discard never rolls content backwards. It only makes a pending destructive
31
+ * operation visibly unsafe until a later release removes that source again. */
32
+ markBlockedWhenReintroduced(sourceKeys: Iterable<string>): readonly MediaOperation[];
33
+ cancel(operationId: string): MediaOperation;
34
+ private required;
35
+ private releaseSources;
36
+ }
37
+ export type MediaOperationEntryInput = Readonly<{
38
+ entryId: string;
39
+ sourceKey: string;
40
+ destinationKey?: string | null;
41
+ objectChecksum?: string | null;
42
+ etag?: string | null;
43
+ metadataJson?: string | null;
44
+ }>;
45
+ export type CreateMediaOperation = Readonly<{
46
+ operationId: string;
47
+ requestChecksum: string;
48
+ type: MediaOperationType;
49
+ sourcePrefix?: string | null;
50
+ destinationPrefix?: string | null;
51
+ authorSubject: string;
52
+ now: number;
53
+ entries: readonly MediaOperationEntryInput[];
54
+ }>;
55
+ /** SQL journal adapter. R2 copy/delete happens around this adapter; it never
56
+ * infers an object as removable simply because a caller stopped mentioning it. */
57
+ export declare class D1MediaOperationRepository {
58
+ private readonly db;
59
+ constructor(db: D1DatabaseLike);
60
+ create(input: CreateMediaOperation): Promise<"created" | "repeated">;
61
+ bind(operationId: string, releaseId: string, now: number): Promise<void>;
62
+ /** Called only after a copy-first R2 effect has been checksum-verified. A
63
+ * destination is therefore durable before an operation ever becomes ready
64
+ * for a release binding. */
65
+ recordPreparedEntry(input: {
66
+ operationId: string;
67
+ entryId: string;
68
+ now: number;
69
+ objectChecksum: string;
70
+ etag: string | null;
71
+ metadataJson: string | null;
72
+ }): Promise<"pending" | "ready">;
73
+ beginFinalization(operationId: string, releaseId: string, now: number): Promise<void>;
74
+ /** Individual cleanup writes are retryable. A single failed entry reaches
75
+ * `completed_with_warning` only after every entry has a terminal result;
76
+ * the already-live release is never rolled back. */
77
+ recordFinalizedEntry(input: {
78
+ operationId: string;
79
+ entryId: string;
80
+ now: number;
81
+ outcome: "completed" | "failed";
82
+ safeError?: string | null;
83
+ }): Promise<"pending" | "settled">;
84
+ blockReintroducedSources(sourceKeys: readonly string[], now: number): Promise<number>;
85
+ cancel(operationId: string, now: number): Promise<void>;
86
+ }
87
+ type R2Metadata = Record<string, string>;
88
+ export type MediaR2Object = {
89
+ arrayBuffer(): Promise<ArrayBuffer>;
90
+ httpMetadata?: Record<string, string | number | boolean | undefined>;
91
+ customMetadata?: R2Metadata;
92
+ etag?: string;
93
+ };
94
+ export type MediaR2Head = {
95
+ customMetadata?: R2Metadata;
96
+ etag?: string;
97
+ } | null;
98
+ export type MediaR2Bucket = {
99
+ get(key: string): Promise<MediaR2Object | null>;
100
+ head(key: string): Promise<MediaR2Head>;
101
+ put(key: string, value: ArrayBuffer, options: {
102
+ httpMetadata?: MediaR2Object["httpMetadata"];
103
+ customMetadata?: R2Metadata;
104
+ }): Promise<unknown>;
105
+ delete(key: string): Promise<unknown>;
106
+ };
107
+ export type MediaPreparationJournal = Pick<D1MediaOperationRepository, "recordPreparedEntry">;
108
+ export type MediaFinalizationJournal = Pick<D1MediaOperationRepository, "beginFinalization" | "recordFinalizedEntry">;
109
+ /** Copy-first R2 effect. No existing destination is overwritten: an identical
110
+ * prior copy is an idempotent retry, while any opaque collision stops safely. */
111
+ export declare function prepareMediaCopy(input: {
112
+ bucket: MediaR2Bucket;
113
+ journal: MediaPreparationJournal;
114
+ operationId: string;
115
+ entryId: string;
116
+ sourceKey: string;
117
+ destinationKey: string;
118
+ now: number;
119
+ }): Promise<"pending" | "ready">;
120
+ /** Cleanup starts only after the release is published. Failures are recorded as
121
+ * safe per-entry warnings, preserving the public source URL for a later retry. */
122
+ export declare function finalizeMediaSources(input: {
123
+ bucket: MediaR2Bucket;
124
+ journal: MediaFinalizationJournal;
125
+ operationId: string;
126
+ releaseId: string;
127
+ now: number;
128
+ entries: readonly {
129
+ entryId: string;
130
+ sourceKey: string;
131
+ }[];
132
+ }): Promise<{
133
+ completed: number;
134
+ failed: number;
135
+ }>;
136
+ export {};
package/dist/media.js ADDED
@@ -0,0 +1,270 @@
1
+ import { canonicalJson } from "./persistence.js";
2
+ export const mediaOperationStates = [
3
+ "preparing", "ready", "blocked_in_use", "bound", "finalizing", "completed", "completed_with_warning", "cancelled", "failed",
4
+ ];
5
+ const terminal = new Set(["completed", "cancelled"]);
6
+ const transitions = {
7
+ preparing: ["ready", "failed", "cancelled"],
8
+ ready: ["blocked_in_use", "bound", "failed", "cancelled"],
9
+ blocked_in_use: ["ready", "failed", "cancelled"],
10
+ bound: ["finalizing", "failed"],
11
+ finalizing: ["completed", "completed_with_warning", "failed"],
12
+ completed_with_warning: ["finalizing"],
13
+ failed: ["preparing", "cancelled"],
14
+ };
15
+ export function assertMediaTransition(from, to) {
16
+ if (!transitions[from]?.includes(to))
17
+ throw new Error("invalid_media_transition");
18
+ }
19
+ /** Pure durable-intent model. Object storage effects deliberately live outside
20
+ * it: an R2 copy may be retried, but no state transition may make a public
21
+ * release point at a source key that has already been removed. */
22
+ export class MediaOperationKernel {
23
+ operations = new Map();
24
+ sourceOwners = new Map();
25
+ create(input) {
26
+ const existing = this.operations.get(input.operationId);
27
+ if (existing) {
28
+ if (existing.requestChecksum !== input.requestChecksum)
29
+ throw new Error("duplicate_request");
30
+ return existing;
31
+ }
32
+ if (!input.sourceKeys.length || new Set(input.sourceKeys).size !== input.sourceKeys.length || input.sourceKeys.some((key) => !key)) {
33
+ throw new Error("invalid_media_operation");
34
+ }
35
+ if (input.sourceKeys.some((key) => this.sourceOwners.has(key)))
36
+ throw new Error("media_source_busy");
37
+ const operation = { ...input, sourceKeys: [...input.sourceKeys], state: "preparing", boundReleaseId: null };
38
+ this.operations.set(operation.operationId, operation);
39
+ for (const key of operation.sourceKeys)
40
+ this.sourceOwners.set(key, operation.operationId);
41
+ return operation;
42
+ }
43
+ transition(operationId, to) {
44
+ const operation = this.required(operationId);
45
+ assertMediaTransition(operation.state, to);
46
+ const updated = { ...operation, state: to };
47
+ this.operations.set(operationId, updated);
48
+ if (terminal.has(to))
49
+ this.releaseSources(updated);
50
+ return updated;
51
+ }
52
+ bind(operationId, releaseId) {
53
+ const operation = this.required(operationId);
54
+ if (!releaseId || operation.state !== "ready" || operation.boundReleaseId)
55
+ throw new Error("media_operation_unavailable");
56
+ const updated = { ...operation, state: "bound", boundReleaseId: releaseId };
57
+ this.operations.set(operationId, updated);
58
+ return updated;
59
+ }
60
+ /** Discard never rolls content backwards. It only makes a pending destructive
61
+ * operation visibly unsafe until a later release removes that source again. */
62
+ markBlockedWhenReintroduced(sourceKeys) {
63
+ const affected = new Set();
64
+ for (const key of sourceKeys) {
65
+ const owner = this.sourceOwners.get(key);
66
+ if (owner)
67
+ affected.add(owner);
68
+ }
69
+ const blocked = [];
70
+ for (const operationId of affected) {
71
+ const operation = this.required(operationId);
72
+ if (operation.state === "ready")
73
+ blocked.push(this.transition(operationId, "blocked_in_use"));
74
+ }
75
+ return blocked;
76
+ }
77
+ cancel(operationId) {
78
+ const operation = this.required(operationId);
79
+ if (operation.boundReleaseId || !["preparing", "ready", "blocked_in_use", "failed"].includes(operation.state)) {
80
+ throw new Error("media_operation_unavailable");
81
+ }
82
+ return this.transition(operationId, "cancelled");
83
+ }
84
+ required(operationId) {
85
+ const operation = this.operations.get(operationId);
86
+ if (!operation)
87
+ throw new Error("media_operation_not_found");
88
+ return operation;
89
+ }
90
+ releaseSources(operation) {
91
+ for (const key of operation.sourceKeys) {
92
+ if (this.sourceOwners.get(key) === operation.operationId)
93
+ this.sourceOwners.delete(key);
94
+ }
95
+ }
96
+ }
97
+ /** SQL journal adapter. R2 copy/delete happens around this adapter; it never
98
+ * infers an object as removable simply because a caller stopped mentioning it. */
99
+ export class D1MediaOperationRepository {
100
+ db;
101
+ constructor(db) {
102
+ this.db = db;
103
+ }
104
+ async create(input) {
105
+ if (!input.entries.length || new Set(input.entries.map((entry) => entry.entryId)).size !== input.entries.length
106
+ || new Set(input.entries.map((entry) => entry.sourceKey)).size !== input.entries.length)
107
+ throw new Error("invalid_media_operation");
108
+ const existing = await this.db.prepare("SELECT request_checksum AS requestChecksum FROM media_operations WHERE operation_id = ?")
109
+ .bind(input.operationId).first();
110
+ if (existing) {
111
+ if (existing.requestChecksum !== input.requestChecksum)
112
+ throw new Error("duplicate_request");
113
+ return "repeated";
114
+ }
115
+ const statements = [this.db.prepare(`INSERT INTO media_operations
116
+ (operation_id, request_checksum, type, state, source_prefix, destination_prefix, created_by_subject, created_at, updated_at)
117
+ VALUES (?, ?, ?, 'preparing', ?, ?, ?, ?, ?)`)
118
+ .bind(input.operationId, input.requestChecksum, input.type, input.sourcePrefix ?? null, input.destinationPrefix ?? null, input.authorSubject, input.now, input.now)];
119
+ for (const entry of input.entries) {
120
+ statements.push(this.db.prepare(`INSERT INTO media_operation_entries
121
+ (operation_id, entry_id, source_key, destination_key, object_checksum, etag, metadata_json, preparation_state, finalization_state)
122
+ VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', 'pending')`)
123
+ .bind(input.operationId, entry.entryId, entry.sourceKey, entry.destinationKey ?? null, entry.objectChecksum ?? null, entry.etag ?? null, entry.metadataJson ?? null));
124
+ }
125
+ const results = await this.db.batch(statements);
126
+ if (results.some((result) => result.meta?.changes !== 1))
127
+ throw new Error("media_operation_unavailable");
128
+ return "created";
129
+ }
130
+ async bind(operationId, releaseId, now) {
131
+ /* A D1 batch is transactional. The first statement cannot change anything
132
+ * unless the candidate release is still publishing; the second can only
133
+ * observe that guarded `bound` state from the same transaction. */
134
+ const claim = this.db.prepare(`UPDATE media_operations SET state = 'bound', updated_at = ?
135
+ WHERE operation_id = ? AND state = 'ready'
136
+ AND EXISTS (SELECT 1 FROM content_releases WHERE release_id = ? AND status = 'publishing')
137
+ AND NOT EXISTS (SELECT 1 FROM content_release_media_operations WHERE operation_id = ?)`)
138
+ .bind(now, operationId, releaseId, operationId);
139
+ const binding = this.db.prepare(`INSERT INTO content_release_media_operations (release_id, operation_id)
140
+ SELECT ?, ? WHERE EXISTS (SELECT 1 FROM media_operations WHERE operation_id = ? AND state = 'bound')
141
+ AND EXISTS (SELECT 1 FROM content_releases WHERE release_id = ? AND status = 'publishing')`)
142
+ .bind(releaseId, operationId, operationId, releaseId);
143
+ const results = await this.db.batch([claim, binding]);
144
+ if (results[0]?.meta?.changes !== 1 || results[1]?.meta?.changes !== 1)
145
+ throw new Error("media_operation_unavailable");
146
+ }
147
+ /** Called only after a copy-first R2 effect has been checksum-verified. A
148
+ * destination is therefore durable before an operation ever becomes ready
149
+ * for a release binding. */
150
+ async recordPreparedEntry(input) {
151
+ const entry = this.db.prepare(`UPDATE media_operation_entries
152
+ SET object_checksum = ?, etag = ?, metadata_json = ?, preparation_state = 'ready'
153
+ WHERE operation_id = ? AND entry_id = ? AND preparation_state = 'pending'
154
+ AND EXISTS (SELECT 1 FROM media_operations WHERE operation_id = ? AND state = 'preparing')`)
155
+ .bind(input.objectChecksum, input.etag, input.metadataJson, input.operationId, input.entryId, input.operationId);
156
+ const ready = this.db.prepare(`UPDATE media_operations SET state = 'ready', updated_at = ?
157
+ WHERE operation_id = ? AND state = 'preparing' AND NOT EXISTS (SELECT 1 FROM media_operation_entries
158
+ WHERE operation_id = ? AND preparation_state != 'ready')`)
159
+ .bind(input.now, input.operationId, input.operationId);
160
+ const results = await this.db.batch([entry, ready]);
161
+ if (results[0]?.meta?.changes !== 1)
162
+ throw new Error("media_operation_unavailable");
163
+ return results[1]?.meta?.changes === 1 ? "ready" : "pending";
164
+ }
165
+ async beginFinalization(operationId, releaseId, now) {
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
168
+ WHERE operation_id = ? AND release_id = ?) AND EXISTS (SELECT 1 FROM content_releases
169
+ WHERE release_id = ? AND status = 'published')`)
170
+ .bind(now, operationId, operationId, releaseId, releaseId).run();
171
+ if (result.meta?.changes !== 1)
172
+ throw new Error("media_operation_unavailable");
173
+ }
174
+ /** Individual cleanup writes are retryable. A single failed entry reaches
175
+ * `completed_with_warning` only after every entry has a terminal result;
176
+ * the already-live release is never rolled back. */
177
+ async recordFinalizedEntry(input) {
178
+ const entry = this.db.prepare(`UPDATE media_operation_entries SET finalization_state = ?, safe_error = ?
179
+ WHERE operation_id = ? AND entry_id = ? AND finalization_state = 'pending'
180
+ AND EXISTS (SELECT 1 FROM media_operations WHERE operation_id = ? AND state = 'finalizing')`)
181
+ .bind(input.outcome, input.safeError ?? null, input.operationId, input.entryId, input.operationId);
182
+ const finish = this.db.prepare(`UPDATE media_operations SET state = CASE WHEN EXISTS (SELECT 1 FROM media_operation_entries
183
+ WHERE operation_id = ? AND finalization_state = 'failed') THEN 'completed_with_warning' ELSE 'completed' END,
184
+ updated_at = ? WHERE operation_id = ? AND state = 'finalizing' AND NOT EXISTS (SELECT 1 FROM media_operation_entries
185
+ WHERE operation_id = ? AND finalization_state = 'pending')`)
186
+ .bind(input.operationId, input.now, input.operationId, input.operationId);
187
+ const results = await this.db.batch([entry, finish]);
188
+ if (results[0]?.meta?.changes !== 1)
189
+ throw new Error("media_operation_unavailable");
190
+ if (results[1]?.meta?.changes !== 1)
191
+ return "pending";
192
+ return "settled";
193
+ }
194
+ async blockReintroducedSources(sourceKeys, now) {
195
+ if (!sourceKeys.length || sourceKeys.some((key) => !key))
196
+ return 0;
197
+ const statements = sourceKeys.map((sourceKey) => this.db.prepare(`UPDATE media_operations SET state = 'blocked_in_use', updated_at = ?
198
+ WHERE state = 'ready' AND EXISTS (SELECT 1 FROM media_operation_entries
199
+ WHERE operation_id = media_operations.operation_id AND source_key = ? AND finalization_state = 'pending')`)
200
+ .bind(now, sourceKey));
201
+ const results = await this.db.batch(statements);
202
+ return results.reduce((count, result) => count + Number(result.meta?.changes ?? 0), 0);
203
+ }
204
+ async cancel(operationId, now) {
205
+ const cancel = this.db.prepare(`UPDATE media_operations SET state = 'cancelled', updated_at = ?
206
+ WHERE operation_id = ? AND state IN ('preparing', 'ready', 'blocked_in_use', 'failed')
207
+ AND NOT EXISTS (SELECT 1 FROM content_release_media_operations WHERE operation_id = ?)`)
208
+ .bind(now, operationId, operationId);
209
+ const entries = this.db.prepare(`UPDATE media_operation_entries SET finalization_state = 'cancelled'
210
+ WHERE operation_id = ? AND EXISTS (SELECT 1 FROM media_operations WHERE operation_id = ? AND state = 'cancelled')`)
211
+ .bind(operationId, operationId);
212
+ const results = await this.db.batch([cancel, entries]);
213
+ if (results[0]?.meta?.changes !== 1)
214
+ throw new Error("media_operation_unavailable");
215
+ }
216
+ }
217
+ const MEDIA_CHECKSUM_METADATA = "adminkit-sha256";
218
+ async function bytesChecksum(bytes) {
219
+ const digest = await crypto.subtle.digest("SHA-256", bytes);
220
+ return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
221
+ }
222
+ /** Copy-first R2 effect. No existing destination is overwritten: an identical
223
+ * prior copy is an idempotent retry, while any opaque collision stops safely. */
224
+ export async function prepareMediaCopy(input) {
225
+ const source = await input.bucket.get(input.sourceKey);
226
+ if (!source)
227
+ throw new Error("media_source_missing");
228
+ const bytes = await source.arrayBuffer();
229
+ const checksum = await bytesChecksum(bytes);
230
+ const destination = await input.bucket.head(input.destinationKey);
231
+ if (destination && destination.customMetadata?.[MEDIA_CHECKSUM_METADATA] !== checksum) {
232
+ throw new Error("media_destination_collision");
233
+ }
234
+ if (!destination) {
235
+ await input.bucket.put(input.destinationKey, bytes, {
236
+ httpMetadata: source.httpMetadata,
237
+ customMetadata: { ...(source.customMetadata ?? {}), [MEDIA_CHECKSUM_METADATA]: checksum },
238
+ });
239
+ }
240
+ const verified = await input.bucket.head(input.destinationKey);
241
+ if (!verified || verified.customMetadata?.[MEDIA_CHECKSUM_METADATA] !== checksum)
242
+ throw new Error("media_copy_unverified");
243
+ return input.journal.recordPreparedEntry({
244
+ operationId: input.operationId, entryId: input.entryId, now: input.now, objectChecksum: checksum,
245
+ etag: verified.etag ?? null,
246
+ metadataJson: canonicalJson({ httpMetadata: source.httpMetadata ?? {}, customMetadata: source.customMetadata ?? {} }),
247
+ });
248
+ }
249
+ /** Cleanup starts only after the release is published. Failures are recorded as
250
+ * safe per-entry warnings, preserving the public source URL for a later retry. */
251
+ export async function finalizeMediaSources(input) {
252
+ await input.journal.beginFinalization(input.operationId, input.releaseId, input.now);
253
+ let completed = 0;
254
+ let failed = 0;
255
+ for (const entry of input.entries) {
256
+ try {
257
+ await input.bucket.delete(entry.sourceKey);
258
+ await input.journal.recordFinalizedEntry({ operationId: input.operationId, entryId: entry.entryId, now: input.now, outcome: "completed" });
259
+ completed += 1;
260
+ }
261
+ catch {
262
+ await input.journal.recordFinalizedEntry({
263
+ operationId: input.operationId, entryId: entry.entryId, now: input.now, outcome: "failed",
264
+ safeError: "La pulizia del Media verrà ritentata.",
265
+ });
266
+ failed += 1;
267
+ }
268
+ }
269
+ return { completed, failed };
270
+ }
@@ -0,0 +1,128 @@
1
+ PRAGMA foreign_keys = ON;
2
+
3
+ CREATE TABLE content_documents (
4
+ document_id TEXT PRIMARY KEY,
5
+ kind TEXT NOT NULL,
6
+ locale TEXT NOT NULL,
7
+ translation_group_id TEXT,
8
+ legacy_key TEXT UNIQUE,
9
+ draft_revision_id TEXT,
10
+ created_at INTEGER NOT NULL,
11
+ updated_at INTEGER NOT NULL,
12
+ abandoned_at INTEGER,
13
+ FOREIGN KEY (document_id, draft_revision_id) REFERENCES content_revisions(document_id, revision_id) DEFERRABLE INITIALLY DEFERRED
14
+ );
15
+ CREATE INDEX content_documents_kind_locale_live ON content_documents(kind, locale, abandoned_at);
16
+ CREATE INDEX content_documents_translation_group ON content_documents(translation_group_id);
17
+
18
+ CREATE TABLE content_revisions (
19
+ revision_id TEXT PRIMARY KEY,
20
+ document_id TEXT NOT NULL REFERENCES content_documents(document_id) ON DELETE RESTRICT,
21
+ base_revision_id TEXT,
22
+ payload_json TEXT,
23
+ is_tombstone INTEGER NOT NULL DEFAULT 0 CHECK (is_tombstone IN (0, 1)),
24
+ schema_version INTEGER NOT NULL CHECK (schema_version >= 1),
25
+ payload_checksum TEXT NOT NULL CHECK (length(payload_checksum) = 64 AND payload_checksum NOT GLOB '*[^0-9a-f]*'),
26
+ request_checksum TEXT NOT NULL CHECK (length(request_checksum) = 64 AND request_checksum NOT GLOB '*[^0-9a-f]*'),
27
+ author_subject TEXT NOT NULL,
28
+ author_label TEXT NOT NULL,
29
+ created_at INTEGER NOT NULL,
30
+ UNIQUE (document_id, revision_id),
31
+ FOREIGN KEY (document_id, base_revision_id) REFERENCES content_revisions(document_id, revision_id) DEFERRABLE INITIALLY DEFERRED,
32
+ CHECK ((payload_json IS NOT NULL AND is_tombstone = 0) OR (payload_json IS NULL AND is_tombstone = 1))
33
+ );
34
+ CREATE INDEX content_revisions_document_created ON content_revisions(document_id, created_at);
35
+ CREATE INDEX content_revisions_payload_checksum ON content_revisions(payload_checksum);
36
+ CREATE TRIGGER content_revisions_immutable BEFORE UPDATE ON content_revisions
37
+ BEGIN SELECT RAISE(ABORT, 'content revisions are immutable'); END;
38
+
39
+ CREATE TABLE content_releases (
40
+ release_id TEXT PRIMARY KEY,
41
+ base_release_id TEXT REFERENCES content_releases(release_id) ON DELETE RESTRICT,
42
+ request_checksum TEXT NOT NULL UNIQUE,
43
+ status TEXT NOT NULL CHECK (status IN ('publishing', 'published', 'failed', 'superseded')),
44
+ active_slot INTEGER UNIQUE CHECK ((status = 'publishing' AND active_slot = 1) OR (status != 'publishing' AND active_slot IS NULL)),
45
+ manifest_checksum TEXT NOT NULL CHECK (length(manifest_checksum) = 64 AND manifest_checksum NOT GLOB '*[^0-9a-f]*'),
46
+ schema_package_version TEXT NOT NULL,
47
+ materializer_version TEXT NOT NULL,
48
+ created_by_subject TEXT NOT NULL,
49
+ created_at INTEGER NOT NULL,
50
+ finished_at INTEGER,
51
+ error_code TEXT,
52
+ error_stage TEXT,
53
+ safe_message TEXT,
54
+ diagnostic_id TEXT
55
+ );
56
+ CREATE TABLE content_release_entries (
57
+ release_id TEXT NOT NULL REFERENCES content_releases(release_id) ON DELETE RESTRICT,
58
+ document_id TEXT NOT NULL,
59
+ revision_id TEXT NOT NULL,
60
+ PRIMARY KEY (release_id, document_id),
61
+ FOREIGN KEY (document_id, revision_id) REFERENCES content_revisions(document_id, revision_id) ON DELETE RESTRICT
62
+ );
63
+ CREATE TRIGGER content_releases_candidate_immutable
64
+ BEFORE UPDATE OF base_release_id, request_checksum, manifest_checksum, schema_package_version, materializer_version, created_by_subject, created_at ON content_releases
65
+ BEGIN SELECT RAISE(ABORT, 'release candidate fields are immutable'); END;
66
+ CREATE TRIGGER content_release_entries_immutable BEFORE UPDATE ON content_release_entries
67
+ BEGIN SELECT RAISE(ABORT, 'release entries are immutable'); END;
68
+ CREATE TABLE content_site_state (
69
+ singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
70
+ current_release_id TEXT REFERENCES content_releases(release_id) ON DELETE RESTRICT,
71
+ current_code_sha TEXT,
72
+ current_pages_deployment_id TEXT,
73
+ current_fingerprint TEXT,
74
+ schema_generation INTEGER NOT NULL,
75
+ updated_at INTEGER NOT NULL
76
+ );
77
+
78
+ CREATE TABLE production_deployment_attempts (
79
+ attempt_id TEXT PRIMARY KEY,
80
+ intent_kind TEXT NOT NULL CHECK (intent_kind IN ('content', 'code')),
81
+ requested_release_id TEXT REFERENCES content_releases(release_id) ON DELETE RESTRICT,
82
+ requested_code_sha TEXT,
83
+ code_sha TEXT,
84
+ release_id TEXT REFERENCES content_releases(release_id) ON DELETE RESTRICT,
85
+ github_run_id TEXT, github_attempt INTEGER,
86
+ pages_project TEXT, pages_deployment_id TEXT, deployment_url TEXT, canonical_hostname TEXT, expected_fingerprint TEXT,
87
+ phase TEXT NOT NULL CHECK (phase IN ('waiting','validating','materializing','building','deploying','verifying_deployment_url','verifying_canonical','succeeded','failed','ambiguous','superseded')),
88
+ error_code TEXT, error_stage TEXT, safe_message TEXT, diagnostic_id TEXT,
89
+ created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, finished_at INTEGER
90
+ );
91
+ CREATE INDEX deployment_attempts_release ON production_deployment_attempts(release_id);
92
+ CREATE INDEX deployment_attempts_phase ON production_deployment_attempts(phase);
93
+
94
+ CREATE TABLE media_operations (
95
+ operation_id TEXT PRIMARY KEY, request_checksum TEXT NOT NULL UNIQUE,
96
+ type TEXT NOT NULL CHECK (type IN ('move','delete')),
97
+ state TEXT NOT NULL CHECK (state IN ('preparing','ready','blocked_in_use','bound','finalizing','completed','completed_with_warning','cancelled','failed')),
98
+ source_prefix TEXT, destination_prefix TEXT, created_by_subject TEXT NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL,
99
+ error_code TEXT, error_stage TEXT, safe_message TEXT, diagnostic_id TEXT, retry_count INTEGER NOT NULL DEFAULT 0
100
+ );
101
+ CREATE TABLE media_operation_entries (
102
+ operation_id TEXT NOT NULL REFERENCES media_operations(operation_id) ON DELETE RESTRICT,
103
+ entry_id TEXT NOT NULL, source_key TEXT NOT NULL, destination_key TEXT, object_checksum TEXT, etag TEXT, metadata_json TEXT,
104
+ preparation_state TEXT NOT NULL, finalization_state TEXT NOT NULL, safe_error TEXT,
105
+ PRIMARY KEY (operation_id, entry_id)
106
+ );
107
+ CREATE UNIQUE INDEX media_operation_entries_active_source ON media_operation_entries(source_key)
108
+ WHERE finalization_state NOT IN ('completed', 'cancelled');
109
+ CREATE TABLE content_release_media_operations (
110
+ release_id TEXT NOT NULL REFERENCES content_releases(release_id) ON DELETE RESTRICT,
111
+ operation_id TEXT NOT NULL REFERENCES media_operations(operation_id) ON DELETE RESTRICT,
112
+ PRIMARY KEY (release_id, operation_id)
113
+ );
114
+
115
+ CREATE TABLE content_import_runs (
116
+ import_id TEXT PRIMARY KEY, request_checksum TEXT NOT NULL UNIQUE, source_main_sha TEXT NOT NULL, source_draft_sha TEXT NOT NULL,
117
+ importer_version TEXT NOT NULL, schema_version TEXT NOT NULL, canonical_checksum TEXT NOT NULL, counts_json TEXT NOT NULL,
118
+ result TEXT NOT NULL, diagnostic_id TEXT, created_at INTEGER NOT NULL
119
+ );
120
+ CREATE TABLE erasure_jobs (
121
+ job_id TEXT PRIMARY KEY, request_checksum TEXT NOT NULL UNIQUE, scope_hash TEXT NOT NULL, confirmations_json TEXT NOT NULL,
122
+ application_erased_at INTEGER, provider_recovery_expiry_not_before INTEGER, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
123
+ );
124
+ CREATE TABLE erasure_job_steps (
125
+ job_id TEXT NOT NULL REFERENCES erasure_jobs(job_id) ON DELETE RESTRICT, step_id TEXT NOT NULL,
126
+ system TEXT NOT NULL, result TEXT NOT NULL, counts_json TEXT NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL,
127
+ PRIMARY KEY (job_id, step_id)
128
+ );
@@ -0,0 +1,6 @@
1
+ ALTER TABLE production_deployment_attempts ADD COLUMN ci_claim_token_checksum TEXT
2
+ CHECK (ci_claim_token_checksum IS NULL OR (length(ci_claim_token_checksum) = 64 AND ci_claim_token_checksum NOT GLOB '*[^0-9a-f]*'));
3
+ ALTER TABLE production_deployment_attempts ADD COLUMN ci_claimed_at INTEGER;
4
+ ALTER TABLE production_deployment_attempts ADD COLUMN ci_claim_expires_at INTEGER;
5
+ CREATE INDEX deployment_attempts_claim_expiry ON production_deployment_attempts(ci_claim_expires_at)
6
+ WHERE ci_claim_expires_at IS NOT NULL;
@@ -0,0 +1,5 @@
1
+ ALTER TABLE production_deployment_attempts ADD COLUMN active_slot INTEGER
2
+ CHECK ((phase IN ('waiting','validating','materializing','building','deploying','verifying_deployment_url','verifying_canonical') AND active_slot = 1)
3
+ OR (phase IN ('succeeded','failed','ambiguous','superseded') AND active_slot IS NULL));
4
+ CREATE UNIQUE INDEX deployment_attempts_one_active_lane ON production_deployment_attempts(active_slot)
5
+ WHERE active_slot = 1;