smoodly 0.0.31 → 0.0.33

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,174 @@
1
+ // packages/smoodly/src/space-transfer.ts
2
+ // The pure half of `smoodly export` / `smoodly import` (spec 2026-09-28
3
+ // export/import): which tables travel, the order they land in, how a row
4
+ // leaves its space and enters another, and the two checks — the export's
5
+ // mirror of the target's foreign keys and the import's "only this
6
+ // export's rows" test. No I/O; the CLI halves are cli/export.ts and
7
+ // cli/import.ts.
8
+ import { CONTENT_TABLES } from "./sql-space.js";
9
+ /** The folder layout's own version, apart from the schema (spec §7). */
10
+ export const FORMAT = 1;
11
+ /** Every foreign key's target lands before the row that names it (spec §9.3). */
12
+ export const INSERT_ORDER = [
13
+ "pages", "page_versions", "page_locales",
14
+ "entries", "entry_versions", "entry_locales",
15
+ "assets", "refs", "paths",
16
+ ];
17
+ /** A row's identity inside one space: its primary key without `space_id`. */
18
+ export const ROW_KEY = {
19
+ pages: ["id"],
20
+ page_locales: ["page_id", "locale"],
21
+ page_versions: ["id"],
22
+ entries: ["id"],
23
+ entry_locales: ["entry_id", "locale"],
24
+ entry_versions: ["id"],
25
+ refs: ["source_kind", "source_id", "source_field", "target_id"],
26
+ paths: ["locale", "path"],
27
+ assets: ["id"],
28
+ };
29
+ /** The primary key as Postgres declares it — what "on conflict do nothing" names. */
30
+ export const CONFLICT_COLUMNS = Object.fromEntries(CONTENT_TABLES.map((t) => [t, (t === "paths" ? ["space_id", ...ROW_KEY[t]] : ROW_KEY[t]).join(",")]));
31
+ export const rowKey = (table, row) => JSON.stringify(ROW_KEY[table].map((c) => row[c]));
32
+ /** Sorted by key, so two exports of an unchanged space diff cleanly (spec §7). */
33
+ export function sortRows(table, rows) {
34
+ return rows
35
+ .map((row) => [rowKey(table, row), row])
36
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
37
+ .map(([, row]) => row);
38
+ }
39
+ export const emptyTables = () => Object.fromEntries(CONTENT_TABLES.map((t) => [t, []]));
40
+ /** A row as it leaves its space: no `space_id`; an asset's path without `<space>/`. */
41
+ export function fromSpace(table, row, space) {
42
+ const { space_id: _space, ...rest } = row;
43
+ if (table !== "assets")
44
+ return rest;
45
+ const path = String(rest.path);
46
+ const prefix = `${space}/`;
47
+ if (!path.startsWith(prefix)) {
48
+ throw new Error(`smoodly: asset ${String(rest.id)} is stored at "${path}", outside its space's prefix "${prefix}".`);
49
+ }
50
+ return { ...rest, path: path.slice(prefix.length) };
51
+ }
52
+ /** A row as it enters a space: `space_id` set; an asset's path prefixed. */
53
+ export function intoSpace(table, row, space) {
54
+ return table === "assets" ? { ...row, space_id: space, path: `${space}/${String(row.path)}` } : { ...row, space_id: space };
55
+ }
56
+ /** Rows whose `column` names another row of the list come after it (parents, posters); a name outside the list counts as none. */
57
+ export function referencedFirst(rows, column) {
58
+ const ids = new Set(rows.map((r) => String(r.id)));
59
+ const placed = new Set();
60
+ const out = [];
61
+ let pending = rows;
62
+ while (pending.length > 0) {
63
+ const next = [];
64
+ for (const row of pending) {
65
+ const ref = row[column];
66
+ if (ref == null || !ids.has(String(ref)) || placed.has(String(ref))) {
67
+ out.push(row);
68
+ placed.add(String(row.id));
69
+ }
70
+ else
71
+ next.push(row);
72
+ }
73
+ if (next.length === pending.length)
74
+ throw new Error(`smoodly: a cycle in ${column} among ${next.length} rows.`);
75
+ pending = next;
76
+ }
77
+ return out;
78
+ }
79
+ /** The tables in insert order, their rows ordered for it (spec §9.3). */
80
+ export function insertPlan(rows) {
81
+ return INSERT_ORDER.map((table) => ({
82
+ table,
83
+ rows: table === "pages" || table === "entries" ? referencedFirst(rows[table], "parent_id")
84
+ : table === "assets" ? referencedFirst(rows[table], "poster_id")
85
+ : rows[table],
86
+ }));
87
+ }
88
+ /** The export's consistency check (spec §8.3): exactly the foreign keys the target enforces, one line per broken reference. */
89
+ export function brokenReferences(rows) {
90
+ const ids = (table) => new Set(rows[table].map((r) => String(r.id)));
91
+ const pages = ids("pages");
92
+ const entries = ids("entries");
93
+ const problems = [];
94
+ const check = (table, column, targets, targetTable) => {
95
+ for (const row of rows[table]) {
96
+ const ref = row[column];
97
+ if (ref != null && !targets.has(String(ref)))
98
+ problems.push(`${table} ${rowKey(table, row)}: ${column} ${String(ref)} is not in ${targetTable}`);
99
+ }
100
+ };
101
+ check("pages", "parent_id", pages, "pages");
102
+ check("page_versions", "page_id", pages, "pages");
103
+ check("page_locales", "page_id", pages, "pages");
104
+ check("page_locales", "draft_version_id", ids("page_versions"), "page_versions");
105
+ check("entries", "parent_id", entries, "entries");
106
+ check("entry_versions", "entry_id", entries, "entries");
107
+ check("entry_locales", "entry_id", entries, "entries");
108
+ check("entry_locales", "draft_version_id", ids("entry_versions"), "entry_versions");
109
+ check("assets", "poster_id", ids("assets"), "assets");
110
+ return problems;
111
+ }
112
+ /** The import's target check (spec §9.1): per table, how many of the target's row keys this export does not hold. All zero = empty, or an earlier run. */
113
+ export function foreignRows(target, exported) {
114
+ return Object.fromEntries(CONTENT_TABLES.map((table) => {
115
+ const own = new Set(exported[table].map((row) => rowKey(table, row)));
116
+ return [table, target[table].filter((key) => !own.has(key)).length];
117
+ }));
118
+ }
119
+ export const countRows = (rows) => Object.fromEntries(CONTENT_TABLES.map((t) => [t, rows[t].length]));
120
+ const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
121
+ const listJoin = (items) => (items.length <= 1 ? items.join("") : `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`);
122
+ const redirectCount = (rows) => rows.paths.filter((r) => r.redirect === true).length;
123
+ export function formatBytes(n) {
124
+ if (n < 1024)
125
+ return `${n} B`;
126
+ const units = ["KB", "MB", "GB", "TB"];
127
+ let v = n / 1024;
128
+ let i = 0;
129
+ while (v >= 1024 && i < units.length - 1) {
130
+ v /= 1024;
131
+ i++;
132
+ }
133
+ return `${v.toFixed(1)} ${units[i]}`;
134
+ }
135
+ /** "14 pages (41 versions), 32 entries (118 versions), 51 assets (38.2 MB), 9 redirects" (spec §8.5). */
136
+ export function describeExport(rows, bytes) {
137
+ return [
138
+ `${plural(rows.pages.length, "page")} (${plural(rows.page_versions.length, "version")})`,
139
+ `${plural(rows.entries.length, "entry", "entries")} (${plural(rows.entry_versions.length, "version")})`,
140
+ `${plural(rows.assets.length, "asset")} (${formatBytes(bytes)})`,
141
+ plural(redirectCount(rows), "redirect"),
142
+ ].join(", ");
143
+ }
144
+ /** "14 pages, 32 entries, 51 assets and 9 redirects" (spec §9.4). */
145
+ export function describeImport(rows) {
146
+ return listJoin([
147
+ plural(rows.pages.length, "page"),
148
+ plural(rows.entries.length, "entry", "entries"),
149
+ plural(rows.assets.length, "asset"),
150
+ plural(redirectCount(rows), "redirect"),
151
+ ]);
152
+ }
153
+ const NOUN = {
154
+ pages: ["page", "pages"],
155
+ page_versions: ["page version", "page versions"],
156
+ page_locales: ["page locale row", "page locale rows"],
157
+ entries: ["entry", "entries"],
158
+ entry_versions: ["entry version", "entry versions"],
159
+ entry_locales: ["entry locale row", "entry locale rows"],
160
+ assets: ["asset", "assets"],
161
+ refs: ["reference row", "reference rows"],
162
+ paths: ["path row", "path rows"],
163
+ };
164
+ /** The target check's refusal (spec §9.1), naming only the tables that hold foreign rows. */
165
+ export function notEmptyMessage(foreign) {
166
+ const parts = INSERT_ORDER.filter((t) => foreign[t] > 0).map((t) => plural(foreign[t], ...NOUN[t]));
167
+ return `The target space holds ${listJoin(parts)} that this export does not. Import needs an empty space: a new Supabase project, or \`supabase db reset\` on a local stack.`;
168
+ }
169
+ /** Spec §6: the target database's version must equal the export's. */
170
+ export function versionMismatchMessage(target, exported) {
171
+ return target < exported
172
+ ? `The target database is at schema v${target} and this export is v${exported}: update smoodly to a version that ships v${exported}, run \`npx smoodly migrate\`, then \`supabase db push\`.`
173
+ : `The target database is at schema v${target}, newer than this export (v${exported}): migrate the source and export again.`;
174
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smoodly",
3
- "version": "0.0.31",
3
+ "version": "0.0.33",
4
4
  "description": "Open-source, code-first CMS for Next.js on Supabase: define a component once, it becomes a typed, editable building block.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,9 +0,0 @@
1
- /** A URL segment from a title: lowercase ASCII letters, digits and
2
- * hyphens (the path grammar, DESIGN.md OQ 24), diacritics stripped
3
- * (ä → a, é → e), everything else a hyphen, no leading, trailing or
4
- * doubled hyphens. */
5
- export declare function slugify(title: string): string;
6
- /** The slug field follows the title until the editor types a slug of
7
- * their own; a slug equal to what the previous title produced is still
8
- * "following". */
9
- export declare function followsTitle(slug: string, previousTitle: string): boolean;
@@ -1,19 +0,0 @@
1
- // Pure helpers for the page dialogs (PageDialogs.tsx).
2
- /** A URL segment from a title: lowercase ASCII letters, digits and
3
- * hyphens (the path grammar, DESIGN.md OQ 24), diacritics stripped
4
- * (ä → a, é → e), everything else a hyphen, no leading, trailing or
5
- * doubled hyphens. */
6
- export function slugify(title) {
7
- return title
8
- .normalize("NFD")
9
- .replace(/[̀-ͯ]/g, "")
10
- .toLowerCase()
11
- .replace(/[^a-z0-9]+/g, "-")
12
- .replace(/^-+|-+$/g, "");
13
- }
14
- /** The slug field follows the title until the editor types a slug of
15
- * their own; a slug equal to what the previous title produced is still
16
- * "following". */
17
- export function followsTitle(slug, previousTitle) {
18
- return slug === "" || slug === slugify(previousTitle);
19
- }