@bygghammar/cms-platform 0.3.0 → 0.4.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.
package/README.md CHANGED
@@ -43,6 +43,11 @@ versions. Keep the dynamic import behind an `import.meta.env.SSR` guard in SSR
43
43
  applications so the browser-only ZIP code cannot enter the optimized server
44
44
  chunk graph.
45
45
 
46
+ Individual media downloads are retried before an export is stopped. Public
47
+ media-bucket adapters should also fall back to the object's public URL when an
48
+ authenticated Storage download fails, so one transient large-file request
49
+ cannot discard an otherwise valid archive.
50
+
46
51
  The consuming site remains responsible for its dataset groups, table import
47
52
  order, Supabase clients, superadmin authorization, HTTP routes, audit records
48
53
  and database migrations. The package never contains site data or credentials.
package/package.json CHANGED
@@ -1,13 +1,12 @@
1
1
  {
2
2
  "name": "@bygghammar/cms-platform",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Shared CMS platform: blocks, block types, i18n core, UI primitives and design-token contract used by every Bygghammar/Voyara site.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
7
7
  "license": "UNLICENSED",
8
8
  "publishConfig": {
9
- "access": "public",
10
- "registry": "https://registry.npmjs.org/"
9
+ "access": "public"
11
10
  },
12
11
  "files": [
13
12
  "src",
@@ -18,9 +17,6 @@
18
17
  "url": "git+https://github.com/byggis79/devsba.git",
19
18
  "directory": "packages/cms-platform"
20
19
  },
21
- "publishConfig": {
22
- "access": "public"
23
- },
24
20
  "exports": {
25
21
  ".": "./src/index.ts",
26
22
  "./i18n": "./src/i18n/index.ts",
@@ -44,7 +40,34 @@
44
40
  "./blocks/registry": "./src/blocks/registry.tsx",
45
41
  "./trips": "./src/trips/index.ts",
46
42
  "./trips/TripSectionEditor": "./src/trips/TripSectionEditor.tsx",
47
- "./navigation": "./src/navigation/index.ts"
43
+ "./navigation": "./src/navigation/index.ts",
44
+ "./forms/conditions": "./src/forms/conditions.ts",
45
+ "./forms/field-sets": "./src/forms/field-sets.ts",
46
+ "./forms/form-audit": "./src/forms/form-audit.ts",
47
+ "./forms/rich-text": "./src/forms/rich-text.tsx",
48
+ "./media/urls": "./src/media/urls.ts",
49
+ "./media/filename": "./src/media/filename.ts",
50
+ "./media/focus": "./src/media/focus.ts",
51
+ "./media/internal-paths": "./src/media/internal-paths.ts",
52
+ "./media/optimize": "./src/media/optimize.ts",
53
+ "./media/srcset": "./src/media/srcset.ts",
54
+ "./media/media-meta": "./src/media/media-meta.ts",
55
+ "./media/review-image": "./src/media/review-image.ts",
56
+ "./media/storage": "./src/media/storage.ts",
57
+ "./media/usage-types": "./src/media/usage-types.ts",
58
+ "./cms/version-diff": "./src/cms/version-diff.ts",
59
+ "./cms/shared-draft-store": "./src/cms/shared-draft-store.ts",
60
+ "./http/cache-policy": "./src/http/cache-policy.ts",
61
+ "./http/compression": "./src/http/compression.ts",
62
+ "./http/response-headers": "./src/http/response-headers.ts",
63
+ "./backup/datasets": "./src/backup/datasets.ts",
64
+ "./backup/export-core": "./src/backup/export-core.ts",
65
+ "./backup/restore-core": "./src/backup/restore-core.ts",
66
+ "./backup/media-export": "./src/backup/media-export.ts",
67
+ "./i18n/json": "./src/i18n/json.ts",
68
+ "./i18n/resolve-locale": "./src/i18n/resolve-locale.ts",
69
+ "./i18n/translation-meta": "./src/i18n/translation-meta.ts",
70
+ "./platform": "./src/platform/index.ts"
48
71
  },
49
72
  "dependencies": {
50
73
  "fflate": "^0.8.3"
@@ -54,6 +77,20 @@
54
77
  "lucide-react": ">=0.5.0",
55
78
  "react": ">=19",
56
79
  "react-dom": ">=19",
57
- "sonner": ">=2"
80
+ "sonner": ">=2",
81
+ "@supabase/supabase-js": ">=2",
82
+ "@tanstack/react-start": ">=1",
83
+ "@tanstack/react-router": ">=1"
84
+ },
85
+ "peerDependenciesMeta": {
86
+ "@supabase/supabase-js": {
87
+ "optional": true
88
+ },
89
+ "@tanstack/react-start": {
90
+ "optional": true
91
+ },
92
+ "@tanstack/react-router": {
93
+ "optional": true
94
+ }
58
95
  }
59
- }
96
+ }
@@ -41,6 +41,23 @@ export type BrowserMediaExportOptions = {
41
41
  onProgress?: (progress: MediaExportProgress) => void;
42
42
  };
