smoodly 0.0.23 → 0.0.25
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 +192 -43
- package/bin/smoodly.js +6 -0
- package/dist/admin/auth.d.ts +10 -5
- package/dist/admin/auth.js +14 -13
- package/dist/admin/client-ops.js +2 -1
- package/dist/admin/editor/EditorView.js +20 -6
- package/dist/admin/editor/InsertPicker.d.ts +14 -0
- package/dist/admin/editor/InsertPicker.js +37 -7
- package/dist/admin/editor/InspectorFields.js +1 -1
- package/dist/admin/editor/Outline.js +2 -2
- package/dist/admin/editor/PageSettings.js +6 -0
- package/dist/admin/editor/SeoSettings.js +1 -1
- package/dist/admin/editor/locale-url.d.ts +7 -4
- package/dist/admin/editor/locale-url.js +14 -7
- package/dist/admin/editor/usePageLocale.js +9 -1
- package/dist/admin/forms/CropEditor.d.ts +4 -2
- package/dist/admin/forms/CropEditor.js +6 -3
- package/dist/admin/forms/DatePicker.d.ts +9 -0
- package/dist/admin/forms/DatePicker.js +133 -0
- package/dist/admin/forms/FieldWidget.js +11 -5
- package/dist/admin/forms/ImagePreview.d.ts +9 -0
- package/dist/admin/forms/ImagePreview.js +11 -0
- package/dist/admin/forms/MediaWidget.d.ts +8 -2
- package/dist/admin/forms/MediaWidget.js +13 -13
- package/dist/admin/forms/RichtextElementCard.js +1 -1
- package/dist/admin/forms/RichtextImageCard.js +14 -9
- package/dist/admin/forms/date-picker.d.ts +44 -0
- package/dist/admin/forms/date-picker.js +117 -0
- package/dist/admin/forms/elements-context.d.ts +3 -0
- package/dist/admin/forms/elements-context.js +7 -2
- package/dist/admin/forms/time-zone-context.d.ts +7 -0
- package/dist/admin/forms/time-zone-context.js +15 -0
- package/dist/admin/forms/useRichtextEditor.js +10 -1
- package/dist/admin/index.d.ts +2 -0
- package/dist/admin/next/bridge.d.ts +2 -1
- package/dist/admin/next/bridge.js +35 -5
- package/dist/admin/next/index.d.ts +2 -0
- package/dist/admin/ops-impl.js +73 -1
- package/dist/admin/ops.d.ts +22 -2
- package/dist/admin/serialize.d.ts +2 -0
- package/dist/admin/serialize.js +1 -0
- package/dist/admin/shell/AdminApp.js +48 -3
- package/dist/admin/shell/EntriesList.js +12 -21
- package/dist/admin/shell/EntryForm.js +63 -5
- package/dist/admin/shell/GlobalForm.js +22 -2
- package/dist/admin/shell/GlobalList.js +4 -18
- package/dist/admin/shell/IconRail.d.ts +3 -1
- package/dist/admin/shell/IconRail.js +17 -7
- package/dist/admin/shell/InviteModal.d.ts +6 -0
- package/dist/admin/shell/InviteModal.js +28 -0
- package/dist/admin/shell/ListView.js +2 -2
- package/dist/admin/shell/LoginView.js +34 -5
- package/dist/admin/shell/MediaScreen.js +11 -1
- package/dist/admin/shell/MembersPage.d.ts +2 -1
- package/dist/admin/shell/MembersPage.js +90 -25
- package/dist/admin/shell/PageDialogs.d.ts +26 -0
- package/dist/admin/shell/PageDialogs.js +38 -0
- package/dist/admin/shell/PagesList.js +100 -70
- package/dist/admin/shell/RouteLink.js +1 -1
- package/dist/admin/shell/SchemaNotice.d.ts +4 -0
- package/dist/admin/shell/SchemaNotice.js +4 -0
- package/dist/admin/shell/SetPasswordView.d.ts +7 -0
- package/dist/admin/shell/SetPasswordView.js +41 -0
- package/dist/admin/shell/SettingsView.d.ts +5 -1
- package/dist/admin/shell/SettingsView.js +17 -11
- package/dist/admin/shell/live-url.d.ts +10 -0
- package/dist/admin/shell/live-url.js +19 -0
- package/dist/admin/shell/locale-memory.d.ts +8 -0
- package/dist/admin/shell/locale-memory.js +23 -0
- package/dist/admin/shell/members-list.d.ts +15 -1
- package/dist/admin/shell/members-list.js +24 -7
- package/dist/admin/shell/page-dialogs.d.ts +9 -0
- package/dist/admin/shell/page-dialogs.js +19 -0
- package/dist/admin/shell/router.d.ts +10 -7
- package/dist/admin/shell/router.js +18 -9
- package/dist/admin/shell/session.d.ts +6 -0
- package/dist/admin/shell/session.js +17 -2
- package/dist/admin/shell/settings.d.ts +0 -6
- package/dist/admin/shell/settings.js +4 -5
- package/dist/admin/supabase-auth.js +6 -5
- package/dist/admin/tree-ops.js +3 -0
- package/dist/admin/ui/ActionMenu.js +26 -6
- package/dist/admin/ui/Combobox.d.ts +5 -0
- package/dist/admin/ui/Combobox.js +1 -1
- package/dist/admin/ui/ConfirmDialog.d.ts +8 -0
- package/dist/admin/ui/ConfirmDialog.js +30 -0
- package/dist/admin/ui/Dialog.d.ts +13 -0
- package/dist/admin/ui/Dialog.js +54 -0
- package/dist/admin/ui/HistoryModal.d.ts +2 -1
- package/dist/admin/ui/HistoryModal.js +7 -2
- package/dist/admin/ui/ResizeHandle.js +4 -4
- package/dist/admin/ui/confirm.d.ts +35 -0
- package/dist/admin/ui/confirm.js +57 -0
- package/dist/admin/ui/primitives.d.ts +19 -4
- package/dist/admin/ui/primitives.js +11 -5
- package/dist/admin/ui/resize.d.ts +7 -4
- package/dist/admin/ui/resize.js +7 -4
- package/dist/admin/ui/theme.d.ts +1 -1
- package/dist/admin/ui/theme.js +368 -203
- package/dist/admin/validate.d.ts +4 -0
- package/dist/admin/validate.js +28 -0
- package/dist/cli/load-collections.d.ts +2 -0
- package/dist/cli/load-collections.js +26 -0
- package/dist/cli/main.d.ts +13 -0
- package/dist/cli/main.js +62 -0
- package/dist/cli/migrate.d.ts +23 -0
- package/dist/cli/migrate.js +64 -0
- package/dist/config.d.ts +4 -0
- package/dist/config.js +9 -0
- package/dist/fields.d.ts +2 -0
- package/dist/fields.js +2 -0
- package/dist/index.d.ts +6 -3
- package/dist/index.js +4 -2
- package/dist/marks.d.ts +3 -0
- package/dist/marks.js +3 -0
- package/dist/member-store.d.ts +30 -1
- package/dist/member-store.js +37 -6
- package/dist/migrations.d.ts +37 -0
- package/dist/migrations.js +73 -0
- package/dist/permissions.d.ts +7 -0
- package/dist/permissions.js +19 -0
- package/dist/schema-version.d.ts +17 -0
- package/dist/schema-version.js +32 -0
- package/dist/sql-space.js +6 -0
- package/dist/sql.js +10 -0
- package/dist/store.d.ts +3 -0
- package/dist/store.js +4 -0
- package/dist/supabase-member-store.d.ts +8 -1
- package/dist/supabase-member-store.js +62 -2
- package/dist/supabase-store.d.ts +3 -0
- package/dist/supabase-store.js +23 -0
- package/dist/time-zone.d.ts +6 -0
- package/dist/time-zone.js +27 -0
- package/migrations/20260926120000_smoodly_v1.sql +535 -0
- package/package.json +13 -3
package/dist/admin/validate.d.ts
CHANGED
|
@@ -22,6 +22,10 @@ export type ElementDescs = Record<string, {
|
|
|
22
22
|
fields: Record<string, Desc>;
|
|
23
23
|
settings?: Record<string, Desc>;
|
|
24
24
|
}>;
|
|
25
|
+
/** The errors of `previous` that a fresh validation still reports, with the
|
|
26
|
+
* fresh message: a form's refusal list shrinks as its fields are fixed and
|
|
27
|
+
* never grows from typing (2026-09-25) — only a publish adds. */
|
|
28
|
+
export declare function stillFailing(previous: Record<string, string>, fresh: FieldError[]): Record<string, string>;
|
|
25
29
|
/** Validate one value map. Without `scope.fields` the values ARE the fields (an entry, a page, an object global). */
|
|
26
30
|
export declare function validateFields(values: Record<string, unknown>, descriptors: Record<string, Desc>, elements?: ElementDescs, scope?: ValidationScope): FieldError[];
|
|
27
31
|
/** A tree node (or a visual global, in node shape) against its schema: fields, settings (prefixed), css (spec 2026-09-14 §3, §4). */
|
package/dist/admin/validate.js
CHANGED
|
@@ -12,14 +12,21 @@ import { normalizeTag, sameTag } from "../tags.js";
|
|
|
12
12
|
import { hasContent } from "./richtext-doc.js";
|
|
13
13
|
import { ELEMENT_NODE, richtextBlocks } from "../richtext-walk.js";
|
|
14
14
|
import { rowsProblems } from "../custom/rows.js";
|
|
15
|
+
import { isCalendarDay, isInstant } from "./forms/date-picker.js";
|
|
15
16
|
const subfields = (d) => d.fields && typeof d.fields === "object" ? d.fields : {};
|
|
16
17
|
const itemOf = (d) => d.item && typeof d.item === "object" ? d.item : undefined;
|
|
17
18
|
const isEmpty = (v) => v === undefined || v === null || v === "" || (Array.isArray(v) && v.length === 0);
|
|
19
|
+
/** An object holding nothing but empty values, at any depth — `{}`, `{ label: "" }`. */
|
|
20
|
+
const isBlankObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v) &&
|
|
21
|
+
Object.values(v).every((x) => isEmpty(x) || isBlankObject(x));
|
|
18
22
|
/** An object whose every subfield is optional — or is itself an object that
|
|
19
23
|
* may be absent — may be absent altogether. */
|
|
20
24
|
const mayBeAbsent = (d) => d.optional === true || d.type === "boolean" || (d.type === "object" && Object.values(subfields(d)).every(mayBeAbsent));
|
|
21
25
|
const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
22
26
|
function validateValue(path, value, d, errors, elements, scope) {
|
|
27
|
+
// An optional object with nothing filled in is absent (drafts inserted before 2026-09-26 carry `{}`).
|
|
28
|
+
if (d.type === "object" && d.optional === true && isBlankObject(value))
|
|
29
|
+
return;
|
|
23
30
|
if (isEmpty(value)) {
|
|
24
31
|
if (!mayBeAbsent(d))
|
|
25
32
|
errors.push({ field: path, message: "Required." });
|
|
@@ -49,6 +56,17 @@ function validateValue(path, value, d, errors, elements, scope) {
|
|
|
49
56
|
}
|
|
50
57
|
return;
|
|
51
58
|
}
|
|
59
|
+
// Dates (spec 2026-09-25 date pickers §2): the stored formats exactly, and a day the calendar has.
|
|
60
|
+
if (d.type === "date") {
|
|
61
|
+
if (typeof value !== "string" || !isCalendarDay(value))
|
|
62
|
+
errors.push({ field: path, message: "Not a date (2026-09-02)." });
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (d.type === "datetime") {
|
|
66
|
+
if (typeof value !== "string" || !isInstant(value))
|
|
67
|
+
errors.push({ field: path, message: "Not a date and time." });
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
52
70
|
if (d.type === "image" || d.type === "video" || d.type === "file") {
|
|
53
71
|
if (typeof value !== "object" || Array.isArray(value) || typeof value.asset !== "string") {
|
|
54
72
|
errors.push({ field: path, message: "Must be an uploaded file." });
|
|
@@ -151,6 +169,16 @@ function validateMap(prefix, values, descriptors, errors, elements, scope) {
|
|
|
151
169
|
validateValue(prefix === "" ? key : `${prefix}.${key}`, values[key], d, errors, elements, scope);
|
|
152
170
|
}
|
|
153
171
|
}
|
|
172
|
+
/** The errors of `previous` that a fresh validation still reports, with the
|
|
173
|
+
* fresh message: a form's refusal list shrinks as its fields are fixed and
|
|
174
|
+
* never grows from typing (2026-09-25) — only a publish adds. */
|
|
175
|
+
export function stillFailing(previous, fresh) {
|
|
176
|
+
const out = {};
|
|
177
|
+
for (const e of fresh)
|
|
178
|
+
if (e.field in previous)
|
|
179
|
+
out[e.field] = e.message;
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
154
182
|
/** Validate one value map. Without `scope.fields` the values ARE the fields (an entry, a page, an object global). */
|
|
155
183
|
export function validateFields(values, descriptors, elements = {}, scope = {}) {
|
|
156
184
|
const errors = [];
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// packages/smoodly/src/cli/load-collections.ts
|
|
2
|
+
// Loads the app's `smoodly/collections.ts` — TypeScript, through tsx's
|
|
3
|
+
// import API, under the app's own tsconfig so its `@/` alias resolves.
|
|
4
|
+
// The barrel and not `smoodly/config.ts`: the config imports the page
|
|
5
|
+
// shapes and with them React and Next, which a command line has no use
|
|
6
|
+
// for; the barrel is what the config registers in both starters.
|
|
7
|
+
import { existsSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { pathToFileURL } from "node:url";
|
|
10
|
+
import { tsImport } from "tsx/esm/api";
|
|
11
|
+
export async function loadCollections(file, cwd) {
|
|
12
|
+
if (!existsSync(file)) {
|
|
13
|
+
throw new Error(`smoodly: ${file} does not exist — pass --collections <file> for the module that exports \`collections\`.`);
|
|
14
|
+
}
|
|
15
|
+
const tsconfig = join(cwd, "tsconfig.json");
|
|
16
|
+
const mod = (await tsImport(pathToFileURL(file).href, {
|
|
17
|
+
parentURL: import.meta.url,
|
|
18
|
+
...(existsSync(tsconfig) ? { tsconfig } : {}),
|
|
19
|
+
}));
|
|
20
|
+
const list = mod.collections;
|
|
21
|
+
const isCollection = (c) => typeof c === "object" && c !== null && typeof c.name === "string";
|
|
22
|
+
if (!Array.isArray(list) || !list.every(isCollection)) {
|
|
23
|
+
throw new Error(`smoodly: ${file} must export \`collections\`, the list of smoodly.collection() definitions the config registers.`);
|
|
24
|
+
}
|
|
25
|
+
return list;
|
|
26
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type MigrateArgs } from "./migrate.ts";
|
|
2
|
+
export declare const USAGE = "usage: smoodly migrate [--check] [--collections <file>] [--migrations <dir>]\n\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)";
|
|
3
|
+
export type Parsed = {
|
|
4
|
+
command: "migrate";
|
|
5
|
+
args: MigrateArgs;
|
|
6
|
+
} | {
|
|
7
|
+
command: "help";
|
|
8
|
+
} | {
|
|
9
|
+
command: "error";
|
|
10
|
+
message: string;
|
|
11
|
+
};
|
|
12
|
+
export declare function parseArgs(argv: string[]): Parsed;
|
|
13
|
+
export declare function main(argv: string[], out?: (line: string) => void): Promise<number>;
|
package/dist/cli/main.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// packages/smoodly/src/cli/main.ts
|
|
2
|
+
// `smoodly` — the package's command line (spec 2026-09-26 §2). One
|
|
3
|
+
// subcommand for now; `check`, `export` and the drift report land here
|
|
4
|
+
// later. Exit codes: 0 done or nothing to do, 1 pending (--check) or a
|
|
5
|
+
// refused file, 2 usage.
|
|
6
|
+
import { resolve } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { runMigrate } from "./migrate.js";
|
|
9
|
+
export const USAGE = `usage: smoodly migrate [--check] [--collections <file>] [--migrations <dir>]
|
|
10
|
+
|
|
11
|
+
Copies the core schema files this package ships that supabase/migrations/
|
|
12
|
+
lacks, and writes one views file for the collections whose view is new or
|
|
13
|
+
changed. Prints the push or reset command; never runs it.
|
|
14
|
+
|
|
15
|
+
--check write nothing; exit 1 when anything is pending
|
|
16
|
+
--collections <file> the module exporting \`collections\` (default smoodly/collections.ts)
|
|
17
|
+
--migrations <dir> the app's migration folder (default supabase/migrations)`;
|
|
18
|
+
export function parseArgs(argv) {
|
|
19
|
+
const [command, ...rest] = argv;
|
|
20
|
+
if (!command || command === "--help" || command === "-h" || command === "help")
|
|
21
|
+
return { command: "help" };
|
|
22
|
+
if (command !== "migrate")
|
|
23
|
+
return { command: "error", message: `unknown command "${command}"` };
|
|
24
|
+
const args = { check: false, collections: "smoodly/collections.ts", migrations: "supabase/migrations" };
|
|
25
|
+
for (let i = 0; i < rest.length; i++) {
|
|
26
|
+
const a = rest[i];
|
|
27
|
+
if (a === "--check")
|
|
28
|
+
args.check = true;
|
|
29
|
+
else if (a === "--collections" && rest[i + 1] !== undefined)
|
|
30
|
+
args.collections = rest[++i];
|
|
31
|
+
else if (a === "--migrations" && rest[i + 1] !== undefined)
|
|
32
|
+
args.migrations = rest[++i];
|
|
33
|
+
else
|
|
34
|
+
return { command: "error", message: `unexpected argument "${a}"` };
|
|
35
|
+
}
|
|
36
|
+
return { command: "migrate", args };
|
|
37
|
+
}
|
|
38
|
+
export async function main(argv, out = console.log) {
|
|
39
|
+
const parsed = parseArgs(argv);
|
|
40
|
+
if (parsed.command === "help") {
|
|
41
|
+
out(USAGE);
|
|
42
|
+
return 0;
|
|
43
|
+
}
|
|
44
|
+
if (parsed.command === "error") {
|
|
45
|
+
out(`smoodly: ${parsed.message}\n\n${USAGE}`);
|
|
46
|
+
return 2;
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
return await runMigrate(parsed.args, out);
|
|
50
|
+
}
|
|
51
|
+
catch (e) {
|
|
52
|
+
out(e instanceof Error ? e.message : String(e));
|
|
53
|
+
return 1;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
// Run when this file is the entry (`tsx src/cli/main.ts …`, the repo's
|
|
57
|
+
// `npm run db:migrate`); the published bin imports `main` and runs it itself.
|
|
58
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(new URL(import.meta.url))) {
|
|
59
|
+
void main(process.argv.slice(2)).then((code) => {
|
|
60
|
+
process.exitCode = code;
|
|
61
|
+
});
|
|
62
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { CollectionSchema } from "../collections.ts";
|
|
2
|
+
import { type MigrationFile, type MigratePlan } from "../migrations.ts";
|
|
3
|
+
export type MigrateArgs = {
|
|
4
|
+
check: boolean;
|
|
5
|
+
collections: string;
|
|
6
|
+
migrations: string;
|
|
7
|
+
};
|
|
8
|
+
/** The folder shipped in the package: `../../migrations/` from dist/cli/ and from src/cli/ alike. */
|
|
9
|
+
export declare const PACKAGE_MIGRATIONS_DIR: string;
|
|
10
|
+
export declare const NO_FOLDER = "No supabase/migrations folder here \u2014 nothing to do. (Smoodly Cloud keeps the schema current; a self-host app gets the folder from `supabase init`.)";
|
|
11
|
+
export declare const NEXT_STEP = "Next: apply it \u2014 `supabase db push` (hosted) or `supabase db reset` (local).";
|
|
12
|
+
export declare const upToDate: (version: number, n: number) => string;
|
|
13
|
+
export declare const modifiedMessage: (name: string) => string;
|
|
14
|
+
export declare function readSqlFiles(dir: string): Promise<MigrationFile[]>;
|
|
15
|
+
/** One line per thing the plan does; "Pending" wording under --check. */
|
|
16
|
+
export declare function describePlan(plan: MigratePlan, check: boolean): string[];
|
|
17
|
+
export type MigrateDeps = {
|
|
18
|
+
cwd?: string;
|
|
19
|
+
/** Test seam: replaces the tsx loader. */
|
|
20
|
+
load?: (file: string, cwd: string) => Promise<CollectionSchema<any>[]>;
|
|
21
|
+
now?: () => Date;
|
|
22
|
+
};
|
|
23
|
+
export declare function runMigrate(args: MigrateArgs, out: (line: string) => void, deps?: MigrateDeps): Promise<number>;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// packages/smoodly/src/cli/migrate.ts
|
|
2
|
+
// The filesystem half of `smoodly migrate` (spec 2026-09-26 §2): reads
|
|
3
|
+
// the package's migrations/ and the app's supabase/migrations/, loads the
|
|
4
|
+
// app's collections, asks the pure planner (../migrations.ts) what to
|
|
5
|
+
// write, writes it. The message strings are what the docs quote.
|
|
6
|
+
import { existsSync } from "node:fs";
|
|
7
|
+
import { readdir, readFile, writeFile } from "node:fs/promises";
|
|
8
|
+
import { join, resolve } from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import { collectionSQL } from "../sql.js";
|
|
11
|
+
import { coreFileVersion, SCHEMA_VERSION } from "../schema-version.js";
|
|
12
|
+
import { planIsEmpty, planMigrate } from "../migrations.js";
|
|
13
|
+
import { loadCollections } from "./load-collections.js";
|
|
14
|
+
/** The folder shipped in the package: `../../migrations/` from dist/cli/ and from src/cli/ alike. */
|
|
15
|
+
export const PACKAGE_MIGRATIONS_DIR = fileURLToPath(new URL("../../migrations/", import.meta.url));
|
|
16
|
+
export const NO_FOLDER = "No supabase/migrations folder here — nothing to do. (Smoodly Cloud keeps the schema current; a self-host app gets the folder from `supabase init`.)";
|
|
17
|
+
export const NEXT_STEP = "Next: apply it — `supabase db push` (hosted) or `supabase db reset` (local).";
|
|
18
|
+
export const upToDate = (version, n) => `Up to date: core schema v${version}, every collection's view is current (${n} ${n === 1 ? "collection" : "collections"}).`;
|
|
19
|
+
export const modifiedMessage = (name) => `Refusing: ${name} differs from the package's copy. A published migration is never edited — restore it from the package, or move your change to a new file.`;
|
|
20
|
+
export async function readSqlFiles(dir) {
|
|
21
|
+
const names = (await readdir(dir)).filter((n) => n.endsWith(".sql")).sort();
|
|
22
|
+
return Promise.all(names.map(async (name) => ({ name, text: await readFile(join(dir, name), "utf8") })));
|
|
23
|
+
}
|
|
24
|
+
/** One line per thing the plan does; "Pending" wording under --check. */
|
|
25
|
+
export function describePlan(plan, check) {
|
|
26
|
+
const lines = [];
|
|
27
|
+
for (const name of plan.modified)
|
|
28
|
+
lines.push(modifiedMessage(name));
|
|
29
|
+
for (const f of plan.copy)
|
|
30
|
+
lines.push(`${check ? "Pending" : "Copied"} ${f.name} (core schema v${coreFileVersion(f.name)}).`);
|
|
31
|
+
if (plan.views) {
|
|
32
|
+
const names = [...plan.views.text.matchAll(/^-- smoodly:collection (\S+)/gm)].map((m) => m[1]);
|
|
33
|
+
lines.push(`${check ? "Pending" : "Wrote"} ${plan.views.name} (views: ${names.join(", ")}).`);
|
|
34
|
+
}
|
|
35
|
+
return lines;
|
|
36
|
+
}
|
|
37
|
+
export async function runMigrate(args, out, deps = {}) {
|
|
38
|
+
const cwd = deps.cwd ?? process.cwd();
|
|
39
|
+
const appDir = resolve(cwd, args.migrations);
|
|
40
|
+
if (!existsSync(appDir)) {
|
|
41
|
+
out(NO_FOLDER);
|
|
42
|
+
return 0;
|
|
43
|
+
}
|
|
44
|
+
const collections = await (deps.load ?? loadCollections)(resolve(cwd, args.collections), cwd);
|
|
45
|
+
const plan = planMigrate({
|
|
46
|
+
packageFiles: await readSqlFiles(PACKAGE_MIGRATIONS_DIR),
|
|
47
|
+
appFiles: await readSqlFiles(appDir),
|
|
48
|
+
collections: collections.map((c) => ({ name: c.name, sql: collectionSQL(c) })),
|
|
49
|
+
}, (deps.now ?? (() => new Date()))());
|
|
50
|
+
for (const line of describePlan(plan, args.check))
|
|
51
|
+
out(line);
|
|
52
|
+
if (plan.modified.length > 0)
|
|
53
|
+
return 1;
|
|
54
|
+
if (planIsEmpty(plan)) {
|
|
55
|
+
out(upToDate(SCHEMA_VERSION, collections.length));
|
|
56
|
+
return 0;
|
|
57
|
+
}
|
|
58
|
+
if (args.check)
|
|
59
|
+
return 1;
|
|
60
|
+
for (const f of [...plan.copy, ...(plan.views ? [plan.views] : [])])
|
|
61
|
+
await writeFile(join(appDir, f.name), f.text, "utf8");
|
|
62
|
+
out(NEXT_STEP);
|
|
63
|
+
return 0;
|
|
64
|
+
}
|
package/dist/config.d.ts
CHANGED
|
@@ -32,6 +32,10 @@ export type SmoodlyConfig = {
|
|
|
32
32
|
default: string;
|
|
33
33
|
supported: string[];
|
|
34
34
|
};
|
|
35
|
+
/** The site's time zone, an IANA name such as "Europe/Helsinki" (spec
|
|
36
|
+
* 2026-09-25 date pickers §5): editors pick datetimes in it, views
|
|
37
|
+
* format with it. Absent = the editor's browser zone. */
|
|
38
|
+
timeZone?: string;
|
|
35
39
|
/** `tree.depth` — how deep editors may nest pages. Absent = flat. */
|
|
36
40
|
pages?: {
|
|
37
41
|
tree?: {
|
package/dist/config.js
CHANGED
|
@@ -3,6 +3,7 @@ import { DEFAULT_MEDIA } from "./media.js";
|
|
|
3
3
|
import { DEFAULT_ADMIN_PATH, assertAdminPath, isAdminSlug } from "./admin-path.js";
|
|
4
4
|
import { resolveStyleValues, styleDescriptors } from "./styles.js";
|
|
5
5
|
import { resolveCustom } from "./custom/index.js";
|
|
6
|
+
import { isValidZone } from "./time-zone.js";
|
|
6
7
|
/** `global`: the placement node type and an admin route; `asset`: the refs
|
|
7
8
|
* target of every media edge; `media`: the admin's Media screen route. */
|
|
8
9
|
export const RESERVED_NAMES = ["global", "asset", "media"];
|
|
@@ -135,6 +136,13 @@ function assertLocales(config) {
|
|
|
135
136
|
if (!supported.includes(def))
|
|
136
137
|
throw new Error(`smoodly: the default locale "${def}" is not in the supported list.`);
|
|
137
138
|
}
|
|
139
|
+
function assertTimeZone(config) {
|
|
140
|
+
if (config.timeZone === undefined)
|
|
141
|
+
return;
|
|
142
|
+
if (!isValidZone(config.timeZone)) {
|
|
143
|
+
throw new Error(`smoodly: timeZone "${config.timeZone}" is not a time zone this runtime knows — use an IANA name such as "Europe/Helsinki".`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
138
146
|
/** Every element name a richtext toolset embeds, walking objects and lists. */
|
|
139
147
|
function toolsetElementNames(descriptors, out) {
|
|
140
148
|
for (const d of Object.values(descriptors ?? {})) {
|
|
@@ -174,6 +182,7 @@ function assertToolsetElements(config) {
|
|
|
174
182
|
}
|
|
175
183
|
export function defineConfig(config) {
|
|
176
184
|
assertLocales(config);
|
|
185
|
+
assertTimeZone(config);
|
|
177
186
|
assertUniqueNames(config);
|
|
178
187
|
assertGlobalSections(config);
|
|
179
188
|
assertToolsetElements(config);
|
package/dist/fields.d.ts
CHANGED
|
@@ -74,6 +74,8 @@ export declare const f: {
|
|
|
74
74
|
from?: string;
|
|
75
75
|
}) => FieldBuilder<string, {}>;
|
|
76
76
|
date: () => FieldBuilder<string, {}>;
|
|
77
|
+
/** A UTC instant to the minute, "2026-09-02T14:30:00Z" (spec 2026-09-25 date pickers §1). */
|
|
78
|
+
datetime: () => FieldBuilder<string, {}>;
|
|
77
79
|
number: () => FieldBuilder<number, {}>;
|
|
78
80
|
/** `aspect`: one `"w:h"` or several the crop tool may lock to — a per-use
|
|
79
81
|
* constraint, so the component knows its box before the image loads. */
|
package/dist/fields.js
CHANGED
|
@@ -81,6 +81,8 @@ export const f = {
|
|
|
81
81
|
link: () => builder({ type: "link" }),
|
|
82
82
|
slug: (opts) => builder({ type: "slug", ...(opts?.from ? { from: opts.from } : {}) }),
|
|
83
83
|
date: () => builder({ type: "date" }),
|
|
84
|
+
/** A UTC instant to the minute, "2026-09-02T14:30:00Z" (spec 2026-09-25 date pickers §1). */
|
|
85
|
+
datetime: () => builder({ type: "datetime" }),
|
|
84
86
|
number: () => builder({ type: "number" }),
|
|
85
87
|
/** `aspect`: one `"w:h"` or several the crop tool may lock to — a per-use
|
|
86
88
|
* constraint, so the component knows its box before the image loads. */
|
package/dist/index.d.ts
CHANGED
|
@@ -27,15 +27,18 @@ export type { Crop, Hotspot, AssetKind, ImageValue, VideoValue, FileValue, Asset
|
|
|
27
27
|
export { z } from "./zones.ts";
|
|
28
28
|
export { defineConfig } from "./config.ts";
|
|
29
29
|
export { collectionSQL, coreTablesSQL } from "./sql.ts";
|
|
30
|
+
export { SCHEMA_VERSION, SCHEMA_VERSION_KEY, schemaVersionSQL, coreFileVersion, schemaBehind, schemaBehindMessage } from "./schema-version.ts";
|
|
30
31
|
export { MemoryPageStore } from "./store.ts";
|
|
31
32
|
export { MemoryAssetStore, assetErrors, kindOf, extensionFor, objectPath, withDownload, PAGE_SIZE as ASSET_PAGE_SIZE } from "./asset-store.ts";
|
|
32
33
|
export type { AssetStore, UploadTicket, FinishInput, AssetQuery, AssetPage, BeginUploadInput, CreateAssetInput } from "./asset-store.ts";
|
|
33
34
|
export { imageDimensions } from "./image-dimensions.ts";
|
|
34
35
|
export { SupabasePageStore } from "./supabase-store.ts";
|
|
35
36
|
export { SupabaseAssetStore } from "./supabase-asset-store.ts";
|
|
36
|
-
export { MemoryMemberStore, sortMembers } from "./member-store.ts";
|
|
37
|
-
export type { MemberStore, MemberRecord } from "./member-store.ts";
|
|
37
|
+
export { MemoryMemberStore, sortMembers, memberErrors } from "./member-store.ts";
|
|
38
|
+
export type { MemberStore, MemberRecord, InviteInput, InviteResult } from "./member-store.ts";
|
|
38
39
|
export { SupabaseMemberStore } from "./supabase-member-store.ts";
|
|
40
|
+
export { MEMBER_ROLES, can, normalizeRole, isMemberRole } from "./permissions.ts";
|
|
41
|
+
export type { MemberRole, Capability } from "./permissions.ts";
|
|
39
42
|
export { collectEntryRefs, collectTreeRefs } from "./refs.ts";
|
|
40
43
|
export type { RefEdge, SectionLike } from "./refs.ts";
|
|
41
44
|
export { richtextBlocks, elementSchemas, IMAGE_NODE, ELEMENT_NODE } from "./richtext-walk.ts";
|
|
@@ -148,7 +151,7 @@ export declare const smoodly: {
|
|
|
148
151
|
Section: typeof Section;
|
|
149
152
|
marks: typeof marks;
|
|
150
153
|
};
|
|
151
|
-
export { NODE_ATTR, ZONE_ATTR, EMPTY_ATTR, FIELD_ATTR, ITEM_ATTR, EDITOR_ATTR, resolveMarkPath } from "./marks.ts";
|
|
154
|
+
export { NODE_ATTR, ZONE_ATTR, EMPTY_ATTR, BLANK_ATTR, FIELD_ATTR, ITEM_ATTR, EDITOR_ATTR, resolveMarkPath } from "./marks.ts";
|
|
152
155
|
export type { MarkNode, MarkPath, Marks, Scope } from "./marks.ts";
|
|
153
156
|
export { resolveSmoodlyEnv, smoodlyEnv } from "./env.ts";
|
|
154
157
|
export type { SmoodlyEnv } from "./env.ts";
|
package/dist/index.js
CHANGED
|
@@ -21,13 +21,15 @@ export { ASSET_COLLECTION, DEFAULT_MEDIA, MEDIA_TYPES, FULL_CROP, CENTER_HOTSPOT
|
|
|
21
21
|
export { z } from "./zones.js";
|
|
22
22
|
export { defineConfig } from "./config.js";
|
|
23
23
|
export { collectionSQL, coreTablesSQL } from "./sql.js";
|
|
24
|
+
export { SCHEMA_VERSION, SCHEMA_VERSION_KEY, schemaVersionSQL, coreFileVersion, schemaBehind, schemaBehindMessage } from "./schema-version.js";
|
|
24
25
|
export { MemoryPageStore } from "./store.js";
|
|
25
26
|
export { MemoryAssetStore, assetErrors, kindOf, extensionFor, objectPath, withDownload, PAGE_SIZE as ASSET_PAGE_SIZE } from "./asset-store.js";
|
|
26
27
|
export { imageDimensions } from "./image-dimensions.js";
|
|
27
28
|
export { SupabasePageStore } from "./supabase-store.js";
|
|
28
29
|
export { SupabaseAssetStore } from "./supabase-asset-store.js";
|
|
29
|
-
export { MemoryMemberStore, sortMembers } from "./member-store.js";
|
|
30
|
+
export { MemoryMemberStore, sortMembers, memberErrors } from "./member-store.js";
|
|
30
31
|
export { SupabaseMemberStore } from "./supabase-member-store.js";
|
|
32
|
+
export { MEMBER_ROLES, can, normalizeRole, isMemberRole } from "./permissions.js";
|
|
31
33
|
export { collectEntryRefs, collectTreeRefs } from "./refs.js";
|
|
32
34
|
export { richtextBlocks, elementSchemas, IMAGE_NODE, ELEMENT_NODE } from "./richtext-walk.js";
|
|
33
35
|
export { MemoryRefIndex } from "./ref-index.js";
|
|
@@ -60,7 +62,7 @@ export const smoodly = {
|
|
|
60
62
|
Section,
|
|
61
63
|
marks,
|
|
62
64
|
};
|
|
63
|
-
export { NODE_ATTR, ZONE_ATTR, EMPTY_ATTR, FIELD_ATTR, ITEM_ATTR, EDITOR_ATTR, resolveMarkPath } from "./marks.js";
|
|
65
|
+
export { NODE_ATTR, ZONE_ATTR, EMPTY_ATTR, BLANK_ATTR, FIELD_ATTR, ITEM_ATTR, EDITOR_ATTR, resolveMarkPath } from "./marks.js";
|
|
64
66
|
export { resolveSmoodlyEnv, smoodlyEnv } from "./env.js";
|
|
65
67
|
export { clientOptions, createSmoodlyClient } from "./client.js";
|
|
66
68
|
export { createSmoodlySite, pageContextOf } from "./site.js";
|
package/dist/marks.d.ts
CHANGED
|
@@ -4,6 +4,9 @@ export declare const NODE_ATTR = "data-smd-node";
|
|
|
4
4
|
/** An empty zone's placeholder box, from `<Zone>` in editor mode. */
|
|
5
5
|
export declare const ZONE_ATTR = "data-smd-zone";
|
|
6
6
|
export declare const EMPTY_ATTR = "data-smd-empty";
|
|
7
|
+
/** A section that renders no text and no media, stamped by the bridge in
|
|
8
|
+
* editor mode so the canvas can give it a height to see and select. */
|
|
9
|
+
export declare const BLANK_ATTR = "data-smd-blank";
|
|
7
10
|
/** The element that renders a field's value verbatim. */
|
|
8
11
|
export declare const FIELD_ATTR = "data-smd-field";
|
|
9
12
|
/** A list item's or object field's root: scopes the field names inside it. */
|
package/dist/marks.js
CHANGED
|
@@ -3,6 +3,9 @@ export const NODE_ATTR = "data-smd-node";
|
|
|
3
3
|
/** An empty zone's placeholder box, from `<Zone>` in editor mode. */
|
|
4
4
|
export const ZONE_ATTR = "data-smd-zone";
|
|
5
5
|
export const EMPTY_ATTR = "data-smd-empty";
|
|
6
|
+
/** A section that renders no text and no media, stamped by the bridge in
|
|
7
|
+
* editor mode so the canvas can give it a height to see and select. */
|
|
8
|
+
export const BLANK_ATTR = "data-smd-blank";
|
|
6
9
|
/** The element that renders a field's value verbatim. */
|
|
7
10
|
export const FIELD_ATTR = "data-smd-field";
|
|
8
11
|
/** A list item's or object field's root: scopes the field names inside it. */
|
package/dist/member-store.d.ts
CHANGED
|
@@ -1,20 +1,49 @@
|
|
|
1
|
+
import type { MemberRole } from "./permissions.ts";
|
|
1
2
|
export type MemberRecord = {
|
|
2
3
|
userId: string;
|
|
3
4
|
email: string | null;
|
|
4
5
|
role: string;
|
|
5
6
|
};
|
|
7
|
+
export type InviteInput = {
|
|
8
|
+
email: string;
|
|
9
|
+
role: MemberRole;
|
|
10
|
+
redirectTo: string;
|
|
11
|
+
};
|
|
12
|
+
/** `invited` false = the email already had a confirmed account, so no mail was sent; the row was added. (A pending invitee is re-invited: same account, mail again, `invited` true.) */
|
|
13
|
+
export type InviteResult = {
|
|
14
|
+
member: MemberRecord;
|
|
15
|
+
invited: boolean;
|
|
16
|
+
};
|
|
6
17
|
export interface MemberStore {
|
|
7
18
|
/** Every member of the current space, by email then user id. */
|
|
8
19
|
list(): Promise<MemberRecord[]>;
|
|
9
20
|
/** The space's console URL when the control plane manages its members; null in self-host. */
|
|
10
21
|
consoleUrl(): Promise<string | null>;
|
|
22
|
+
/** Creates or finds the Auth user for the email, mails the invite when it created one, upserts the row (spec 2026-09-25 members management §3). */
|
|
23
|
+
invite(input: InviteInput): Promise<InviteResult>;
|
|
24
|
+
setRole(userId: string, role: MemberRole): Promise<MemberRecord>;
|
|
25
|
+
/** Deletes the member row only; the Auth account stays. */
|
|
26
|
+
remove(userId: string): Promise<void>;
|
|
11
27
|
}
|
|
28
|
+
export declare const memberErrors: {
|
|
29
|
+
notFound: (userId: string) => Error;
|
|
30
|
+
noAccount: (email: string) => Error;
|
|
31
|
+
};
|
|
12
32
|
/** Email first (case-insensitive), rows without one last, ties by user id. */
|
|
13
33
|
export declare function sortMembers(rows: MemberRecord[]): MemberRecord[];
|
|
14
34
|
export declare class MemoryMemberStore implements MemberStore {
|
|
15
35
|
private rows;
|
|
16
36
|
private url;
|
|
17
|
-
|
|
37
|
+
/** Auth accounts that exist without a member row: email → user id. */
|
|
38
|
+
private accounts;
|
|
39
|
+
/** Emails an invite was "sent" to, in order — what a test asserts. */
|
|
40
|
+
readonly sent: string[];
|
|
41
|
+
constructor(rows?: MemberRecord[], url?: string | null,
|
|
42
|
+
/** Auth accounts that exist without a member row: email → user id. */
|
|
43
|
+
accounts?: Record<string, string>);
|
|
18
44
|
list(): Promise<MemberRecord[]>;
|
|
19
45
|
consoleUrl(): Promise<string | null>;
|
|
46
|
+
invite(input: InviteInput): Promise<InviteResult>;
|
|
47
|
+
setRole(userId: string, role: MemberRole): Promise<MemberRecord>;
|
|
48
|
+
remove(userId: string): Promise<void>;
|
|
20
49
|
}
|
package/dist/member-store.js
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
// the rows (a cloud space) — no row, no URL, self-host.
|
|
1
|
+
export const memberErrors = {
|
|
2
|
+
notFound: (userId) => new Error(`smoodly: no member "${userId}".`),
|
|
3
|
+
noAccount: (email) => new Error(`smoodly: no Auth account for "${email}" although the invite said it exists.`),
|
|
4
|
+
};
|
|
6
5
|
/** Email first (case-insensitive), rows without one last, ties by user id. */
|
|
7
6
|
export function sortMembers(rows) {
|
|
8
7
|
return [...rows].sort((a, b) => {
|
|
@@ -16,9 +15,14 @@ export function sortMembers(rows) {
|
|
|
16
15
|
});
|
|
17
16
|
}
|
|
18
17
|
export class MemoryMemberStore {
|
|
19
|
-
constructor(rows = [], url = null
|
|
18
|
+
constructor(rows = [], url = null,
|
|
19
|
+
/** Auth accounts that exist without a member row: email → user id. */
|
|
20
|
+
accounts = {}) {
|
|
20
21
|
this.rows = rows;
|
|
21
22
|
this.url = url;
|
|
23
|
+
this.accounts = accounts;
|
|
24
|
+
/** Emails an invite was "sent" to, in order — what a test asserts. */
|
|
25
|
+
this.sent = [];
|
|
22
26
|
}
|
|
23
27
|
async list() {
|
|
24
28
|
return sortMembers(this.rows);
|
|
@@ -26,4 +30,31 @@ export class MemoryMemberStore {
|
|
|
26
30
|
async consoleUrl() {
|
|
27
31
|
return this.url;
|
|
28
32
|
}
|
|
33
|
+
async invite(input) {
|
|
34
|
+
const key = input.email.toLowerCase();
|
|
35
|
+
let userId = this.accounts[key] ?? this.rows.find((r) => r.email?.toLowerCase() === key)?.userId;
|
|
36
|
+
let invited = false;
|
|
37
|
+
if (!userId) {
|
|
38
|
+
userId = crypto.randomUUID();
|
|
39
|
+
this.accounts[key] = userId;
|
|
40
|
+
this.sent.push(input.email);
|
|
41
|
+
invited = true;
|
|
42
|
+
}
|
|
43
|
+
const member = { userId, email: input.email, role: input.role };
|
|
44
|
+
this.rows = [...this.rows.filter((r) => r.userId !== userId), member];
|
|
45
|
+
return { member, invited };
|
|
46
|
+
}
|
|
47
|
+
async setRole(userId, role) {
|
|
48
|
+
const row = this.rows.find((r) => r.userId === userId);
|
|
49
|
+
if (!row)
|
|
50
|
+
throw memberErrors.notFound(userId);
|
|
51
|
+
const next = { ...row, role };
|
|
52
|
+
this.rows = this.rows.map((r) => (r.userId === userId ? next : r));
|
|
53
|
+
return next;
|
|
54
|
+
}
|
|
55
|
+
async remove(userId) {
|
|
56
|
+
if (!this.rows.some((r) => r.userId === userId))
|
|
57
|
+
throw memberErrors.notFound(userId);
|
|
58
|
+
this.rows = this.rows.filter((r) => r.userId !== userId);
|
|
59
|
+
}
|
|
29
60
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export type MigrationFile = {
|
|
2
|
+
name: string;
|
|
3
|
+
text: string;
|
|
4
|
+
};
|
|
5
|
+
export type CollectionSQL = {
|
|
6
|
+
name: string;
|
|
7
|
+
sql: string;
|
|
8
|
+
};
|
|
9
|
+
export declare const MARKER_PREFIX = "-- smoodly:collection ";
|
|
10
|
+
/** The first 16 hex chars of sha256 over the exact emitted SQL. */
|
|
11
|
+
export declare const collectionHash: (sql: string) => string;
|
|
12
|
+
export declare const collectionMarker: (c: CollectionSQL) => string;
|
|
13
|
+
/** name → hash, the LAST marker per collection across the files in name order. */
|
|
14
|
+
export declare function parseMarkers(files: MigrationFile[]): Map<string, string>;
|
|
15
|
+
/** UTC yyyyMMddHHmmss — the Supabase CLI's file-name prefix. */
|
|
16
|
+
export declare const stamp: (at: Date) => string;
|
|
17
|
+
/** A views file must sort after every core file it may depend on and
|
|
18
|
+
* after any earlier views file it supersedes: the later of `now` and the
|
|
19
|
+
* newest own stamp plus a minute. */
|
|
20
|
+
export declare function viewsStamp(names: string[], now: Date): Date;
|
|
21
|
+
/** One file with a block per new or changed collection; null when none changed. */
|
|
22
|
+
export declare function viewsFile(collections: CollectionSQL[], existing: Map<string, string>, at: Date): MigrationFile | null;
|
|
23
|
+
export type CorePending = {
|
|
24
|
+
copy: MigrationFile[];
|
|
25
|
+
modified: string[];
|
|
26
|
+
};
|
|
27
|
+
/** Package files the app lacks (copy) and same-named files whose text differs (modified — never overwritten). */
|
|
28
|
+
export declare function pendingCoreFiles(packageFiles: MigrationFile[], appFiles: MigrationFile[]): CorePending;
|
|
29
|
+
export type MigratePlan = CorePending & {
|
|
30
|
+
views: MigrationFile | null;
|
|
31
|
+
};
|
|
32
|
+
export declare function planMigrate(input: {
|
|
33
|
+
packageFiles: MigrationFile[];
|
|
34
|
+
appFiles: MigrationFile[];
|
|
35
|
+
collections: CollectionSQL[];
|
|
36
|
+
}, now: Date): MigratePlan;
|
|
37
|
+
export declare const planIsEmpty: (plan: MigratePlan) => boolean;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// packages/smoodly/src/migrations.ts
|
|
2
|
+
// What `npx smoodly migrate` decides, as pure functions over file lists
|
|
3
|
+
// (spec 2026-09-26 §2–§3): which package files the app lacks, which
|
|
4
|
+
// collections need a fresh view block, and the one views file to write.
|
|
5
|
+
// Node-only (crypto): exposed at `smoodly/migrations`, never from the
|
|
6
|
+
// root index, which the client bundle reaches through the config.
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
export const MARKER_PREFIX = "-- smoodly:collection ";
|
|
9
|
+
const MARKER_RE = /^-- smoodly:collection (\S+) sha256:([0-9a-f]+)\s*$/gm;
|
|
10
|
+
const OWN_FILE_RE = /^(\d{14})_smoodly_(v\d+|views)\.sql$/;
|
|
11
|
+
/** The first 16 hex chars of sha256 over the exact emitted SQL. */
|
|
12
|
+
export const collectionHash = (sql) => createHash("sha256").update(sql).digest("hex").slice(0, 16);
|
|
13
|
+
export const collectionMarker = (c) => `${MARKER_PREFIX}${c.name} sha256:${collectionHash(c.sql)}`;
|
|
14
|
+
/** name → hash, the LAST marker per collection across the files in name order. */
|
|
15
|
+
export function parseMarkers(files) {
|
|
16
|
+
const out = new Map();
|
|
17
|
+
for (const file of [...files].sort((a, b) => a.name.localeCompare(b.name))) {
|
|
18
|
+
for (const m of file.text.matchAll(MARKER_RE))
|
|
19
|
+
out.set(m[1], m[2]);
|
|
20
|
+
}
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
23
|
+
/** UTC yyyyMMddHHmmss — the Supabase CLI's file-name prefix. */
|
|
24
|
+
export const stamp = (at) => at.toISOString().replace(/\D/g, "").slice(0, 14);
|
|
25
|
+
const stampDate = (s) => new Date(Date.UTC(+s.slice(0, 4), +s.slice(4, 6) - 1, +s.slice(6, 8), +s.slice(8, 10), +s.slice(10, 12), +s.slice(12, 14)));
|
|
26
|
+
/** A views file must sort after every core file it may depend on and
|
|
27
|
+
* after any earlier views file it supersedes: the later of `now` and the
|
|
28
|
+
* newest own stamp plus a minute. */
|
|
29
|
+
export function viewsStamp(names, now) {
|
|
30
|
+
let at = now;
|
|
31
|
+
for (const name of names) {
|
|
32
|
+
const m = OWN_FILE_RE.exec(name);
|
|
33
|
+
if (!m)
|
|
34
|
+
continue;
|
|
35
|
+
const after = new Date(stampDate(m[1]).getTime() + 60000);
|
|
36
|
+
if (after > at)
|
|
37
|
+
at = after;
|
|
38
|
+
}
|
|
39
|
+
return at;
|
|
40
|
+
}
|
|
41
|
+
/** One file with a block per new or changed collection; null when none changed. */
|
|
42
|
+
export function viewsFile(collections, existing, at) {
|
|
43
|
+
const changed = collections.filter((c) => existing.get(c.name) !== collectionHash(c.sql));
|
|
44
|
+
if (changed.length === 0)
|
|
45
|
+
return null;
|
|
46
|
+
const text = "-- Smoodly: read views and order indexes for this app's collections, written by `npx smoodly migrate`\n" +
|
|
47
|
+
`-- (${at.toISOString()}). One block per collection; the marker's hash is the block's SQL, compared on the next run.\n` +
|
|
48
|
+
"-- Never edit a written file: change the collection and run migrate again.\n\n" +
|
|
49
|
+
changed.map((c) => `${collectionMarker(c)}\n${c.sql.trim()}`).join("\n\n") +
|
|
50
|
+
"\n";
|
|
51
|
+
return { name: `${stamp(at)}_smoodly_views.sql`, text };
|
|
52
|
+
}
|
|
53
|
+
/** Package files the app lacks (copy) and same-named files whose text differs (modified — never overwritten). */
|
|
54
|
+
export function pendingCoreFiles(packageFiles, appFiles) {
|
|
55
|
+
const app = new Map(appFiles.map((f) => [f.name, f.text]));
|
|
56
|
+
const copy = [];
|
|
57
|
+
const modified = [];
|
|
58
|
+
for (const file of [...packageFiles].sort((a, b) => a.name.localeCompare(b.name))) {
|
|
59
|
+
const have = app.get(file.name);
|
|
60
|
+
if (have === undefined)
|
|
61
|
+
copy.push(file);
|
|
62
|
+
else if (have !== file.text)
|
|
63
|
+
modified.push(file.name);
|
|
64
|
+
}
|
|
65
|
+
return { copy, modified };
|
|
66
|
+
}
|
|
67
|
+
export function planMigrate(input, now) {
|
|
68
|
+
const core = pendingCoreFiles(input.packageFiles, input.appFiles);
|
|
69
|
+
const names = [...input.packageFiles, ...input.appFiles].map((f) => f.name);
|
|
70
|
+
const views = viewsFile(input.collections, parseMarkers(input.appFiles), viewsStamp(names, now));
|
|
71
|
+
return { ...core, views };
|
|
72
|
+
}
|
|
73
|
+
export const planIsEmpty = (plan) => plan.copy.length === 0 && plan.modified.length === 0 && plan.views === null;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare const MEMBER_ROLES: readonly ["admin", "editor"];
|
|
2
|
+
export type MemberRole = (typeof MEMBER_ROLES)[number];
|
|
3
|
+
export type Capability = "content.write" | "settings" | "members.manage";
|
|
4
|
+
export declare function can(role: MemberRole, capability: Capability): boolean;
|
|
5
|
+
export declare const isMemberRole: (v: unknown) => v is MemberRole;
|
|
6
|
+
/** What the table holds → a role: `owner` (the pre-2026-09-25 word) is admin; anything unknown reads as editor, the safer side. */
|
|
7
|
+
export declare function normalizeRole(stored: string): MemberRole;
|