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.
- package/README.md +58 -12
- package/dist/admin/editor/EditorView.js +2 -2
- package/dist/admin/editor/PageSettings.js +5 -1
- package/dist/admin/editor/autosave.d.ts +2 -1
- package/dist/admin/editor/autosave.js +6 -0
- package/dist/admin/forms/FieldWidget.d.ts +5 -1
- package/dist/admin/forms/FieldWidget.js +6 -3
- package/dist/admin/forms/slug-field.d.ts +17 -0
- package/dist/admin/forms/slug-field.js +39 -0
- package/dist/admin/ops-impl.js +24 -3
- package/dist/admin/shell/EntryForm.js +46 -6
- package/dist/admin/shell/GlobalForm.js +1 -1
- package/dist/admin/shell/PageDialogs.js +11 -5
- package/dist/admin/ui/primitives.js +1 -0
- package/dist/admin/validate.d.ts +2 -0
- package/dist/admin/validate.js +9 -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/collections.js +10 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/paths.d.ts +7 -0
- package/dist/paths.js +21 -0
- package/dist/space-transfer.d.ts +58 -0
- package/dist/space-transfer.js +174 -0
- package/package.json +1 -1
- package/dist/admin/shell/page-dialogs.d.ts +0 -9
- package/dist/admin/shell/page-dialogs.js +0 -19
|
@@ -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
|
+
}
|
package/dist/collections.js
CHANGED
|
@@ -34,6 +34,16 @@ export function collection(input) {
|
|
|
34
34
|
input.fields[input.order.by]?.descriptor.localized === true) {
|
|
35
35
|
throw new Error(`smoodly: collection "${input.name}" orders by "${input.order.by}", which is localized — order fields are shared across locales.`);
|
|
36
36
|
}
|
|
37
|
+
// f.slug({ from }) follows a field in the entry form (spec 2026-09-28
|
|
38
|
+
// ASCII slugs §5): a typo would silently never follow, so it fails here.
|
|
39
|
+
for (const [key, builder] of Object.entries(input.fields)) {
|
|
40
|
+
const from = builder.descriptor.type === "slug" ? builder.descriptor.from : undefined;
|
|
41
|
+
if (from === key)
|
|
42
|
+
throw new Error(`smoodly: collection "${input.name}" slug field "${key}" cannot follow itself.`);
|
|
43
|
+
if (typeof from === "string" && !Object.prototype.hasOwnProperty.call(input.fields, from)) {
|
|
44
|
+
throw new Error(`smoodly: collection "${input.name}" slug field "${key}" follows an unknown field "${from}".`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
37
47
|
return {
|
|
38
48
|
kind: "collection",
|
|
39
49
|
name: input.name,
|
package/dist/index.d.ts
CHANGED
|
@@ -57,7 +57,7 @@ export { affectedTargets, pageTag, entryTag, globalTag, pagesTreeTag, collection
|
|
|
57
57
|
export type { AffectedTargets } from "./revalidate.ts";
|
|
58
58
|
export { resolveTree, resolveFields, resolveMedia } from "./resolve.ts";
|
|
59
59
|
export type { EntryFetcher, ResolveFieldsOptions, AssetFetcher } from "./resolve.ts";
|
|
60
|
-
export { SEGMENT_RE, isSegment, assertSegment, joinPath, splitPath, segmentFor, isHomeNode, pagePathIn, chainOf, assertDepth, pagePath, pagePathRows, entryPathRows, collectionSegment, buildPageTree, perLocaleSegments, assertSupportedLocale, unsupportedLocale, pageAlternates, entryAlternates, } from "./paths.ts";
|
|
60
|
+
export { SEGMENT_RE, isSegment, assertSegment, slugify, joinPath, splitPath, segmentFor, isHomeNode, pagePathIn, chainOf, assertDepth, pagePath, pagePathRows, entryPathRows, collectionSegment, buildPageTree, perLocaleSegments, assertSupportedLocale, unsupportedLocale, pageAlternates, entryAlternates, } from "./paths.ts";
|
|
61
61
|
export type { LocaleSet, PathTarget, PathRow, PageLike, EntryLike, PageTreeNode, PagePathOptions, LocaleAlternate, PageAlternate } from "./paths.ts";
|
|
62
62
|
export { fixedPathFor } from "./localize.ts";
|
|
63
63
|
export type { PageStore, PageRecord, PageLocale, PageVersion, PageScan, PageStoreOptions, CreatePageInput, AddLocaleOptions } from "./store.ts";
|
package/dist/index.js
CHANGED
|
@@ -41,7 +41,7 @@ export { SupabasePathIndex } from "./supabase-path-index.js";
|
|
|
41
41
|
export { entryErrors, compareEntries, DEFAULT_ENTRY_LOCALES, localizedKeys, splitFields, entryLocaleRow, entryFieldsIn, entryRefEdges, entryTitleIn, matchesWhere, } from "./entry-store.js";
|
|
42
42
|
export { affectedTargets, pageTag, entryTag, globalTag, pagesTreeTag, collectionTag } from "./revalidate.js";
|
|
43
43
|
export { resolveTree, resolveFields, resolveMedia } from "./resolve.js";
|
|
44
|
-
export { SEGMENT_RE, isSegment, assertSegment, joinPath, splitPath, segmentFor, isHomeNode, pagePathIn, chainOf, assertDepth, pagePath, pagePathRows, entryPathRows, collectionSegment, buildPageTree, perLocaleSegments, assertSupportedLocale, unsupportedLocale, pageAlternates, entryAlternates, } from "./paths.js";
|
|
44
|
+
export { SEGMENT_RE, isSegment, assertSegment, slugify, joinPath, splitPath, segmentFor, isHomeNode, pagePathIn, chainOf, assertDepth, pagePath, pagePathRows, entryPathRows, collectionSegment, buildPageTree, perLocaleSegments, assertSupportedLocale, unsupportedLocale, pageAlternates, entryAlternates, } from "./paths.js";
|
|
45
45
|
export { fixedPathFor } from "./localize.js";
|
|
46
46
|
export { pathOptions, pageErrors, localeRow, pageRefEdges, DEFAULT_LOCALES } from "./store.js";
|
|
47
47
|
export { DEFAULT_TOOLSET } from "./toolset.js";
|
package/dist/paths.d.ts
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
export declare const SEGMENT_RE: RegExp;
|
|
2
2
|
export declare const isSegment: (s: string) => boolean;
|
|
3
3
|
export declare function assertSegment(s: string, what: string): void;
|
|
4
|
+
/** A segment from any text, the one slugifier (spec 2026-09-28 ASCII
|
|
5
|
+
* slugs §2): compatibility forms folded (NFKD: fi → fi, A → a),
|
|
6
|
+
* diacritics stripped (ä → a, é → e), the letters in TRANSLITERATE
|
|
7
|
+
* spelled out (ø → o, ß → ss), everything else a hyphen, no leading,
|
|
8
|
+
* trailing or doubled hyphens. The result passes isSegment
|
|
9
|
+
* or is "" — a title with no Latin letters or digits makes no slug. */
|
|
10
|
+
export declare function slugify(text: string): string;
|
|
4
11
|
export type LocaleSet = {
|
|
5
12
|
default: string;
|
|
6
13
|
supported: string[];
|
package/dist/paths.js
CHANGED
|
@@ -9,6 +9,27 @@ export function assertSegment(s, what) {
|
|
|
9
9
|
throw new Error(`smoodly: ${what} "${s}" is not a valid slug — use lowercase letters, digits and single hyphens.`);
|
|
10
10
|
}
|
|
11
11
|
}
|
|
12
|
+
/** Letters with no Unicode decomposition, which NFKD cannot strip to a base
|
|
13
|
+
* letter (spec 2026-09-28 ASCII slugs §2). Applied after lowercasing. */
|
|
14
|
+
const TRANSLITERATE = {
|
|
15
|
+
ø: "o", æ: "ae", œ: "oe", ß: "ss", ł: "l", đ: "d", ð: "d", þ: "th", ħ: "h", ı: "i",
|
|
16
|
+
};
|
|
17
|
+
const TRANSLITERATE_RE = new RegExp(`[${Object.keys(TRANSLITERATE).join("")}]`, "g");
|
|
18
|
+
/** A segment from any text, the one slugifier (spec 2026-09-28 ASCII
|
|
19
|
+
* slugs §2): compatibility forms folded (NFKD: fi → fi, A → a),
|
|
20
|
+
* diacritics stripped (ä → a, é → e), the letters in TRANSLITERATE
|
|
21
|
+
* spelled out (ø → o, ß → ss), everything else a hyphen, no leading,
|
|
22
|
+
* trailing or doubled hyphens. The result passes isSegment
|
|
23
|
+
* or is "" — a title with no Latin letters or digits makes no slug. */
|
|
24
|
+
export function slugify(text) {
|
|
25
|
+
return text
|
|
26
|
+
.normalize("NFKD")
|
|
27
|
+
.replace(/[\u0300-\u036f]/g, "")
|
|
28
|
+
.toLowerCase()
|
|
29
|
+
.replace(TRANSLITERATE_RE, (ch) => TRANSLITERATE[ch])
|
|
30
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
31
|
+
.replace(/^-+|-+$/g, "");
|
|
32
|
+
}
|
|
12
33
|
/** ONE text for both stores: a locale outside the config set. */
|
|
13
34
|
export const unsupportedLocale = (locale) => new Error(`smoodly: "${locale}" is not a supported locale.`);
|
|
14
35
|
export function assertSupportedLocale(locale, set) {
|
|
@@ -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;
|