43
43
 
44
+ const MEDIA_DOWNLOAD_ATTEMPTS = 3;
45
+
46
+ async function downloadWithRetry(storage: BrowserBackupStorage, path: string): Promise<Uint8Array> {
47
+ let lastError: unknown;
48
+ for (let attempt = 1; attempt <= MEDIA_DOWNLOAD_ATTEMPTS; attempt += 1) {
49
+ try {
50
+ return await storage.download(path);
51
+ } catch (error) {
52
+ lastError = error;
53
+ if (attempt < MEDIA_DOWNLOAD_ATTEMPTS) {
54
+ await new Promise((resolve) => setTimeout(resolve, attempt * 250));
55
+ }
56
+ }
57
+ }
58
+ throw lastError;
59
+ }
60
+
44
61
  function readManifest<M extends BackupArchiveManifest>(
45
62
  files: Record<string, Uint8Array>,
46
63
  expectedFormat: number | string,
@@ -149,7 +166,7 @@ export async function buildBrowserMediaArchive<M extends BackupArchiveManifest>(
149
166
  for (const file of candidates) {
150
167
  let data: Uint8Array;
151
168
  try {
152
- data = await storage.download(file.name);
169
+ data = await downloadWithRetry(storage, file.name);
153
170
  } catch (error) {
154
171
  throw new Error(
155
172
  `Could not download media file “${file.name}”: ${
@@ -0,0 +1,249 @@
1
+ /**
2
+ * Client-safe description of what a backup archive can contain.
3
+ *
4
+ * Used by both the backoffice UI and the server-side export/import engine so
5
+ * the two can never drift apart.
6
+ */
7
+
8
+ /**
9
+ * Archive layout version. Format 2 adds accounts, email configuration,
10
+ * verified media and secret sanitising. Format 1 archives are rejected.
11
+ */
12
+ export const ARCHIVE_FORMAT_VERSION = 2;
13
+ /** Bump when the database schema changes in a way that breaks old archives. */
14
+ export const SCHEMA_VERSION = 2;
15
+
16
+ export {
17
+ ALLOWED_MEDIA_EXTENSIONS,
18
+ isAllowedMediaFile,
19
+ hasAllowedMediaExtension,
20
+ verifyMediaFile,
21
+ isSafeArchivePath,
22
+ sanitizeSecrets,
23
+ rewriteMediaUrls,
24
+ neutralizeInjection,
25
+ } from "./safety";
26
+
27
+ export type TableSpec = {
28
+ table: string;
29
+ /** Primary key column(s) used for upserts. */
30
+ pk: string;
31
+ /**
32
+ * Conflict target for upserts. Defaults to `pk`, but tables that carry a
33
+ * business key (pages.key, forms.key, …) must conflict on that instead, or
34
+ * archives with different UUIDs collide on the unique index.
35
+ */
36
+ conflict?: string;
37
+ /**
38
+ * Columns that reference rows created later in the import order.
39
+ * They are stripped on the first pass and written in a second pass.
40
+ */
41
+ deferred?: string[];
42
+ /** Tables that must have imported cleanly before this one is attempted. */
43
+ requires?: string[];
44
+ /** Credential-like values in these JSON columns are stripped on export. */
45
+ sanitize?: string[];
46
+ /**
47
+ * Nullable foreign keys checked against the archive itself. A non-null value
48
+ * whose parent row is absent fails analysis before anything is restored.
49
+ */
50
+ references?: { column: string; table: string; pk?: string }[];
51
+ };
52
+
53
+ /** Business keys used to detect that an archive comes from another database. */
54
+ export const LINEAGE_KEYS: Record<string, string> = {
55
+ pages: "key",
56
+ global_sections: "key",
57
+ forms: "key",
58
+ content_sections: "key",
59
+ page_seo: "page_key",
60
+ redirects: "from_path",
61
+ locale_domains: "locale",
62
+ };
63
+
64
+ export type DatasetGroup = {
65
+ id: string;
66
+ label: string;
67
+ description: string;
68
+ tables: TableSpec[];
69
+ /** Rows are only ever inserted — never updated or deleted. */
70
+ insertOnly?: boolean;
71
+ /** Special payloads that are not plain tables. */
72
+ special?: "mediaFiles" | "authUsers";
73
+ /** Not selected when the user picks "recommended". */
74
+ heavy?: boolean;
75
+ };
76
+
77
+ export const DATASET_GROUPS: DatasetGroup[] = [
78
+ {
79
+ id: "content",
80
+ label: "Content & pages",
81
+ description:
82
+ "Pages, page versions, publications, sections, global sections and block templates.",
83
+ tables: [
84
+ { table: "pages", pk: "id", conflict: "key", deferred: ["current_version_id"] },
85
+ { table: "page_versions", pk: "id", conflict: "page_id,version_no", requires: ["pages"] },
86
+ { table: "page_publications", pk: "id", requires: ["pages", "page_versions"] },
87
+ { table: "content_sections", pk: "key", conflict: "key" },
88
+ { table: "global_sections", pk: "id", conflict: "key", deferred: ["current_version_id"] },
89
+ {
90
+ table: "global_section_versions",
91
+ pk: "id",
92
+ conflict: "section_id,version_no",
93
+ requires: ["global_sections"],
94
+ },
95
+ { table: "block_templates", pk: "id" },
96
+ ],
97
+ },
98
+ {
99
+ id: "seo",
100
+ label: "SEO & routing",
101
+ description: "Page SEO, redirects, language domains and tracking/code injections.",
102
+ tables: [
103
+ { table: "page_seo", pk: "page_key", conflict: "page_key" },
104
+ { table: "redirects", pk: "id", conflict: "from_path" },
105
+ { table: "locale_domains", pk: "locale", conflict: "locale" },
106
+ { table: "site_injections", pk: "id" },
107
+ ],
108
+ },
109
+ {
110
+ id: "forms",
111
+ label: "Forms",
112
+ description: "Form definitions, their version history and global form settings.",
113
+ tables: [
114
+ { table: "forms", pk: "id", conflict: "key" },
115
+ { table: "form_versions", pk: "id", conflict: "form_key,version_no", requires: ["forms"] },
116
+ { table: "form_settings", pk: "id" },
117
+ ],
118
+ },
119
+
120
+ {
121
+ id: "customer",
122
+ label: "Customer data",
123
+ description:
124
+ "Form submissions, partial submissions and delivery attempts. Always insert-only — never overwritten or deleted.",
125
+ insertOnly: true,
126
+ tables: [
127
+ { table: "form_submissions", pk: "id", requires: ["forms"] },
128
+ { table: "form_partials", pk: "id", conflict: "form_key,client_token", requires: ["forms"] },
129
+ {
130
+ table: "form_delivery_attempts",
131
+ pk: "id",
132
+ requires: ["forms", "form_submissions"],
133
+ },
134
+ ],
135
+ },
136
+
137
+ {
138
+ id: "mediaMeta",
139
+ label: "Media metadata",
140
+ description: "Alt texts, titles, descriptions, focus points and the media bin index.",
141
+ tables: [
142
+ { table: "media_meta", pk: "path" },
143
+ { table: "media_focus", pk: "path" },
144
+ { table: "media_trash", pk: "id" },
145
+ ],
146
+ },
147
+ {
148
+ id: "mediaFiles",
149
+ label: "Media files",
150
+ description: "The actual images and videos. This is by far the largest part of an archive.",
151
+ special: "mediaFiles",
152
+ heavy: true,
153
+ tables: [],
154
+ },
155
+ {
156
+ id: "users",
157
+ label: "Users & roles",
158
+ description:
159
+ "Accounts and their role assignments. Passwords can never be exported — restored users must reset theirs.",
160
+ special: "authUsers",
161
+ tables: [
162
+ { table: "app_users", pk: "id" },
163
+ { table: "user_roles", pk: "id", conflict: "user_id,role", requires: ["app_users"] },
164
+ ],
165
+ },
166
+ {
167
+ id: "email",
168
+ label: "Email configuration",
169
+ description:
170
+ "Sending providers and global email settings. Only the names of secrets are stored — never their values.",
171
+ tables: [
172
+ { table: "email_providers", pk: "id", sanitize: ["config", "notes"] },
173
+ {
174
+ table: "email_settings",
175
+ pk: "id",
176
+ requires: ["email_providers"],
177
+ sanitize: ["*"],
178
+ references: [
179
+ { column: "default_provider_id", table: "email_providers" },
180
+ { column: "fallback_provider_id", table: "email_providers" },
181
+ ],
182
+ },
183
+ ],
184
+ },
185
+ {
186
+ id: "logs",
187
+ label: "Logs",
188
+ description: "Audit log and outgoing email history.",
189
+ insertOnly: true,
190
+ heavy: true,
191
+ tables: [
192
+ { table: "audit_logs", pk: "id" },
193
+ { table: "email_logs", pk: "id" },
194
+ ],
195
+ },
196
+ ];
197
+
198
+ export const ALL_GROUP_IDS = DATASET_GROUPS.map((g) => g.id);
199
+ export const DEFAULT_GROUP_IDS = DATASET_GROUPS.filter((g) => !g.heavy).map((g) => g.id);
200
+
201
+ export function groupById(id: string): DatasetGroup | undefined {
202
+ return DATASET_GROUPS.find((g) => g.id === id);
203
+ }
204
+
205
+ /** Import order across all groups, respecting foreign keys. */
206
+ export const IMPORT_ORDER: string[] = [
207
+ "app_users",
208
+ "user_roles",
209
+ "pages",
210
+ "page_versions",
211
+ "page_publications",
212
+ "global_sections",
213
+ "global_section_versions",
214
+ "content_sections",
215
+ "block_templates",
216
+ "page_seo",
217
+ "redirects",
218
+ "locale_domains",
219
+ "site_injections",
220
+ "email_providers",
221
+ "email_settings",
222
+ "forms",
223
+ "form_versions",
224
+ "form_settings",
225
+ "form_submissions",
226
+ "form_partials",
227
+ "form_delivery_attempts",
228
+ "media_meta",
229
+ "media_focus",
230
+ "media_trash",
231
+ "audit_logs",
232
+ "email_logs",
233
+ ];
234
+
235
+ export type ArchiveManifest = {
236
+ format: number;
237
+ schemaVersion: number;
238
+ createdAt: string;
239
+ createdBy: string | null;
240
+ site: string | null;
241
+ groups: string[];
242
+ counts: Record<string, number>;
243
+ mediaFiles: number;
244
+ mediaBytes: number;
245
+ /** Files found in the library but excluded because they are not media. */
246
+ quarantinedMedia?: string[];
247
+ };
248
+
249
+ export type ImportMode = "merge" | "replace";