smoodly 0.0.24 → 0.0.26
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 +214 -40
- package/bin/smoodly.js +6 -0
- package/dist/admin/client-ops.js +1 -0
- package/dist/admin/editor/EditorView.js +100 -22
- 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/autosave.d.ts +4 -1
- package/dist/admin/editor/autosave.js +24 -1
- package/dist/admin/editor/global-edit.d.ts +5 -1
- package/dist/admin/editor/global-edit.js +15 -5
- package/dist/admin/editor/locale-url.d.ts +7 -4
- package/dist/admin/editor/locale-url.js +14 -7
- package/dist/admin/editor/useGlobalEdit.d.ts +1 -1
- package/dist/admin/editor/useGlobalEdit.js +1 -1
- 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/next/create-admin.d.ts +4 -0
- package/dist/admin/next/create-admin.js +3 -1
- package/dist/admin/next/index.d.ts +1 -0
- package/dist/admin/ops-impl.js +74 -24
- package/dist/admin/ops.d.ts +31 -12
- package/dist/admin/serialize.d.ts +2 -0
- package/dist/admin/serialize.js +1 -0
- package/dist/admin/shell/AdminApp.d.ts +3 -1
- package/dist/admin/shell/AdminApp.js +42 -2
- package/dist/admin/shell/EntriesList.js +17 -22
- package/dist/admin/shell/EntryForm.js +91 -13
- package/dist/admin/shell/GlobalForm.js +68 -9
- package/dist/admin/shell/GlobalList.js +10 -20
- 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 +101 -74
- 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/presence-context.d.ts +9 -0
- package/dist/admin/shell/presence-context.js +35 -0
- package/dist/admin/shell/presence-realtime.d.ts +3 -0
- package/dist/admin/shell/presence-realtime.js +22 -0
- package/dist/admin/shell/presence.d.ts +51 -0
- package/dist/admin/shell/presence.js +90 -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/shell/stale.d.ts +28 -0
- package/dist/admin/shell/stale.js +17 -0
- 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/LockIcon.d.ts +4 -0
- package/dist/admin/ui/LockIcon.js +12 -0
- package/dist/admin/ui/PresenceLine.d.ts +6 -0
- package/dist/admin/ui/PresenceLine.js +11 -0
- package/dist/admin/ui/ResizeHandle.js +4 -4
- package/dist/admin/ui/StaleBanner.d.ts +5 -0
- package/dist/admin/ui/StaleBanner.js +9 -0
- 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 +12 -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 +373 -204
- 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/draft-token.d.ts +31 -0
- package/dist/draft-token.js +41 -0
- package/dist/entry-store.d.ts +11 -8
- package/dist/entry-store.js +24 -10
- package/dist/env.d.ts +9 -0
- package/dist/env.js +19 -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/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 +17 -0
- package/dist/sql.js +21 -0
- package/dist/store.d.ts +14 -8
- package/dist/store.js +30 -8
- package/dist/supabase-entry-store.d.ts +15 -5
- package/dist/supabase-entry-store.js +87 -22
- package/dist/supabase-store.d.ts +18 -5
- package/dist/supabase-store.js +83 -20
- package/dist/time-zone.d.ts +6 -0
- package/dist/time-zone.js +27 -0
- package/migrations/20260926120000_smoodly_v1.sql +535 -0
- package/migrations/20260926130000_smoodly_v2.sql +26 -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);
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export type DraftToken = {
|
|
2
|
+
versionId: string;
|
|
3
|
+
savedAt: string;
|
|
4
|
+
};
|
|
5
|
+
/** What a locale row carries for the token (both stores' row types satisfy it). */
|
|
6
|
+
export type DraftPointerRow = {
|
|
7
|
+
draftVersionId: string | null;
|
|
8
|
+
draftSavedAt: string | null;
|
|
9
|
+
};
|
|
10
|
+
/** The row's token, or null when it has no draft pointer (a folder, a page created without a tree). */
|
|
11
|
+
export declare function draftToken(row: DraftPointerRow): DraftToken | null;
|
|
12
|
+
export declare function sameToken(a: DraftToken | null, b: DraftToken | null): boolean;
|
|
13
|
+
/** An untrusted value is a token when it has the two strings and nothing is missing. */
|
|
14
|
+
export declare function isDraftToken(v: unknown): v is DraftToken;
|
|
15
|
+
export declare class StaleDraftError extends Error {
|
|
16
|
+
/** The row's token now — what a reload would get. */
|
|
17
|
+
readonly current: DraftToken | null;
|
|
18
|
+
/** The audit pair of the row: who wrote last, when. */
|
|
19
|
+
readonly by: string | null;
|
|
20
|
+
readonly at: number;
|
|
21
|
+
constructor(
|
|
22
|
+
/** The row's token now — what a reload would get. */
|
|
23
|
+
current: DraftToken | null,
|
|
24
|
+
/** The audit pair of the row: who wrote last, when. */
|
|
25
|
+
by: string | null, at: number);
|
|
26
|
+
}
|
|
27
|
+
/** The read-side check every guarded write runs first. `undefined` = no check. */
|
|
28
|
+
export declare function assertFresh(row: DraftPointerRow & {
|
|
29
|
+
updatedBy: string | null;
|
|
30
|
+
updatedAt: number;
|
|
31
|
+
}, expected: DraftToken | null | undefined): void;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// The draft token (spec 2026-09-26 concurrency §1): the locale row's
|
|
2
|
+
// draft pointer with that version's saved time. Every state change moves
|
|
3
|
+
// it — a save rewrites saved_at, a discard points at the published
|
|
4
|
+
// version, a restore creates a new draft. Pure and framework-free; both
|
|
5
|
+
// stores throw StaleDraftError, the ops map it, the admin freezes on it.
|
|
6
|
+
/** The row's token, or null when it has no draft pointer (a folder, a page created without a tree). */
|
|
7
|
+
export function draftToken(row) {
|
|
8
|
+
return row.draftVersionId === null ? null : { versionId: row.draftVersionId, savedAt: row.draftSavedAt ?? "" };
|
|
9
|
+
}
|
|
10
|
+
export function sameToken(a, b) {
|
|
11
|
+
if (a === null || b === null)
|
|
12
|
+
return a === b;
|
|
13
|
+
return a.versionId === b.versionId && a.savedAt === b.savedAt;
|
|
14
|
+
}
|
|
15
|
+
/** An untrusted value is a token when it has the two strings and nothing is missing. */
|
|
16
|
+
export function isDraftToken(v) {
|
|
17
|
+
return typeof v === "object" && v !== null && !Array.isArray(v)
|
|
18
|
+
&& typeof v.versionId === "string"
|
|
19
|
+
&& typeof v.savedAt === "string";
|
|
20
|
+
}
|
|
21
|
+
export class StaleDraftError extends Error {
|
|
22
|
+
constructor(
|
|
23
|
+
/** The row's token now — what a reload would get. */
|
|
24
|
+
current,
|
|
25
|
+
/** The audit pair of the row: who wrote last, when. */
|
|
26
|
+
by, at) {
|
|
27
|
+
super("smoodly: the draft changed since it was loaded — reload before saving.");
|
|
28
|
+
this.current = current;
|
|
29
|
+
this.by = by;
|
|
30
|
+
this.at = at;
|
|
31
|
+
this.name = "StaleDraftError";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** The read-side check every guarded write runs first. `undefined` = no check. */
|
|
35
|
+
export function assertFresh(row, expected) {
|
|
36
|
+
if (expected === undefined)
|
|
37
|
+
return;
|
|
38
|
+
const current = draftToken(row);
|
|
39
|
+
if (!sameToken(current, expected))
|
|
40
|
+
throw new StaleDraftError(current, row.updatedBy, row.updatedAt);
|
|
41
|
+
}
|
package/dist/entry-store.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { GlobalSchema } from "./global.ts";
|
|
|
3
3
|
import { type RefEdge, type SectionLike } from "./refs.ts";
|
|
4
4
|
import { MemoryRefIndex } from "./ref-index.ts";
|
|
5
5
|
import { type PathIndex } from "./path-index.ts";
|
|
6
|
+
import { type DraftToken } from "./draft-token.ts";
|
|
6
7
|
import { type LocaleSet, type PathTarget } from "./paths.ts";
|
|
7
8
|
export type EntryLocale = {
|
|
8
9
|
/** The entry's segment in this locale; null claims no URL. */
|
|
@@ -11,6 +12,8 @@ export type EntryLocale = {
|
|
|
11
12
|
/** ONLY the .localized() fields, for this locale. */
|
|
12
13
|
fields: Record<string, unknown>;
|
|
13
14
|
draftVersionId: string | null;
|
|
15
|
+
/** The draft version's saved time as the store's own text (spec 2026-09-26 concurrency §1). Null with no pointer. */
|
|
16
|
+
draftSavedAt: string | null;
|
|
14
17
|
publishedVersionId: string | null;
|
|
15
18
|
updatedAt: number;
|
|
16
19
|
/** The editor's email behind the last write that touched this row; null for the seed and scripts (spec 2026-09-13 history §2). */
|
|
@@ -104,14 +107,14 @@ export interface EntryStore {
|
|
|
104
107
|
}): Promise<Record<string, Record<string, unknown>>>;
|
|
105
108
|
/** Replaces the merged fields for `locale`: shared keys on the node
|
|
106
109
|
* (every locale sees them), localized keys and the slug on the row. */
|
|
107
|
-
update(collection: string, id: string, locale: string, fields: Record<string, unknown>, by?: string): Promise<EntryRecord>;
|
|
108
|
-
setStatus(collection: string, id: string, locale: string, status: "draft" | "published", by?: string): Promise<EntryRecord>;
|
|
110
|
+
update(collection: string, id: string, locale: string, fields: Record<string, unknown>, by?: string, expected?: DraftToken | null): Promise<EntryRecord>;
|
|
111
|
+
setStatus(collection: string, id: string, locale: string, status: "draft" | "published", by?: string, expected?: DraftToken | null): Promise<EntryRecord>;
|
|
109
112
|
/** The live row goes back to what is published: the published snapshot's fields
|
|
110
113
|
* (shared and localized, split as on save — the slug is never versioned) written over
|
|
111
114
|
* the row, the draft pointer set to the published one, the abandoned draft row deleted
|
|
112
115
|
* (spec 2026-09-13 history §1). Refused when the locale has no published version (a
|
|
113
116
|
* non-versioned collection never has one). */
|
|
114
|
-
discardDraft(collection: string, id: string, locale: string, by?: string): Promise<EntryRecord>;
|
|
117
|
+
discardDraft(collection: string, id: string, locale: string, by?: string, expected?: DraftToken | null): Promise<EntryRecord>;
|
|
115
118
|
/** Copies `from`'s localized fields and slug as the new row (shared
|
|
116
119
|
* fields need no copy); refused when the entry has the locale or its
|
|
117
120
|
* parent lacks it. */
|
|
@@ -124,7 +127,7 @@ export interface EntryStore {
|
|
|
124
127
|
/** The snapshot's shared and localized fields become the draft (the slug is never
|
|
125
128
|
* versioned) through the draft rule; the live version restores as a discard. Refused
|
|
126
129
|
* when the version is not this entry's in this locale (spec 2026-09-13 history §6). */
|
|
127
|
-
restore(collection: string, id: string, locale: string, versionId: string, by?: string): Promise<EntryRecord>;
|
|
130
|
+
restore(collection: string, id: string, locale: string, versionId: string, by?: string, expected?: DraftToken | null): Promise<EntryRecord>;
|
|
128
131
|
/** One stored version by id — callers check `entryId` and `locale`. */
|
|
129
132
|
getVersion(collection: string, versionId: string): Promise<EntryVersion | null>;
|
|
130
133
|
/** Blocked while anything references the entry (the safe-delete guard),
|
|
@@ -268,13 +271,13 @@ export declare class MemoryEntryStore implements EntryStore {
|
|
|
268
271
|
getMany(collection: string, ids: string[], locale: string, options?: {
|
|
269
272
|
status?: "published";
|
|
270
273
|
}): Promise<Record<string, Record<string, unknown>>>;
|
|
271
|
-
update(collection: string, id: string, locale: string, fields: Record<string, unknown>, by?: string): Promise<EntryRecord>;
|
|
272
|
-
setStatus(collection: string, id: string, locale: string, status: "draft" | "published", by?: string): Promise<EntryRecord>;
|
|
273
|
-
discardDraft(collection: string, id: string, locale: string, by?: string): Promise<EntryRecord>;
|
|
274
|
+
update(collection: string, id: string, locale: string, fields: Record<string, unknown>, by?: string, expected?: DraftToken | null): Promise<EntryRecord>;
|
|
275
|
+
setStatus(collection: string, id: string, locale: string, status: "draft" | "published", by?: string, expected?: DraftToken | null): Promise<EntryRecord>;
|
|
276
|
+
discardDraft(collection: string, id: string, locale: string, by?: string, expected?: DraftToken | null): Promise<EntryRecord>;
|
|
274
277
|
addLocale(collection: string, id: string, locale: string, options: AddEntryLocaleOptions, by?: string): Promise<EntryRecord>;
|
|
275
278
|
removeLocale(collection: string, id: string, locale: string): Promise<EntryRecord>;
|
|
276
279
|
listVersions(collection: string, id: string, locale: string): Promise<EntryVersion[]>;
|
|
277
|
-
restore(collection: string, id: string, locale: string, versionId: string, by?: string): Promise<EntryRecord>;
|
|
280
|
+
restore(collection: string, id: string, locale: string, versionId: string, by?: string, expected?: DraftToken | null): Promise<EntryRecord>;
|
|
278
281
|
getVersion(collection: string, versionId: string): Promise<EntryVersion | null>;
|
|
279
282
|
move(collection: string, id: string, to: {
|
|
280
283
|
parentId: string | null;
|
package/dist/entry-store.js
CHANGED
|
@@ -13,6 +13,7 @@ import { collectionDepth, collectionOrder } from "./collections.js";
|
|
|
13
13
|
import { collectEntryRefs } from "./refs.js";
|
|
14
14
|
import { MemoryRefIndex } from "./ref-index.js";
|
|
15
15
|
import { MemoryPathIndex } from "./path-index.js";
|
|
16
|
+
import { assertFresh } from "./draft-token.js";
|
|
16
17
|
import { globalEntryId } from "./global-id.js";
|
|
17
18
|
import { DEFAULT_SPACE_ID } from "./sql-space.js";
|
|
18
19
|
import { assertDepth, assertSupportedLocale, chainOf, entryPathRows, slugOf, unsupportedLocale, } from "./paths.js";
|
|
@@ -44,6 +45,11 @@ export function schemaOf(collections, name) {
|
|
|
44
45
|
export const isGlobalSchema = (schema) => schema.kind === "global";
|
|
45
46
|
let counter = 0;
|
|
46
47
|
const uid = (prefix) => `${prefix}-${++counter}-${Date.now().toString(36)}`;
|
|
48
|
+
/** A version's saved time: wall-clock, but strictly increasing, so two saves in one
|
|
49
|
+
* millisecond still move the draft token (spec 2026-09-26 concurrency §1) the way
|
|
50
|
+
* Postgres's microsecond `now()` does. */
|
|
51
|
+
let lastSavedAt = 0;
|
|
52
|
+
const savedClock = () => (lastSavedAt = Math.max(Date.now(), lastSavedAt + 1));
|
|
47
53
|
export const DEFAULT_ENTRY_LOCALES = { default: "en", supported: ["en"] };
|
|
48
54
|
export const entryErrors = {
|
|
49
55
|
notFound: (collection, id) => new Error(`smoodly: no entry "${id}" in "${collection}".`),
|
|
@@ -216,14 +222,16 @@ export class MemoryEntryStore {
|
|
|
216
222
|
const current = row.draftVersionId ? this.versions.get(row.draftVersionId) : undefined;
|
|
217
223
|
if (current && row.draftVersionId !== row.publishedVersionId) {
|
|
218
224
|
current.fields = fields;
|
|
219
|
-
current.savedAt =
|
|
225
|
+
current.savedAt = savedClock();
|
|
226
|
+
row.draftSavedAt = String(current.savedAt);
|
|
220
227
|
this.versionSeq.set(current.id, ++this.seq); // newest again
|
|
221
228
|
return;
|
|
222
229
|
}
|
|
223
|
-
const version = { id: uid("version"), entryId: entry.id, locale, fields, savedAt:
|
|
230
|
+
const version = { id: uid("version"), entryId: entry.id, locale, fields, savedAt: savedClock() };
|
|
224
231
|
this.versions.set(version.id, version);
|
|
225
232
|
this.versionSeq.set(version.id, ++this.seq);
|
|
226
233
|
row.draftVersionId = version.id;
|
|
234
|
+
row.draftSavedAt = String(version.savedAt);
|
|
227
235
|
}
|
|
228
236
|
/** A published row from before every collection was versioned has no snapshot; give it
|
|
229
237
|
* one from what is live BEFORE a write changes the fields, so the site keeps serving it
|
|
@@ -232,11 +240,12 @@ export class MemoryEntryStore {
|
|
|
232
240
|
const row = entry.locales[locale];
|
|
233
241
|
if (row.status !== "published" || row.publishedVersionId !== null)
|
|
234
242
|
return;
|
|
235
|
-
const version = { id: uid("version"), entryId: entry.id, locale, fields: entryFieldsIn(entry, locale), savedAt:
|
|
243
|
+
const version = { id: uid("version"), entryId: entry.id, locale, fields: entryFieldsIn(entry, locale), savedAt: savedClock() };
|
|
236
244
|
this.versions.set(version.id, version);
|
|
237
245
|
this.versionSeq.set(version.id, ++this.seq);
|
|
238
246
|
row.draftVersionId = version.id;
|
|
239
247
|
row.publishedVersionId = version.id;
|
|
248
|
+
row.draftSavedAt = String(version.savedAt);
|
|
240
249
|
}
|
|
241
250
|
dropVersions(entryId, locale) {
|
|
242
251
|
for (const [id, v] of this.versions) {
|
|
@@ -323,7 +332,7 @@ export class MemoryEntryStore {
|
|
|
323
332
|
collection, parentId, fields: shared,
|
|
324
333
|
locales: {
|
|
325
334
|
[options.locale]: {
|
|
326
|
-
slug, status: "draft", fields: localized, draftVersionId: null, publishedVersionId: null, updatedAt: now, updatedBy: by ?? null,
|
|
335
|
+
slug, status: "draft", fields: localized, draftVersionId: null, draftSavedAt: null, publishedVersionId: null, updatedAt: now, updatedBy: by ?? null,
|
|
327
336
|
},
|
|
328
337
|
},
|
|
329
338
|
sort: null, createdAt: now, updatedAt: now,
|
|
@@ -387,10 +396,11 @@ export class MemoryEntryStore {
|
|
|
387
396
|
}
|
|
388
397
|
return out;
|
|
389
398
|
}
|
|
390
|
-
async update(collection, id, locale, fields, by) {
|
|
399
|
+
async update(collection, id, locale, fields, by, expected) {
|
|
391
400
|
const schema = this.schema(collection);
|
|
392
401
|
const entry = this.must(collection, id);
|
|
393
402
|
const row = entryLocaleRow(entry, locale);
|
|
403
|
+
assertFresh(row, expected);
|
|
394
404
|
this.ensureLiveSnapshot(entry, locale);
|
|
395
405
|
const { shared, localized, slug } = splitFields(fields, schema);
|
|
396
406
|
if (isGlobalSchema(schema) && slug !== null)
|
|
@@ -413,10 +423,11 @@ export class MemoryEntryStore {
|
|
|
413
423
|
this.writeRefs(entry);
|
|
414
424
|
return this.snapshot(entry);
|
|
415
425
|
}
|
|
416
|
-
async setStatus(collection, id, locale, status, by) {
|
|
426
|
+
async setStatus(collection, id, locale, status, by, expected) {
|
|
417
427
|
const entry = this.must(collection, id);
|
|
418
428
|
const row = entryLocaleRow(entry, locale);
|
|
419
429
|
if (status === "published") {
|
|
430
|
+
assertFresh(row, expected);
|
|
420
431
|
// Publish points at the current draft; a row always has one after its first write, but never trust that from here.
|
|
421
432
|
if (!row.draftVersionId)
|
|
422
433
|
this.recordVersion(entry, locale);
|
|
@@ -428,10 +439,11 @@ export class MemoryEntryStore {
|
|
|
428
439
|
this.writeRefs(entry); // the published snapshot just became, or stopped being, a live set
|
|
429
440
|
return this.snapshot(entry);
|
|
430
441
|
}
|
|
431
|
-
async discardDraft(collection, id, locale, by) {
|
|
442
|
+
async discardDraft(collection, id, locale, by, expected) {
|
|
432
443
|
const schema = this.schema(collection);
|
|
433
444
|
const entry = this.must(collection, id);
|
|
434
445
|
const row = entryLocaleRow(entry, locale);
|
|
446
|
+
assertFresh(row, expected);
|
|
435
447
|
const snapshot = row.publishedVersionId ? this.versions.get(row.publishedVersionId) : undefined;
|
|
436
448
|
if (!snapshot)
|
|
437
449
|
throw entryErrors.nothingPublished(collection, id, locale);
|
|
@@ -440,6 +452,7 @@ export class MemoryEntryStore {
|
|
|
440
452
|
row.fields = localized;
|
|
441
453
|
const abandoned = row.draftVersionId;
|
|
442
454
|
row.draftVersionId = row.publishedVersionId;
|
|
455
|
+
row.draftSavedAt = String(snapshot.savedAt);
|
|
443
456
|
if (abandoned && abandoned !== row.publishedVersionId) {
|
|
444
457
|
this.versions.delete(abandoned);
|
|
445
458
|
this.versionSeq.delete(abandoned);
|
|
@@ -462,7 +475,7 @@ export class MemoryEntryStore {
|
|
|
462
475
|
const now = Date.now();
|
|
463
476
|
entry.locales[locale] = {
|
|
464
477
|
slug: source.slug, status: "draft", fields: structuredClone(source.fields),
|
|
465
|
-
draftVersionId: null, publishedVersionId: null, updatedAt: now, updatedBy: by ?? null,
|
|
478
|
+
draftVersionId: null, draftSavedAt: null, publishedVersionId: null, updatedAt: now, updatedBy: by ?? null,
|
|
466
479
|
};
|
|
467
480
|
this.recordVersion(entry, locale);
|
|
468
481
|
// Only this node's rows change: a child cannot have a locale its parent lacked.
|
|
@@ -510,15 +523,16 @@ export class MemoryEntryStore {
|
|
|
510
523
|
.sort((a, b) => b.savedAt - a.savedAt || this.versionSeq.get(b.id) - this.versionSeq.get(a.id))
|
|
511
524
|
.map((v) => structuredClone(v));
|
|
512
525
|
}
|
|
513
|
-
async restore(collection, id, locale, versionId, by) {
|
|
526
|
+
async restore(collection, id, locale, versionId, by, expected) {
|
|
514
527
|
const schema = this.schema(collection);
|
|
515
528
|
const entry = this.must(collection, id);
|
|
516
529
|
const row = entryLocaleRow(entry, locale);
|
|
517
530
|
const version = this.versions.get(versionId);
|
|
518
531
|
if (!version || version.entryId !== id || version.locale !== locale)
|
|
519
532
|
throw entryErrors.foreignVersion(collection, id, locale, versionId);
|
|
533
|
+
assertFresh(row, expected);
|
|
520
534
|
if (versionId === row.publishedVersionId)
|
|
521
|
-
return this.discardDraft(collection, id, locale, by);
|
|
535
|
+
return this.discardDraft(collection, id, locale, by, expected);
|
|
522
536
|
const { shared, localized } = splitFields(version.fields, schema);
|
|
523
537
|
entry.fields = shared;
|
|
524
538
|
row.fields = localized; // the slug is the row's own and never versioned
|
package/dist/env.d.ts
CHANGED
|
@@ -14,3 +14,12 @@ export declare function resolveSmoodlyEnv(env: Record<string, string | undefined
|
|
|
14
14
|
/** `resolveSmoodlyEnv(process.env)` — call lazily, never at module load,
|
|
15
15
|
* so `next build` without env still succeeds. */
|
|
16
16
|
export declare const smoodlyEnv: () => SmoodlyEnv;
|
|
17
|
+
/** What the browser needs to join the content project's presence channel (spec 2026-09-26 concurrency §5): public values only. */
|
|
18
|
+
export type ContentConfig = {
|
|
19
|
+
url: string;
|
|
20
|
+
key: string;
|
|
21
|
+
spaceId: string;
|
|
22
|
+
};
|
|
23
|
+
/** The space a key acts in: its `space_id` claim when it is a JWT that carries one, else the default space. */
|
|
24
|
+
export declare function spaceIdOfKey(key: string): string;
|
|
25
|
+
export declare function publicContentConfig(env: SmoodlyEnv): ContentConfig;
|
package/dist/env.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// The one config shape (spec 2026-09-04 §4/§6). Self-host sets three
|
|
2
2
|
// names; the cloud sets five. The two defaults are what keep a single
|
|
3
3
|
// code path: a default for an absent value is not a mode branch.
|
|
4
|
+
import { DEFAULT_SPACE_ID } from "./sql-space.js";
|
|
4
5
|
const REQUIRED = ["SMOODLY_URL", "SMOODLY_SECRET_KEY", "SMOODLY_PUBLISHABLE_KEY"];
|
|
5
6
|
export function resolveSmoodlyEnv(env) {
|
|
6
7
|
const missing = REQUIRED.filter((name) => !env[name]);
|
|
@@ -20,3 +21,21 @@ export function resolveSmoodlyEnv(env) {
|
|
|
20
21
|
/** `resolveSmoodlyEnv(process.env)` — call lazily, never at module load,
|
|
21
22
|
* so `next build` without env still succeeds. */
|
|
22
23
|
export const smoodlyEnv = () => resolveSmoodlyEnv(process.env);
|
|
24
|
+
/** The space a key acts in: its `space_id` claim when it is a JWT that carries one, else the default space. */
|
|
25
|
+
export function spaceIdOfKey(key) {
|
|
26
|
+
const parts = key.split(".");
|
|
27
|
+
if (parts.length !== 3)
|
|
28
|
+
return DEFAULT_SPACE_ID;
|
|
29
|
+
try {
|
|
30
|
+
// base64url → base64, padded; atob exists in Node 22 and every browser, so no Buffer here.
|
|
31
|
+
const b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(parts[1].length / 4) * 4, "=");
|
|
32
|
+
const claims = JSON.parse(atob(b64));
|
|
33
|
+
return typeof claims.space_id === "string" && claims.space_id ? claims.space_id : DEFAULT_SPACE_ID;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return DEFAULT_SPACE_ID;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export function publicContentConfig(env) {
|
|
40
|
+
return { url: env.url, key: env.publishableKey, spaceId: spaceIdOfKey(env.secretKey) };
|
|
41
|
+
}
|
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. */
|