smoodly 0.0.30 → 0.0.32
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 +38 -0
- package/dist/cli/export.d.ts +14 -0
- package/dist/cli/export.js +96 -0
- package/dist/cli/import.d.ts +19 -0
- package/dist/cli/import.js +93 -0
- package/dist/cli/main.d.ts +9 -1
- package/dist/cli/main.js +42 -11
- package/dist/cli/space-io.d.ts +33 -0
- package/dist/cli/space-io.js +77 -0
- package/dist/space-transfer.d.ts +58 -0
- package/dist/space-transfer.js +174 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -587,6 +587,20 @@ Implemented:
|
|
|
587
587
|
but `smoodly/admin/next` reaches no client module except those two),
|
|
588
588
|
`examples/site/e2e/site-bundle.spec.ts` (CI only: production chunks).
|
|
589
589
|
|
|
590
|
+
- Export and import (spec 2026-09-28 export/import): `npx smoodly export
|
|
591
|
+
[--out <dir>]` writes the key's space — the nine content tables with
|
|
592
|
+
history, the member list, the ready assets' files — into a folder
|
|
593
|
+
(`manifest.json`, `rows/<table>.json` without `space_id`, `media/`
|
|
594
|
+
without the space prefix), written as `.partial` and renamed last; it
|
|
595
|
+
refuses a publishable key (the `smoodly_meta` read), a database behind
|
|
596
|
+
the package, and a snapshot whose foreign keys do not close ("content
|
|
597
|
+
changed during export"). `npx smoodly import <dir>` writes it into a
|
|
598
|
+
space that is empty or holds only this export's rows, schema versions
|
|
599
|
+
equal: media first, rows in reference order with on-conflict-do-nothing,
|
|
600
|
+
so a stopped run finishes by running it again. Tests: `space-transfer`,
|
|
601
|
+
`cli-transfer`, `space-transfer.supabase`,
|
|
602
|
+
`examples/site/e2e/transfer.spec.ts`.
|
|
603
|
+
|
|
590
604
|
Not yet: canvas drag-and-drop (list fields, the outline, manual
|
|
591
605
|
collections and the pages tree drag since 2026-09-09 — spec
|
|
592
606
|
`docs/superpowers/specs/2026-09-09-drag-and-drop-design.md`), undo/redo, sample/placeholder content
|
|
@@ -613,6 +627,30 @@ after both are on the registry. From the repo root:
|
|
|
613
627
|
`prepublishOnly` rebuilds each package. The Claude Code skill
|
|
614
628
|
`.claude/skills/releasing-smoodly` carries the same order with its checks.
|
|
615
629
|
|
|
630
|
+
## Follow-ups (logged 2026-09-28, export/import spec)
|
|
631
|
+
|
|
632
|
+
- `import --replace`: delete the space's content and media first, after
|
|
633
|
+
typing the space to confirm — a backup restored over a live project.
|
|
634
|
+
Mention it in the not-empty refusal when it lands.
|
|
635
|
+
- A copy inside one database with fresh IDs — the cloud's "duplicate
|
|
636
|
+
environment", staging → production; needs the reference walk (parent
|
|
637
|
+
links, version pointers, refs, paths, asset ids in trees, fields and
|
|
638
|
+
richtext) the eject avoids by keeping IDs.
|
|
639
|
+
- A readable export for leaving Smoodly: one document per page or entry,
|
|
640
|
+
fields resolved, no internal IDs.
|
|
641
|
+
- A "Download backup" button in the admin: the same export behind an op,
|
|
642
|
+
zipped for an editor.
|
|
643
|
+
- A ready asset whose `poster_id` names a pending asset would fail the
|
|
644
|
+
export's consistency check on every run (pending assets are not
|
|
645
|
+
exported). Unverified whether the upload flow can leave that state;
|
|
646
|
+
if it can, export the poster or null the pointer with a warning.
|
|
647
|
+
- Import cannot expire the app's cache tags; it prints a restart note. A
|
|
648
|
+
revalidate call through the app (an op the CLI could reach) would make
|
|
649
|
+
the note unnecessary.
|
|
650
|
+
- A resumed import that lands nothing still prints the full "Imported …"
|
|
651
|
+
summary and the member list — accurate (the space holds all of it) but
|
|
652
|
+
noisy; count what actually landed and say "nothing new" when it is zero.
|
|
653
|
+
|
|
616
654
|
## Follow-ups (logged 2026-09-27, shadowed-page detector spec)
|
|
617
655
|
|
|
618
656
|
- A section's crash on a live page after data changed outside the page
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type SpaceConnection } from "./space-io.ts";
|
|
2
|
+
export type ExportArgs = {
|
|
3
|
+
out: string | null;
|
|
4
|
+
};
|
|
5
|
+
export type ExportDeps = {
|
|
6
|
+
cwd?: string;
|
|
7
|
+
connection?: SpaceConnection;
|
|
8
|
+
now?: () => Date;
|
|
9
|
+
};
|
|
10
|
+
export declare const defaultExportDir: (now: Date) => string;
|
|
11
|
+
export declare const folderExists: (dir: string) => string;
|
|
12
|
+
export declare const CHANGED_DURING_EXPORT = "Content changed during export \u2014 run it again.";
|
|
13
|
+
export declare const missingFile: (path: string) => string;
|
|
14
|
+
export declare function runExport(args: ExportArgs, out: (line: string) => void, deps?: ExportDeps): Promise<number>;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// packages/smoodly/src/cli/export.ts
|
|
2
|
+
// `smoodly export` (spec 2026-09-28 export/import §8): the key's space —
|
|
3
|
+
// every content row, history included, and the media files — into a
|
|
4
|
+
// folder. Reads only; the folder appears whole or not at all (written as
|
|
5
|
+
// <dir>.partial, renamed last). The message strings are what the guide
|
|
6
|
+
// quotes.
|
|
7
|
+
import { existsSync } from "node:fs";
|
|
8
|
+
import { mkdir, rename, rm, writeFile } from "node:fs/promises";
|
|
9
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
10
|
+
import { schemaBehind, schemaBehindMessage } from "../schema-version.js";
|
|
11
|
+
import { CONTENT_TABLES } from "../sql-space.js";
|
|
12
|
+
import { FORMAT, brokenReferences, countRows, describeExport, emptyTables, fromSpace, sortRows, } from "../space-transfer.js";
|
|
13
|
+
import { MEDIA_BUCKET, connectFromEnv, isNotFound, packageVersion, readSchemaVersion, readSpaceRows, schemaRefusal, } from "./space-io.js";
|
|
14
|
+
export const defaultExportDir = (now) => `smoodly-export-${now.toISOString().slice(0, 10)}`;
|
|
15
|
+
export const folderExists = (dir) => `Refusing: ${dir} already exists. Remove it, or pass --out <dir>.`;
|
|
16
|
+
export const CHANGED_DURING_EXPORT = "Content changed during export — run it again.";
|
|
17
|
+
export const missingFile = (path) => `Warning: ${path} is missing from the media bucket (already broken on the site); its asset row is exported without a file.`;
|
|
18
|
+
export async function runExport(args, out, deps = {}) {
|
|
19
|
+
const cwd = deps.cwd ?? process.cwd();
|
|
20
|
+
const now = (deps.now ?? (() => new Date()))();
|
|
21
|
+
const dir = resolve(cwd, args.out ?? defaultExportDir(now));
|
|
22
|
+
// Relative when inside the app's folder, absolute when --out points elsewhere.
|
|
23
|
+
const rel = relative(cwd, dir);
|
|
24
|
+
const shown = rel && !rel.startsWith("..") && !isAbsolute(rel) ? rel : dir;
|
|
25
|
+
if (existsSync(dir)) {
|
|
26
|
+
out(folderExists(shown));
|
|
27
|
+
return 1;
|
|
28
|
+
}
|
|
29
|
+
const { db, space } = deps.connection ?? connectFromEnv(cwd);
|
|
30
|
+
const schema = await readSchemaVersion(db);
|
|
31
|
+
if ("missing" in schema) {
|
|
32
|
+
out(schemaRefusal(schema.missing, "Export"));
|
|
33
|
+
return 1;
|
|
34
|
+
}
|
|
35
|
+
if (schemaBehind(schema.version)) {
|
|
36
|
+
out(schemaBehindMessage(schema.version));
|
|
37
|
+
return 1;
|
|
38
|
+
}
|
|
39
|
+
const rows = emptyTables();
|
|
40
|
+
for (const table of CONTENT_TABLES) {
|
|
41
|
+
const read = await readSpaceRows(db, table, space, table === "assets" ? { status: "ready" } : {});
|
|
42
|
+
rows[table] = sortRows(table, read.map((row) => fromSpace(table, row, space)));
|
|
43
|
+
}
|
|
44
|
+
const broken = brokenReferences(rows);
|
|
45
|
+
if (broken.length > 0) {
|
|
46
|
+
out(CHANGED_DURING_EXPORT);
|
|
47
|
+
for (const line of broken.slice(0, 10))
|
|
48
|
+
out(` ${line}`);
|
|
49
|
+
return 1;
|
|
50
|
+
}
|
|
51
|
+
const members = await db.from("members").select("email, role").eq("space_id", space).order("email");
|
|
52
|
+
if (members.error)
|
|
53
|
+
throw new Error(`smoodly: reading members: ${members.error.message}`);
|
|
54
|
+
const partial = `${dir}.partial`;
|
|
55
|
+
await rm(partial, { recursive: true, force: true });
|
|
56
|
+
await mkdir(join(partial, "rows"), { recursive: true });
|
|
57
|
+
await mkdir(join(partial, "media"), { recursive: true });
|
|
58
|
+
const missing = [];
|
|
59
|
+
let files = 0;
|
|
60
|
+
let bytes = 0;
|
|
61
|
+
for (const asset of rows.assets) {
|
|
62
|
+
const path = String(asset.path);
|
|
63
|
+
const got = await db.storage.from(MEDIA_BUCKET).download(`${space}/${path}`);
|
|
64
|
+
if (got.error) {
|
|
65
|
+
if (!isNotFound(got.error))
|
|
66
|
+
throw new Error(`smoodly: downloading ${path}: ${got.error.message}`);
|
|
67
|
+
missing.push(path);
|
|
68
|
+
out(missingFile(path));
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
const data = new Uint8Array(await got.data.arrayBuffer());
|
|
72
|
+
const file = join(partial, "media", path);
|
|
73
|
+
await mkdir(dirname(file), { recursive: true });
|
|
74
|
+
await writeFile(file, data);
|
|
75
|
+
files++;
|
|
76
|
+
bytes += data.byteLength;
|
|
77
|
+
}
|
|
78
|
+
for (const table of CONTENT_TABLES) {
|
|
79
|
+
await writeFile(join(partial, "rows", `${table}.json`), `${JSON.stringify(rows[table], null, 2)}\n`);
|
|
80
|
+
}
|
|
81
|
+
const manifest = {
|
|
82
|
+
format: FORMAT,
|
|
83
|
+
schemaVersion: schema.version,
|
|
84
|
+
smoodlyVersion: packageVersion(),
|
|
85
|
+
exportedAt: now.toISOString(),
|
|
86
|
+
sourceSpace: space,
|
|
87
|
+
counts: countRows(rows),
|
|
88
|
+
media: { files, bytes, missing },
|
|
89
|
+
members: (members.data ?? []),
|
|
90
|
+
};
|
|
91
|
+
await writeFile(join(partial, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
92
|
+
await rename(partial, dir);
|
|
93
|
+
out(`Exported space ${space}: ${describeExport(rows, bytes)}`);
|
|
94
|
+
out(`→ ${shown}/`);
|
|
95
|
+
return 0;
|
|
96
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type ContentTable } from "../sql-space.ts";
|
|
2
|
+
import { type SpaceConnection } from "./space-io.ts";
|
|
3
|
+
export type ImportArgs = {
|
|
4
|
+
dir: string;
|
|
5
|
+
};
|
|
6
|
+
export type ImportDeps = {
|
|
7
|
+
cwd?: string;
|
|
8
|
+
connection?: SpaceConnection;
|
|
9
|
+
batchSize?: number;
|
|
10
|
+
/** Test seam: runs before each batch; a throw stops the import there. */
|
|
11
|
+
beforeBatch?: (table: ContentTable, index: number) => void;
|
|
12
|
+
};
|
|
13
|
+
export declare const BATCH_ROWS = 500;
|
|
14
|
+
export declare const notAnExport: (dir: string) => string;
|
|
15
|
+
export declare const formatMismatch: (format: unknown) => string;
|
|
16
|
+
export declare const RUN_AGAIN = "Run the same command again to continue: rows and files that landed are skipped.";
|
|
17
|
+
export declare const MEMBERS_HEADING = "Members in the source \u2014 invite them in Settings \u203A Members:";
|
|
18
|
+
export declare const CACHE_NOTE = "Restart or redeploy the app: it may have cached pages from while the space was empty.";
|
|
19
|
+
export declare function runImport(args: ImportArgs, out: (line: string) => void, deps?: ImportDeps): Promise<number>;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// packages/smoodly/src/cli/import.ts
|
|
2
|
+
// `smoodly import <dir>` (spec 2026-09-28 export/import §9–§10): an
|
|
3
|
+
// export folder into the key's space. Every check runs before the first
|
|
4
|
+
// write; nothing is overwritten or deleted; a run that stops midway is
|
|
5
|
+
// finished by running it again, since the target may hold only this
|
|
6
|
+
// export's rows and both writes skip what already landed.
|
|
7
|
+
import { existsSync } from "node:fs";
|
|
8
|
+
import { readFile } from "node:fs/promises";
|
|
9
|
+
import { join, resolve } from "node:path";
|
|
10
|
+
import { MEDIA_CACHE_SECONDS } from "../media.js";
|
|
11
|
+
import { CONTENT_TABLES } from "../sql-space.js";
|
|
12
|
+
import { CONFLICT_COLUMNS, FORMAT, ROW_KEY, describeImport, emptyTables, foreignRows, insertPlan, intoSpace, notEmptyMessage, rowKey, versionMismatchMessage, } from "../space-transfer.js";
|
|
13
|
+
import { MEDIA_BUCKET, connectFromEnv, isAlreadyExists, readSchemaVersion, readSpaceRows, schemaRefusal, } from "./space-io.js";
|
|
14
|
+
export const BATCH_ROWS = 500;
|
|
15
|
+
export const notAnExport = (dir) => `Refusing: ${dir} is not a Smoodly export (no manifest.json).`;
|
|
16
|
+
export const formatMismatch = (format) => `Refusing: this export's format is ${String(format)}; this smoodly reads format ${FORMAT}. Update smoodly.`;
|
|
17
|
+
export const RUN_AGAIN = "Run the same command again to continue: rows and files that landed are skipped.";
|
|
18
|
+
export const MEMBERS_HEADING = "Members in the source — invite them in Settings › Members:";
|
|
19
|
+
export const CACHE_NOTE = "Restart or redeploy the app: it may have cached pages from while the space was empty.";
|
|
20
|
+
export async function runImport(args, out, deps = {}) {
|
|
21
|
+
const cwd = deps.cwd ?? process.cwd();
|
|
22
|
+
const dir = resolve(cwd, args.dir);
|
|
23
|
+
const manifestFile = join(dir, "manifest.json");
|
|
24
|
+
if (!existsSync(manifestFile)) {
|
|
25
|
+
out(notAnExport(args.dir));
|
|
26
|
+
return 1;
|
|
27
|
+
}
|
|
28
|
+
const manifest = JSON.parse(await readFile(manifestFile, "utf8"));
|
|
29
|
+
if (manifest.format !== FORMAT) {
|
|
30
|
+
out(formatMismatch(manifest.format));
|
|
31
|
+
return 1;
|
|
32
|
+
}
|
|
33
|
+
const { db, space } = deps.connection ?? connectFromEnv(cwd);
|
|
34
|
+
const schema = await readSchemaVersion(db);
|
|
35
|
+
if ("missing" in schema) {
|
|
36
|
+
out(schemaRefusal(schema.missing, "Import"));
|
|
37
|
+
return 1;
|
|
38
|
+
}
|
|
39
|
+
if (schema.version !== manifest.schemaVersion) {
|
|
40
|
+
out(versionMismatchMessage(schema.version, manifest.schemaVersion));
|
|
41
|
+
return 1;
|
|
42
|
+
}
|
|
43
|
+
const rows = emptyTables();
|
|
44
|
+
for (const table of CONTENT_TABLES)
|
|
45
|
+
rows[table] = JSON.parse(await readFile(join(dir, "rows", `${table}.json`), "utf8"));
|
|
46
|
+
const target = {};
|
|
47
|
+
for (const table of CONTENT_TABLES) {
|
|
48
|
+
target[table] = (await readSpaceRows(db, table, space, {}, ROW_KEY[table].join(","))).map((row) => rowKey(table, row));
|
|
49
|
+
}
|
|
50
|
+
const foreign = foreignRows(target, rows);
|
|
51
|
+
if (CONTENT_TABLES.some((t) => foreign[t] > 0)) {
|
|
52
|
+
out(notEmptyMessage(foreign));
|
|
53
|
+
return 1;
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
// Media first, so no ready asset row ever points at a missing file.
|
|
57
|
+
for (const asset of rows.assets) {
|
|
58
|
+
const path = String(asset.path);
|
|
59
|
+
const file = join(dir, "media", path);
|
|
60
|
+
if (!existsSync(file))
|
|
61
|
+
continue; // missing in the source as well (manifest.media.missing)
|
|
62
|
+
const up = await db.storage.from(MEDIA_BUCKET).upload(`${space}/${path}`, await readFile(file), {
|
|
63
|
+
contentType: String(asset.mime),
|
|
64
|
+
upsert: false,
|
|
65
|
+
cacheControl: String(MEDIA_CACHE_SECONDS),
|
|
66
|
+
});
|
|
67
|
+
if (up.error && !isAlreadyExists(up.error))
|
|
68
|
+
throw new Error(`uploading ${path}: ${up.error.message}`);
|
|
69
|
+
}
|
|
70
|
+
const size = deps.batchSize ?? BATCH_ROWS;
|
|
71
|
+
for (const { table, rows: list } of insertPlan(rows)) {
|
|
72
|
+
for (let i = 0; i * size < list.length; i++) {
|
|
73
|
+
deps.beforeBatch?.(table, i);
|
|
74
|
+
const batch = list.slice(i * size, (i + 1) * size).map((row) => intoSpace(table, row, space));
|
|
75
|
+
const res = await db.from(table).upsert(batch, { onConflict: CONFLICT_COLUMNS[table], ignoreDuplicates: true });
|
|
76
|
+
if (res.error)
|
|
77
|
+
throw new Error(`inserting ${table}: ${res.error.message}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
catch (e) {
|
|
82
|
+
out(`Import stopped: ${e instanceof Error ? e.message : String(e)}`);
|
|
83
|
+
out(RUN_AGAIN);
|
|
84
|
+
return 1;
|
|
85
|
+
}
|
|
86
|
+
out(`Imported ${describeImport(rows)} into space ${space}.`);
|
|
87
|
+
if (manifest.members.length > 0) {
|
|
88
|
+
out(MEMBERS_HEADING);
|
|
89
|
+
out(` ${manifest.members.map((m) => `${m.email ?? "(no email)"} (${m.role})`).join(", ")}`);
|
|
90
|
+
}
|
|
91
|
+
out(CACHE_NOTE);
|
|
92
|
+
return 0;
|
|
93
|
+
}
|
package/dist/cli/main.d.ts
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
import { type MigrateArgs } from "./migrate.ts";
|
|
2
|
-
|
|
2
|
+
import { type ExportArgs } from "./export.ts";
|
|
3
|
+
import { type ImportArgs } from "./import.ts";
|
|
4
|
+
export declare const USAGE = "usage: smoodly <command>\n\n migrate [--check] [--collections <file>] [--migrations <dir>]\n Copies the core schema files this package ships that supabase/migrations/\n lacks, and writes one views file for the collections whose view is new or\n changed. Prints the push or reset command; never runs it.\n\n --check write nothing; exit 1 when anything is pending\n --collections <file> the module exporting `collections` (default smoodly/collections.ts)\n --migrations <dir> the app's migration folder (default supabase/migrations)\n\n export [--out <dir>]\n Writes the space SMOODLY_SECRET_KEY names \u2014 every content row, history\n included, and the media files \u2014 into a folder (default\n smoodly-export-<date>). Reads only.\n\n import <dir>\n Writes an export folder into the key's space, which must be empty or hold\n only this export's rows (an earlier run to continue). Never deletes.";
|
|
3
5
|
export type Parsed = {
|
|
4
6
|
command: "migrate";
|
|
5
7
|
args: MigrateArgs;
|
|
8
|
+
} | {
|
|
9
|
+
command: "export";
|
|
10
|
+
args: ExportArgs;
|
|
11
|
+
} | {
|
|
12
|
+
command: "import";
|
|
13
|
+
args: ImportArgs;
|
|
6
14
|
} | {
|
|
7
15
|
command: "help";
|
|
8
16
|
} | {
|
package/dist/cli/main.js
CHANGED
|
@@ -1,24 +1,51 @@
|
|
|
1
1
|
// packages/smoodly/src/cli/main.ts
|
|
2
|
-
// `smoodly` — the package's command line
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// refused file, 2 usage.
|
|
2
|
+
// `smoodly` — the package's command line. Three subcommands: migrate
|
|
3
|
+
// (spec 2026-09-26 §2), export and import (spec 2026-09-28). Exit codes:
|
|
4
|
+
// 0 done or nothing to do, 1 pending (--check), refused or failed, 2 usage.
|
|
6
5
|
import { resolve } from "node:path";
|
|
7
6
|
import { fileURLToPath } from "node:url";
|
|
8
7
|
import { runMigrate } from "./migrate.js";
|
|
9
|
-
|
|
8
|
+
import { runExport } from "./export.js";
|
|
9
|
+
import { runImport } from "./import.js";
|
|
10
|
+
export const USAGE = `usage: smoodly <command>
|
|
10
11
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
migrate [--check] [--collections <file>] [--migrations <dir>]
|
|
13
|
+
Copies the core schema files this package ships that supabase/migrations/
|
|
14
|
+
lacks, and writes one views file for the collections whose view is new or
|
|
15
|
+
changed. Prints the push or reset command; never runs it.
|
|
14
16
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
--check write nothing; exit 1 when anything is pending
|
|
18
|
+
--collections <file> the module exporting \`collections\` (default smoodly/collections.ts)
|
|
19
|
+
--migrations <dir> the app's migration folder (default supabase/migrations)
|
|
20
|
+
|
|
21
|
+
export [--out <dir>]
|
|
22
|
+
Writes the space SMOODLY_SECRET_KEY names — every content row, history
|
|
23
|
+
included, and the media files — into a folder (default
|
|
24
|
+
smoodly-export-<date>). Reads only.
|
|
25
|
+
|
|
26
|
+
import <dir>
|
|
27
|
+
Writes an export folder into the key's space, which must be empty or hold
|
|
28
|
+
only this export's rows (an earlier run to continue). Never deletes.`;
|
|
18
29
|
export function parseArgs(argv) {
|
|
19
30
|
const [command, ...rest] = argv;
|
|
20
31
|
if (!command || command === "--help" || command === "-h" || command === "help")
|
|
21
32
|
return { command: "help" };
|
|
33
|
+
if (command === "export") {
|
|
34
|
+
const args = { out: null };
|
|
35
|
+
for (let i = 0; i < rest.length; i++) {
|
|
36
|
+
const a = rest[i];
|
|
37
|
+
if (a === "--out" && rest[i + 1] !== undefined)
|
|
38
|
+
args.out = rest[++i];
|
|
39
|
+
else
|
|
40
|
+
return { command: "error", message: `unexpected argument "${a}"` };
|
|
41
|
+
}
|
|
42
|
+
return { command: "export", args };
|
|
43
|
+
}
|
|
44
|
+
if (command === "import") {
|
|
45
|
+
if (rest.length !== 1 || rest[0].startsWith("-"))
|
|
46
|
+
return { command: "error", message: "import needs one export folder: smoodly import <dir>" };
|
|
47
|
+
return { command: "import", args: { dir: rest[0] } };
|
|
48
|
+
}
|
|
22
49
|
if (command !== "migrate")
|
|
23
50
|
return { command: "error", message: `unknown command "${command}"` };
|
|
24
51
|
const args = { check: false, collections: "smoodly/collections.ts", migrations: "supabase/migrations" };
|
|
@@ -46,6 +73,10 @@ export async function main(argv, out = console.log) {
|
|
|
46
73
|
return 2;
|
|
47
74
|
}
|
|
48
75
|
try {
|
|
76
|
+
if (parsed.command === "import")
|
|
77
|
+
return await runImport(parsed.args, out);
|
|
78
|
+
if (parsed.command === "export")
|
|
79
|
+
return await runExport(parsed.args, out);
|
|
49
80
|
return await runMigrate(parsed.args, out);
|
|
50
81
|
}
|
|
51
82
|
catch (e) {
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { SupabaseClient } from "@supabase/supabase-js";
|
|
2
|
+
import type { ContentTable } from "../sql-space.ts";
|
|
3
|
+
import { type Row } from "../space-transfer.ts";
|
|
4
|
+
export type SpaceConnection = {
|
|
5
|
+
db: SupabaseClient;
|
|
6
|
+
space: string;
|
|
7
|
+
};
|
|
8
|
+
export declare const MEDIA_BUCKET = "media";
|
|
9
|
+
/** Rows per request. A host may cap below it, so a read stops at the first empty page, not the first short one. */
|
|
10
|
+
export declare const PAGE_ROWS = 1000;
|
|
11
|
+
/** `.env.local`, then `.env`, as Next reads them; a variable already set wins (spec §5). */
|
|
12
|
+
export declare function loadAppEnv(cwd: string, env?: Record<string, string | undefined>): void;
|
|
13
|
+
/** The app's content client and the space its secret key names: a cloud key's claim, or the default space. */
|
|
14
|
+
export declare function connectFromEnv(cwd: string): SpaceConnection;
|
|
15
|
+
/** The policies show `smoodly_meta` to a write key only, so no visible row means the key is not a secret key (spec §5). */
|
|
16
|
+
export declare function readSchemaVersion(db: SupabaseClient): Promise<{
|
|
17
|
+
version: number;
|
|
18
|
+
} | {
|
|
19
|
+
missing: "key" | "schema";
|
|
20
|
+
}>;
|
|
21
|
+
export declare function schemaRefusal(missing: "key" | "schema", command: "Export" | "Import"): string;
|
|
22
|
+
/** Every row of one table in one space, paged in primary-key order. The space filter is explicit: a service-role key sees every space. */
|
|
23
|
+
export declare function readSpaceRows(db: SupabaseClient, table: ContentTable, space: string, where?: Record<string, string>, columns?: string): Promise<Row[]>;
|
|
24
|
+
type StorageErrorLike = {
|
|
25
|
+
message: string;
|
|
26
|
+
statusCode?: string | number;
|
|
27
|
+
status?: number;
|
|
28
|
+
};
|
|
29
|
+
export declare const isNotFound: (e: StorageErrorLike) => boolean;
|
|
30
|
+
export declare const isAlreadyExists: (e: StorageErrorLike) => boolean;
|
|
31
|
+
/** The installed package's version for the manifest: `../../package.json` from src/cli/ and dist/cli/ alike. */
|
|
32
|
+
export declare function packageVersion(): string;
|
|
33
|
+
export {};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// packages/smoodly/src/cli/space-io.ts
|
|
2
|
+
// What export and import share (spec 2026-09-28 export/import §5, §8):
|
|
3
|
+
// the app's env, the client and the space its key names, the schema-
|
|
4
|
+
// version read that doubles as the secret-key check, paged reads of one
|
|
5
|
+
// space's rows, and the two storage error tests.
|
|
6
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { parseEnv } from "node:util";
|
|
9
|
+
import { createSmoodlyClient } from "../client.js";
|
|
10
|
+
import { resolveSmoodlyEnv, spaceIdOfKey } from "../env.js";
|
|
11
|
+
import { SCHEMA_VERSION_KEY } from "../schema-version.js";
|
|
12
|
+
import { ROW_KEY } from "../space-transfer.js";
|
|
13
|
+
export const MEDIA_BUCKET = "media";
|
|
14
|
+
/** Rows per request. A host may cap below it, so a read stops at the first empty page, not the first short one. */
|
|
15
|
+
export const PAGE_ROWS = 1000;
|
|
16
|
+
/** `.env.local`, then `.env`, as Next reads them; a variable already set wins (spec §5). */
|
|
17
|
+
export function loadAppEnv(cwd, env = process.env) {
|
|
18
|
+
for (const name of [".env.local", ".env"]) {
|
|
19
|
+
const file = join(cwd, name);
|
|
20
|
+
if (!existsSync(file))
|
|
21
|
+
continue;
|
|
22
|
+
for (const [key, value] of Object.entries(parseEnv(readFileSync(file, "utf8")))) {
|
|
23
|
+
if (env[key] === undefined)
|
|
24
|
+
env[key] = value;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** The app's content client and the space its secret key names: a cloud key's claim, or the default space. */
|
|
29
|
+
export function connectFromEnv(cwd) {
|
|
30
|
+
loadAppEnv(cwd);
|
|
31
|
+
const env = resolveSmoodlyEnv(process.env);
|
|
32
|
+
return { db: createSmoodlyClient(env), space: spaceIdOfKey(env.secretKey) };
|
|
33
|
+
}
|
|
34
|
+
/** The policies show `smoodly_meta` to a write key only, so no visible row means the key is not a secret key (spec §5). */
|
|
35
|
+
export async function readSchemaVersion(db) {
|
|
36
|
+
const res = await db.from("smoodly_meta").select("value").eq("key", SCHEMA_VERSION_KEY).maybeSingle();
|
|
37
|
+
if (res.error) {
|
|
38
|
+
// PGRST205: not in PostgREST's schema cache; 42P01: undefined table; 42501: no grant.
|
|
39
|
+
if (res.error.code === "PGRST205" || res.error.code === "42P01")
|
|
40
|
+
return { missing: "schema" };
|
|
41
|
+
if (res.error.code === "42501")
|
|
42
|
+
return { missing: "key" };
|
|
43
|
+
throw new Error(`smoodly: reading the schema version: ${res.error.message}`);
|
|
44
|
+
}
|
|
45
|
+
const n = Number(res.data?.value);
|
|
46
|
+
return res.data && Number.isInteger(n) ? { version: n } : { missing: "key" };
|
|
47
|
+
}
|
|
48
|
+
export function schemaRefusal(missing, command) {
|
|
49
|
+
return missing === "key"
|
|
50
|
+
? `${command} needs SMOODLY_SECRET_KEY — the service-role key, or the cloud's write key. The publishable key sees published content only.`
|
|
51
|
+
: "This database has no Smoodly schema. Run `npx smoodly migrate`, then `supabase db push` (hosted) or `supabase db reset` (local).";
|
|
52
|
+
}
|
|
53
|
+
/** Every row of one table in one space, paged in primary-key order. The space filter is explicit: a service-role key sees every space. */
|
|
54
|
+
export async function readSpaceRows(db, table, space, where = {}, columns = "*") {
|
|
55
|
+
const rows = [];
|
|
56
|
+
for (let from = 0;;) {
|
|
57
|
+
let q = db.from(table).select(columns).eq("space_id", space);
|
|
58
|
+
for (const [column, value] of Object.entries(where))
|
|
59
|
+
q = q.eq(column, value);
|
|
60
|
+
for (const column of ROW_KEY[table])
|
|
61
|
+
q = q.order(column);
|
|
62
|
+
const res = await q.range(from, from + PAGE_ROWS - 1);
|
|
63
|
+
if (res.error)
|
|
64
|
+
throw new Error(`smoodly: reading ${table}: ${res.error.message}`);
|
|
65
|
+
const page = (res.data ?? []);
|
|
66
|
+
if (page.length === 0)
|
|
67
|
+
return rows;
|
|
68
|
+
rows.push(...page);
|
|
69
|
+
from += page.length;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
export const isNotFound = (e) => String(e.statusCode) === "404" || e.status === 404 || /not.?found/i.test(e.message);
|
|
73
|
+
export const isAlreadyExists = (e) => String(e.statusCode) === "409" || e.status === 409 || /already exists/i.test(e.message);
|
|
74
|
+
/** The installed package's version for the manifest: `../../package.json` from src/cli/ and dist/cli/ alike. */
|
|
75
|
+
export function packageVersion() {
|
|
76
|
+
return JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")).version;
|
|
77
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { type ContentTable } from "./sql-space.ts";
|
|
2
|
+
/** The folder layout's own version, apart from the schema (spec §7). */
|
|
3
|
+
export declare const FORMAT = 1;
|
|
4
|
+
export type Row = Record<string, unknown>;
|
|
5
|
+
export type TableRows = Record<ContentTable, Row[]>;
|
|
6
|
+
export type ExportMember = {
|
|
7
|
+
email: string | null;
|
|
8
|
+
role: string;
|
|
9
|
+
};
|
|
10
|
+
export type Manifest = {
|
|
11
|
+
format: number;
|
|
12
|
+
schemaVersion: number;
|
|
13
|
+
smoodlyVersion: string;
|
|
14
|
+
exportedAt: string;
|
|
15
|
+
sourceSpace: string;
|
|
16
|
+
counts: Record<ContentTable, number>;
|
|
17
|
+
media: {
|
|
18
|
+
files: number;
|
|
19
|
+
bytes: number;
|
|
20
|
+
missing: string[];
|
|
21
|
+
};
|
|
22
|
+
members: ExportMember[];
|
|
23
|
+
};
|
|
24
|
+
/** Every foreign key's target lands before the row that names it (spec §9.3). */
|
|
25
|
+
export declare const INSERT_ORDER: readonly ContentTable[];
|
|
26
|
+
/** A row's identity inside one space: its primary key without `space_id`. */
|
|
27
|
+
export declare const ROW_KEY: Record<ContentTable, readonly string[]>;
|
|
28
|
+
/** The primary key as Postgres declares it — what "on conflict do nothing" names. */
|
|
29
|
+
export declare const CONFLICT_COLUMNS: Record<ContentTable, string>;
|
|
30
|
+
export declare const rowKey: (table: ContentTable, row: Row) => string;
|
|
31
|
+
/** Sorted by key, so two exports of an unchanged space diff cleanly (spec §7). */
|
|
32
|
+
export declare function sortRows(table: ContentTable, rows: Row[]): Row[];
|
|
33
|
+
export declare const emptyTables: () => TableRows;
|
|
34
|
+
/** A row as it leaves its space: no `space_id`; an asset's path without `<space>/`. */
|
|
35
|
+
export declare function fromSpace(table: ContentTable, row: Row, space: string): Row;
|
|
36
|
+
/** A row as it enters a space: `space_id` set; an asset's path prefixed. */
|
|
37
|
+
export declare function intoSpace(table: ContentTable, row: Row, space: string): Row;
|
|
38
|
+
/** Rows whose `column` names another row of the list come after it (parents, posters); a name outside the list counts as none. */
|
|
39
|
+
export declare function referencedFirst(rows: Row[], column: string): Row[];
|
|
40
|
+
/** The tables in insert order, their rows ordered for it (spec §9.3). */
|
|
41
|
+
export declare function insertPlan(rows: TableRows): {
|
|
42
|
+
table: ContentTable;
|
|
43
|
+
rows: Row[];
|
|
44
|
+
}[];
|
|
45
|
+
/** The export's consistency check (spec §8.3): exactly the foreign keys the target enforces, one line per broken reference. */
|
|
46
|
+
export declare function brokenReferences(rows: TableRows): string[];
|
|
47
|
+
/** 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. */
|
|
48
|
+
export declare function foreignRows(target: Record<ContentTable, string[]>, exported: TableRows): Record<ContentTable, number>;
|
|
49
|
+
export declare const countRows: (rows: TableRows) => Record<ContentTable, number>;
|
|
50
|
+
export declare function formatBytes(n: number): string;
|
|
51
|
+
/** "14 pages (41 versions), 32 entries (118 versions), 51 assets (38.2 MB), 9 redirects" (spec §8.5). */
|
|
52
|
+
export declare function describeExport(rows: TableRows, bytes: number): string;
|
|
53
|
+
/** "14 pages, 32 entries, 51 assets and 9 redirects" (spec §9.4). */
|
|
54
|
+
export declare function describeImport(rows: TableRows): string;
|
|
55
|
+
/** The target check's refusal (spec §9.1), naming only the tables that hold foreign rows. */
|
|
56
|
+
export declare function notEmptyMessage(foreign: Record<ContentTable, number>): string;
|
|
57
|
+
/** Spec §6: the target database's version must equal the export's. */
|
|
58
|
+
export declare function versionMismatchMessage(target: number, exported: number): string;
|
|
@@ -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