smoodly 0.0.24 → 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 +182 -40
- package/bin/smoodly.js +6 -0
- package/dist/admin/client-ops.js +1 -0
- 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 +1 -1
- 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/next/bridge.d.ts +2 -1
- package/dist/admin/next/bridge.js +35 -5
- package/dist/admin/ops-impl.js +4 -0
- package/dist/admin/ops.d.ts +9 -0
- package/dist/admin/serialize.d.ts +2 -0
- package/dist/admin/serialize.js +1 -0
- package/dist/admin/shell/AdminApp.js +23 -1
- package/dist/admin/shell/EntriesList.js +12 -21
- package/dist/admin/shell/EntryForm.js +48 -7
- package/dist/admin/shell/GlobalForm.js +22 -2
- package/dist/admin/shell/GlobalList.js +4 -18
- package/dist/admin/shell/IconRail.js +14 -5
- package/dist/admin/shell/InviteModal.js +9 -5
- package/dist/admin/shell/ListView.js +2 -2
- package/dist/admin/shell/MediaScreen.js +11 -1
- package/dist/admin/shell/MembersPage.js +33 -15
- package/dist/admin/shell/PageDialogs.d.ts +26 -0
- package/dist/admin/shell/PageDialogs.js +38 -0
- package/dist/admin/shell/PagesList.js +96 -73
- 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/SettingsView.js +11 -26
- package/dist/admin/shell/live-url.d.ts +4 -3
- package/dist/admin/shell/live-url.js +12 -3
- 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 +2 -0
- package/dist/admin/shell/members-list.js +5 -0
- 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/settings.d.ts +0 -6
- package/dist/admin/shell/settings.js +4 -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 +2 -1
- package/dist/index.js +2 -1
- package/dist/marks.d.ts +3 -0
- package/dist/marks.js +3 -0
- package/dist/migrations.d.ts +37 -0
- package/dist/migrations.js +73 -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-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,6 +27,7 @@ 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";
|
|
@@ -150,7 +151,7 @@ export declare const smoodly: {
|
|
|
150
151
|
Section: typeof Section;
|
|
151
152
|
marks: typeof marks;
|
|
152
153
|
};
|
|
153
|
-
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";
|
|
154
155
|
export type { MarkNode, MarkPath, Marks, Scope } from "./marks.ts";
|
|
155
156
|
export { resolveSmoodlyEnv, smoodlyEnv } from "./env.ts";
|
|
156
157
|
export type { SmoodlyEnv } from "./env.ts";
|
package/dist/index.js
CHANGED
|
@@ -21,6 +21,7 @@ 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";
|
|
@@ -61,7 +62,7 @@ export const smoodly = {
|
|
|
61
62
|
Section,
|
|
62
63
|
marks,
|
|
63
64
|
};
|
|
64
|
-
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";
|
|
65
66
|
export { resolveSmoodlyEnv, smoodlyEnv } from "./env.js";
|
|
66
67
|
export { clientOptions, createSmoodlyClient } from "./client.js";
|
|
67
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. */
|
|
@@ -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,17 @@
|
|
|
1
|
+
/** The minimum schema version the package needs. Moves ONLY in a release
|
|
2
|
+
* that adds a table, column, function or policy the package reads; a
|
|
3
|
+
* unit test holds it equal to the highest file in `migrations/`. */
|
|
4
|
+
export declare const SCHEMA_VERSION = 1;
|
|
5
|
+
export declare const SCHEMA_VERSION_KEY = "schema_version";
|
|
6
|
+
export declare const CORE_FILE_RE: RegExp;
|
|
7
|
+
export declare const VIEWS_FILE_RE: RegExp;
|
|
8
|
+
/** The version a core file leaves behind, from its name; null for anything else. */
|
|
9
|
+
export declare function coreFileVersion(name: string): number | null;
|
|
10
|
+
/** The last statement of every core file. */
|
|
11
|
+
export declare function schemaVersionSQL(version: number): string;
|
|
12
|
+
/** Behind blocks the admin and warns on the site (§5). Null = no
|
|
13
|
+
* `smoodly_meta` table, a database older than version 1. Ahead is the
|
|
14
|
+
* normal state of a cloud app between our migration and its package
|
|
15
|
+
* bump, and is fine. */
|
|
16
|
+
export declare const schemaBehind: (database: number | null, required?: number) => boolean;
|
|
17
|
+
export declare function schemaBehindMessage(database: number | null, required?: number): string;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// packages/smoodly/src/schema-version.ts
|
|
2
|
+
// The schema version this package needs (spec 2026-09-26 §1) and the two
|
|
3
|
+
// things every side agrees on: the `smoodly_meta` row that records a
|
|
4
|
+
// database's version, and the names of the files the package ships. Pure
|
|
5
|
+
// and framework-free — the SQL emitters, the CLI, the stores, the admin
|
|
6
|
+
// op and the shell all read it from here.
|
|
7
|
+
/** The minimum schema version the package needs. Moves ONLY in a release
|
|
8
|
+
* that adds a table, column, function or policy the package reads; a
|
|
9
|
+
* unit test holds it equal to the highest file in `migrations/`. */
|
|
10
|
+
export const SCHEMA_VERSION = 1;
|
|
11
|
+
export const SCHEMA_VERSION_KEY = "schema_version";
|
|
12
|
+
export const CORE_FILE_RE = /^(\d{14})_smoodly_v(\d+)\.sql$/;
|
|
13
|
+
export const VIEWS_FILE_RE = /^(\d{14})_smoodly_views\.sql$/;
|
|
14
|
+
/** The version a core file leaves behind, from its name; null for anything else. */
|
|
15
|
+
export function coreFileVersion(name) {
|
|
16
|
+
const m = CORE_FILE_RE.exec(name);
|
|
17
|
+
return m ? Number(m[2]) : null;
|
|
18
|
+
}
|
|
19
|
+
/** The last statement of every core file. */
|
|
20
|
+
export function schemaVersionSQL(version) {
|
|
21
|
+
return (`insert into "smoodly_meta" ("key", "value") values ('${SCHEMA_VERSION_KEY}', '${version}')\n` +
|
|
22
|
+
` on conflict ("key") do update set "value" = excluded."value";`);
|
|
23
|
+
}
|
|
24
|
+
/** Behind blocks the admin and warns on the site (§5). Null = no
|
|
25
|
+
* `smoodly_meta` table, a database older than version 1. Ahead is the
|
|
26
|
+
* normal state of a cloud app between our migration and its package
|
|
27
|
+
* bump, and is fine. */
|
|
28
|
+
export const schemaBehind = (database, required = SCHEMA_VERSION) => database === null || database < required;
|
|
29
|
+
export function schemaBehindMessage(database, required = SCHEMA_VERSION) {
|
|
30
|
+
return (`smoodly: the database schema is at version ${database ?? "none"}; this Smoodly needs version ${required}. ` +
|
|
31
|
+
"Run `npx smoodly migrate`, then `supabase db push` (hosted) or `supabase db reset` (local).");
|
|
32
|
+
}
|
package/dist/sql-space.js
CHANGED
|
@@ -193,6 +193,12 @@ $$;`.trim());
|
|
|
193
193
|
statements.push(`drop policy if exists "smoodly_read" on "spaces";\n` +
|
|
194
194
|
`create policy "smoodly_read" on "spaces" for select\n` +
|
|
195
195
|
` using (${write});`);
|
|
196
|
+
// The package reads its schema version with the write key (the
|
|
197
|
+
// service role bypasses anyway); nothing else sees the table. No
|
|
198
|
+
// space_id: the schema is one per database (spec 2026-09-26 §1).
|
|
199
|
+
statements.push(`drop policy if exists "smoodly_read" on "smoodly_meta";\n` +
|
|
200
|
+
`create policy "smoodly_read" on "smoodly_meta" for select\n` +
|
|
201
|
+
` using ((select "smoodly_key_kind"()) = 'write' and not (select "smoodly_space_key_revoked"()));`);
|
|
196
202
|
statements.push(storageSQL());
|
|
197
203
|
return statements.join("\n\n");
|
|
198
204
|
}
|
package/dist/sql.js
CHANGED
|
@@ -286,6 +286,15 @@ $$;
|
|
|
286
286
|
-- to anything but a write key. The generated per-collection views
|
|
287
287
|
-- (collectionSQL) run with security_invoker = true, so they are subject
|
|
288
288
|
-- to this same smoodly_read policy rather than their owner's privileges.
|
|
289
|
+
-- smoodly_meta: which version of this schema the database holds (spec
|
|
290
|
+
-- 2026-09-26 §1). One row per database, not per space; every core
|
|
291
|
+
-- migration file upserts the version row as its last statement, and the
|
|
292
|
+
-- package compares it with SCHEMA_VERSION (behind blocks the admin).
|
|
293
|
+
create table if not exists "smoodly_meta" (
|
|
294
|
+
"key" text primary key,
|
|
295
|
+
"value" text not null
|
|
296
|
+
);
|
|
297
|
+
|
|
289
298
|
alter table "pages" enable row level security;
|
|
290
299
|
alter table "page_locales" enable row level security;
|
|
291
300
|
alter table "page_versions" enable row level security;
|
|
@@ -295,6 +304,7 @@ alter table "entry_versions" enable row level security;
|
|
|
295
304
|
alter table "refs" enable row level security;
|
|
296
305
|
alter table "paths" enable row level security;
|
|
297
306
|
alter table "assets" enable row level security;
|
|
307
|
+
alter table "smoodly_meta" enable row level security;
|
|
298
308
|
`.trim(),
|
|
299
309
|
spaceLayerSQL(),
|
|
300
310
|
].join("\n\n");
|
package/dist/store.d.ts
CHANGED
|
@@ -54,6 +54,8 @@ export interface PageStore {
|
|
|
54
54
|
getPage(id: string): Promise<PageRecord | null>;
|
|
55
55
|
listPages(): Promise<PageRecord[]>;
|
|
56
56
|
resolvePath(locale: string, path: string): Promise<PathTarget | null>;
|
|
57
|
+
/** The database's schema version from `smoodly_meta`; null when the table is missing (older than v1). Memory: always current (spec 2026-09-26 §5). */
|
|
58
|
+
schemaVersion(): Promise<number | null>;
|
|
57
59
|
/** locale → path, for the locales the page exists in. */
|
|
58
60
|
pathsOf(id: string): Promise<Record<string, string>>;
|
|
59
61
|
/** Refused when the page already has the locale or its parent lacks it (the parent rule). */
|
|
@@ -194,6 +196,7 @@ export declare class MemoryPageStore implements PageStore {
|
|
|
194
196
|
getPage(id: string): Promise<PageRecord | null>;
|
|
195
197
|
listPages(): Promise<PageRecord[]>;
|
|
196
198
|
resolvePath(locale: string, path: string): Promise<PathTarget | null>;
|
|
199
|
+
schemaVersion(): Promise<number>;
|
|
197
200
|
pathsOf(id: string): Promise<Record<string, string>>;
|
|
198
201
|
addLocale(id: string, locale: string, options: AddLocaleOptions, by?: string): Promise<PageRecord>;
|
|
199
202
|
removeLocale(id: string, locale: string): Promise<PageRecord>;
|
package/dist/store.js
CHANGED
|
@@ -13,6 +13,7 @@ import { ASSET_COLLECTION } from "./media.js";
|
|
|
13
13
|
import { collectTreeRefs } from "./refs.js";
|
|
14
14
|
import { customToolset, withCustomSchema } from "./custom/index.js";
|
|
15
15
|
import { MemoryPathIndex } from "./path-index.js";
|
|
16
|
+
import { SCHEMA_VERSION } from "./schema-version.js";
|
|
16
17
|
import { assertDepth, assertSegment, assertSupportedLocale, chainOf, isHomeNode, pagePathRows, unsupportedLocale, } from "./paths.js";
|
|
17
18
|
export const DEFAULT_LOCALES = { default: "en", supported: ["en"] };
|
|
18
19
|
/** The section list every adapter walks refs with: the registered ones plus the custom section's schema (spec 2026-09-14 §6). */
|
|
@@ -266,6 +267,9 @@ export class MemoryPageStore {
|
|
|
266
267
|
resolvePath(locale, path) {
|
|
267
268
|
return this.paths.resolve(locale, path);
|
|
268
269
|
}
|
|
270
|
+
schemaVersion() {
|
|
271
|
+
return Promise.resolve(SCHEMA_VERSION);
|
|
272
|
+
}
|
|
269
273
|
pathsOf(id) {
|
|
270
274
|
return this.paths.pathsOf({ kind: "page", id });
|
|
271
275
|
}
|
package/dist/supabase-store.d.ts
CHANGED
|
@@ -48,7 +48,10 @@ export declare class SupabasePageStore implements PageStore {
|
|
|
48
48
|
createPage(input: CreatePageInput, by?: string): Promise<PageRecord>;
|
|
49
49
|
getPage(id: string): Promise<PageRecord | null>;
|
|
50
50
|
listPages(): Promise<PageRecord[]>;
|
|
51
|
+
/** The site's one warning per process (spec 2026-09-26 §5): fired by the first locate, never blocking. */
|
|
52
|
+
private schemaChecked;
|
|
51
53
|
resolvePath(locale: string, path: string): Promise<PathTarget | null>;
|
|
54
|
+
schemaVersion(): Promise<number | null>;
|
|
52
55
|
pathsOf(id: string): Promise<{}>;
|
|
53
56
|
addLocale(id: string, locale: string, options: AddLocaleOptions, by?: string): Promise<PageRecord>;
|
|
54
57
|
removeLocale(id: string, locale: string): Promise<PageRecord>;
|