smoodly 0.0.6 → 0.0.7
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 +231 -73
- package/dist/admin/client-ops.js +2 -2
- package/dist/admin/editor/EditorView.js +167 -24
- package/dist/admin/editor/PageSettings.d.ts +8 -4
- package/dist/admin/editor/PageSettings.js +25 -25
- package/dist/admin/fixed-nodes.d.ts +16 -10
- package/dist/admin/fixed-nodes.js +55 -41
- package/dist/admin/ops-impl.js +134 -35
- package/dist/admin/ops.d.ts +51 -11
- package/dist/admin/shell/AdminApp.js +3 -32
- package/dist/admin/shell/EntriesList.d.ts +7 -0
- package/dist/admin/shell/EntriesList.js +95 -0
- package/dist/admin/shell/EntryForm.js +167 -49
- package/dist/admin/shell/PagesList.js +86 -26
- package/dist/admin/shell/entries-list.d.ts +21 -0
- package/dist/admin/shell/entries-list.js +36 -0
- package/dist/admin/shell/pages-tree.d.ts +30 -12
- package/dist/admin/shell/pages-tree.js +48 -14
- package/dist/admin/ui/LocaleSwitcher.d.ts +11 -0
- package/dist/admin/ui/LocaleSwitcher.js +15 -0
- package/dist/collections.d.ts +6 -0
- package/dist/collections.js +16 -0
- package/dist/config.js +23 -3
- package/dist/entry-store.d.ts +132 -54
- package/dist/entry-store.js +294 -86
- package/dist/index.d.ts +6 -6
- package/dist/index.js +4 -4
- package/dist/localize.d.ts +0 -11
- package/dist/localize.js +11 -29
- package/dist/paths.d.ts +46 -27
- package/dist/paths.js +62 -29
- package/dist/revalidate.js +2 -1
- package/dist/site.d.ts +6 -2
- package/dist/site.js +65 -39
- package/dist/sql-space.d.ts +1 -1
- package/dist/sql-space.js +13 -5
- package/dist/sql.js +96 -53
- package/dist/store.d.ts +66 -32
- package/dist/store.js +205 -90
- package/dist/supabase-entry-store.d.ts +29 -8
- package/dist/supabase-entry-store.js +341 -177
- package/dist/supabase-store.d.ts +21 -9
- package/dist/supabase-store.js +202 -111
- package/package.json +1 -1
|
@@ -1,24 +1,57 @@
|
|
|
1
1
|
// Pure view-model for the Pages list — the tree of the design system's
|
|
2
2
|
// TreeTable (indent per depth, chevron on parents, the derived path as
|
|
3
|
-
// the slug column)
|
|
3
|
+
// the slug column) for ONE selected locale (spec 2026-09-06 §6). The
|
|
4
|
+
// structure is shared, so every node has a row; presence is per locale,
|
|
5
|
+
// so a node absent from the selected locale is dimmed with no path and
|
|
6
|
+
// an "Add" action. The shell is not DOM-tested; every decision that
|
|
4
7
|
// matters lives here.
|
|
5
|
-
import { titleFor } from "../../localize.js";
|
|
6
8
|
import { buildPageTree } from "../../paths.js";
|
|
7
9
|
/** Registrations editors may create pages against: fixed ones are
|
|
8
10
|
* materialized by Smoodly, never offered. */
|
|
9
11
|
export function openTemplates(templates) {
|
|
10
12
|
return templates.filter((t) => t.slug === undefined);
|
|
11
13
|
}
|
|
14
|
+
/** The default-locale slug that identifies a ROOT node to the registry;
|
|
15
|
+
* a root that exists in another locale only is never code-owned. */
|
|
16
|
+
const rootSlug = (record, registry) => record.parentId === null ? record.locales[registry.locales.default]?.slug : undefined;
|
|
12
17
|
/** The collection a ROOT record is the mount of, if any — known from the
|
|
13
18
|
* registry, never from a column (spec 2026-09-05 §4). */
|
|
14
19
|
export function mountOf(record, registry) {
|
|
15
|
-
|
|
20
|
+
const slug = rootSlug(record, registry);
|
|
21
|
+
if (slug === undefined)
|
|
16
22
|
return null;
|
|
17
|
-
return registry.collections.find((c) => c.path?.[registry.locales.default] ===
|
|
23
|
+
return registry.collections.find((c) => c.path?.[registry.locales.default] === slug) ?? null;
|
|
18
24
|
}
|
|
19
|
-
/** Fixed pages and mounts: never renamed, moved or
|
|
25
|
+
/** Fixed pages and mounts: never renamed, moved, deleted or stripped of a locale by editors. */
|
|
20
26
|
export function isFixedNode(record, registry) {
|
|
21
|
-
|
|
27
|
+
const slug = rootSlug(record, registry);
|
|
28
|
+
return slug !== undefined && (registry.pages.some((t) => t.slug === slug) || mountOf(record, registry) !== null);
|
|
29
|
+
}
|
|
30
|
+
/** A node's title for display in a locale it may not exist in: its own,
|
|
31
|
+
* else the default locale's, else any. Display only — never content. */
|
|
32
|
+
export function displayTitle(record, locale, registry) {
|
|
33
|
+
return (record.locales[locale]?.title ??
|
|
34
|
+
record.locales[registry.locales.default]?.title ??
|
|
35
|
+
Object.values(record.locales)[0]?.title ??
|
|
36
|
+
record.id);
|
|
37
|
+
}
|
|
38
|
+
/** The locale an "Add <locale>" copies from: the default when the node has it, else its first. */
|
|
39
|
+
export function sourceLocaleOf(record, registry) {
|
|
40
|
+
return record.locales[registry.locales.default] ? registry.locales.default : Object.keys(record.locales)[0];
|
|
41
|
+
}
|
|
42
|
+
/** Whether a locale's row is live, and if so whether the draft has moved
|
|
43
|
+
* on. `status` decides — spec 2026-09-06 §2 keeps it as a column so a
|
|
44
|
+
* future unpublish can clear it while the version pointers stay — and
|
|
45
|
+
* the pointers only tell "published" from "draft edits". The Pages list
|
|
46
|
+
* and the editor's badge both read it here so they cannot disagree. */
|
|
47
|
+
export function localeStatusOf(row) {
|
|
48
|
+
if (row.status !== "published")
|
|
49
|
+
return "draft";
|
|
50
|
+
// Pointers exist on pages and on versioned collections; a row without
|
|
51
|
+
// them is live as it stands.
|
|
52
|
+
if (row.publishedVersionId === null)
|
|
53
|
+
return "published";
|
|
54
|
+
return row.publishedVersionId === row.draftVersionId ? "published" : "draft edits";
|
|
22
55
|
}
|
|
23
56
|
export function pagesTreeRows(records, registry, locale, collapsed) {
|
|
24
57
|
const opts = { locales: registry.locales, homePageSlug: registry.homePageSlug };
|
|
@@ -27,8 +60,10 @@ export function pagesTreeRows(records, registry, locale, collapsed) {
|
|
|
27
60
|
const walk = (nodes, depth, parentId) => {
|
|
28
61
|
nodes.forEach((node, index) => {
|
|
29
62
|
const r = node.record;
|
|
63
|
+
const row = r.locales[locale];
|
|
64
|
+
const present = row !== undefined;
|
|
30
65
|
const mount = mountOf(r, registry);
|
|
31
|
-
const isHome = r.parentId === null && r.slug === registry.homePageSlug;
|
|
66
|
+
const isHome = r.parentId === null && r.locales[registry.locales.default]?.slug === registry.homePageSlug;
|
|
32
67
|
const expanded = !collapsed.has(r.id);
|
|
33
68
|
rows.push({
|
|
34
69
|
id: r.id,
|
|
@@ -38,8 +73,10 @@ export function pagesTreeRows(records, registry, locale, collapsed) {
|
|
|
38
73
|
siblingCount: nodes.length,
|
|
39
74
|
hasChildren: node.children.length > 0,
|
|
40
75
|
expanded,
|
|
41
|
-
title:
|
|
76
|
+
title: displayTitle(r, locale, registry),
|
|
42
77
|
path: node.path,
|
|
78
|
+
present,
|
|
79
|
+
locales: Object.keys(r.locales),
|
|
43
80
|
kind: mount ? "mount" : r.template === null ? "folder" : "page",
|
|
44
81
|
collection: mount?.name ?? null,
|
|
45
82
|
fixed: isFixedNode(r, registry),
|
|
@@ -48,12 +85,9 @@ export function pagesTreeRows(records, registry, locale, collapsed) {
|
|
|
48
85
|
templateTitle: mount
|
|
49
86
|
? r.template === null ? "Collection" : `Collection · ${templateTitle(r.template)}`
|
|
50
87
|
: r.template === null ? "Folder" : templateTitle(r.template),
|
|
51
|
-
status: r.template === null
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
? r.publishedVersionId === r.draftVersionId ? "published" : "draft edits"
|
|
55
|
-
: "draft",
|
|
56
|
-
canHaveChildren: !mount && !isHome && depth + 1 < registry.pageDepth,
|
|
88
|
+
status: !row || r.template === null ? null : localeStatusOf(row),
|
|
89
|
+
// A child needs its parent in the locale (the parent rule), so an absent node offers none.
|
|
90
|
+
canHaveChildren: present && !mount && !isHome && depth + 1 < registry.pageDepth,
|
|
57
91
|
record: r,
|
|
58
92
|
});
|
|
59
93
|
if (expanded)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { AdminRegistry } from "../serialize.ts";
|
|
2
|
+
export declare function LocaleSwitcher({ registry, present, parentLocales, value, adding, onSwitch, onAdd }: {
|
|
3
|
+
registry: AdminRegistry;
|
|
4
|
+
/** The locales the record has; `[value]` while nothing is loaded. */
|
|
5
|
+
present: string[];
|
|
6
|
+
parentLocales: string[];
|
|
7
|
+
value: string;
|
|
8
|
+
adding: boolean;
|
|
9
|
+
onSwitch(locale: string): void;
|
|
10
|
+
onAdd(locale: string): void;
|
|
11
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
export function LocaleSwitcher({ registry, present, parentLocales, value, adding, onSwitch, onAdd }) {
|
|
4
|
+
const missing = registry.locales.supported.filter((l) => !present.includes(l) && l !== value);
|
|
5
|
+
return (_jsxs("select", { className: "sm-input", style: { width: "auto" }, value: value, "aria-label": "Locale", onChange: (e) => { const v = e.target.value; if (v.startsWith("+"))
|
|
6
|
+
onAdd(v.slice(1));
|
|
7
|
+
else
|
|
8
|
+
onSwitch(v); }, children: [present.map((l) => _jsx("option", { value: l, children: l }, l)), !present.includes(value) && _jsxs("option", { value: value, children: [value, " (not yet)"] }), missing.length > 0 && (_jsx("optgroup", { label: "Add locale", disabled: adding, children: missing.map((l) => {
|
|
9
|
+
const blocked = !parentLocales.includes(l);
|
|
10
|
+
const reason = `add ${l} to the parent first`;
|
|
11
|
+
return blocked
|
|
12
|
+
? _jsxs("option", { value: `+${l}`, disabled: true, title: reason, children: ["+ ", l, " \u2014 ", reason] }, l)
|
|
13
|
+
: _jsxs("option", { value: `+${l}`, children: ["+ ", l] }, l);
|
|
14
|
+
}) }))] }));
|
|
15
|
+
}
|
package/dist/collections.d.ts
CHANGED
|
@@ -25,6 +25,11 @@ export type CollectionSchema<F extends BuilderMap = BuilderMap> = {
|
|
|
25
25
|
admin?: {
|
|
26
26
|
listColumns?: string[];
|
|
27
27
|
};
|
|
28
|
+
/** Per-locale version history for this collection's entries (spec
|
|
29
|
+
* 2026-09-06 §7): every save becomes an `entry_versions` snapshot and
|
|
30
|
+
* publish moves a pointer, as on pages. Off by default — a People list
|
|
31
|
+
* does not want history; articles do. */
|
|
32
|
+
versions?: boolean;
|
|
28
33
|
/** phantom: the entry value shape, used by f.ref target inference */
|
|
29
34
|
readonly __entry?: NamespaceValues<F>;
|
|
30
35
|
};
|
|
@@ -43,6 +48,7 @@ export declare function collection<F extends BuilderMap>(input: {
|
|
|
43
48
|
admin?: {
|
|
44
49
|
listColumns?: string[];
|
|
45
50
|
};
|
|
51
|
+
versions?: boolean;
|
|
46
52
|
}): CollectionSchema<F>;
|
|
47
53
|
export type Toolset = {
|
|
48
54
|
marks?: string[];
|
package/dist/collections.js
CHANGED
|
@@ -8,6 +8,15 @@ export function collection(input) {
|
|
|
8
8
|
if (input.tree !== undefined && (!Number.isInteger(input.tree.depth) || input.tree.depth < 1)) {
|
|
9
9
|
throw new Error(`smoodly: collection "${input.name}" declares an invalid tree depth.`);
|
|
10
10
|
}
|
|
11
|
+
// `slug` is not a field on the node at all: splitFields drops it and the
|
|
12
|
+
// locale row owns it (spec 2026-09-06 §12), so ordering by it would be
|
|
13
|
+
// silently creation-ordered. Checked before the unknown-field check below
|
|
14
|
+
// so a collection with no declared `slug` field still gets this message,
|
|
15
|
+
// not "orders by an unknown field" — `slug` routes to the locale row
|
|
16
|
+
// whether or not it is declared.
|
|
17
|
+
if (input.order !== undefined && input.order !== "manual" && input.order.by === "slug") {
|
|
18
|
+
throw new Error(`smoodly: collection "${input.name}" orders by "slug", which is per locale — order fields are shared across locales.`);
|
|
19
|
+
}
|
|
11
20
|
if (input.order !== undefined &&
|
|
12
21
|
input.order !== "manual" &&
|
|
13
22
|
!BUILT_IN_ORDER_FIELDS.includes(input.order.by) &&
|
|
@@ -17,6 +26,12 @@ export function collection(input) {
|
|
|
17
26
|
!Object.prototype.hasOwnProperty.call(input.fields, input.order.by)) {
|
|
18
27
|
throw new Error(`smoodly: collection "${input.name}" orders by an unknown field "${input.order.by}".`);
|
|
19
28
|
}
|
|
29
|
+
if (input.order !== undefined &&
|
|
30
|
+
input.order !== "manual" &&
|
|
31
|
+
!BUILT_IN_ORDER_FIELDS.includes(input.order.by) &&
|
|
32
|
+
input.fields[input.order.by]?.descriptor.localized === true) {
|
|
33
|
+
throw new Error(`smoodly: collection "${input.name}" orders by "${input.order.by}", which is localized — order fields are shared across locales.`);
|
|
34
|
+
}
|
|
20
35
|
return {
|
|
21
36
|
kind: "collection",
|
|
22
37
|
name: input.name,
|
|
@@ -26,6 +41,7 @@ export function collection(input) {
|
|
|
26
41
|
...(input.path !== undefined ? { path: input.path } : {}),
|
|
27
42
|
...(input.tree !== undefined ? { tree: input.tree } : {}),
|
|
28
43
|
...(input.order !== undefined ? { order: input.order } : {}),
|
|
44
|
+
...(input.versions ? { versions: true } : {}),
|
|
29
45
|
admin: input.admin,
|
|
30
46
|
};
|
|
31
47
|
}
|
package/dist/config.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { assertSegment, perLocaleSegments } from "./paths.js";
|
|
1
|
+
import { assertSegment, fixedPageSegments, perLocaleSegments } from "./paths.js";
|
|
2
2
|
function assertUniqueNames(config) {
|
|
3
3
|
const seen = new Set();
|
|
4
4
|
const all = [
|
|
@@ -19,7 +19,11 @@ function assertUniqueNames(config) {
|
|
|
19
19
|
* EVERY locale of that path, which is the collection's index page. */
|
|
20
20
|
function assertRootSegments(config) {
|
|
21
21
|
const { default: def, supported } = config.locales;
|
|
22
|
+
// Mounts claim exactly the locales their path names; fixed pages fill an
|
|
23
|
+
// omitted locale from the default one, so the check must fill the same
|
|
24
|
+
// way the materializer does or the clash only shows up at materialization.
|
|
22
25
|
const perLocale = (v) => perLocaleSegments(v, supported);
|
|
26
|
+
const pageSegments = (v) => fixedPageSegments(v, config.locales);
|
|
23
27
|
const pages = (config.registry.pages ?? []).filter((p) => p.schema.kind === "page" && p.schema.slug !== undefined);
|
|
24
28
|
const owners = new Map(); // "<locale> <segment>" → page name
|
|
25
29
|
for (const p of pages) {
|
|
@@ -27,7 +31,7 @@ function assertRootSegments(config) {
|
|
|
27
31
|
if (typeof slug !== "string" && slug[def] === undefined) {
|
|
28
32
|
throw new Error(`smoodly: page "${p.schema.name}" declares a per-locale slug without the default locale "${def}".`);
|
|
29
33
|
}
|
|
30
|
-
for (const [locale, segment] of Object.entries(
|
|
34
|
+
for (const [locale, segment] of Object.entries(pageSegments(slug))) {
|
|
31
35
|
assertSegment(segment, `page "${p.schema.name}" fixed slug`);
|
|
32
36
|
const owner = owners.get(`${locale} ${segment}`);
|
|
33
37
|
if (owner) {
|
|
@@ -62,14 +66,30 @@ function assertRootSegments(config) {
|
|
|
62
66
|
if (!pageName)
|
|
63
67
|
continue;
|
|
64
68
|
const page = pages.find((p) => p.schema.name === pageName);
|
|
65
|
-
const everywhere = Object.entries(segments).every(([l, s]) =>
|
|
69
|
+
const everywhere = Object.entries(segments).every(([l, s]) => pageSegments(page.schema.slug)[l] === s);
|
|
66
70
|
if (!everywhere) {
|
|
67
71
|
throw new Error(`smoodly: collection "${c.name}" and page registration "${pageName}" both claim the root segment "${segment}" (${locale}).`);
|
|
68
72
|
}
|
|
69
73
|
}
|
|
70
74
|
}
|
|
71
75
|
}
|
|
76
|
+
/** A language subtag, optionally a script or region subtag (`en`, `fi`,
|
|
77
|
+
* `en-US`, `zh-Hant`). Codes are otherwise opaque strings all the way
|
|
78
|
+
* down — the paths key, the page_locales key — so the only place a
|
|
79
|
+
* typo can be caught is here, at boot (spec 2026-09-06 §5). */
|
|
80
|
+
const LOCALE_CODE_RE = /^[a-z]{2,3}(?:-[A-Za-z]{2,4})?$/;
|
|
81
|
+
function assertLocales(config) {
|
|
82
|
+
const { default: def, supported } = config.locales;
|
|
83
|
+
for (const code of supported) {
|
|
84
|
+
if (!LOCALE_CODE_RE.test(code)) {
|
|
85
|
+
throw new Error(`smoodly: "${code}" is not a valid locale code — use a language tag like "en", "fi", "en-US" or "zh-Hant".`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (!supported.includes(def))
|
|
89
|
+
throw new Error(`smoodly: the default locale "${def}" is not in the supported list.`);
|
|
90
|
+
}
|
|
72
91
|
export function defineConfig(config) {
|
|
92
|
+
assertLocales(config);
|
|
73
93
|
assertUniqueNames(config);
|
|
74
94
|
assertRootSegments(config);
|
|
75
95
|
const depth = config.pages?.tree?.depth ?? 1;
|
package/dist/entry-store.d.ts
CHANGED
|
@@ -1,19 +1,38 @@
|
|
|
1
1
|
import type { CollectionSchema, EntryOrder } from "./collections.ts";
|
|
2
|
+
import { type RefEdge } from "./refs.ts";
|
|
2
3
|
import { MemoryRefIndex } from "./ref-index.ts";
|
|
3
4
|
import { type PathIndex } from "./path-index.ts";
|
|
4
5
|
import { type LocaleSet, type PathTarget } from "./paths.ts";
|
|
6
|
+
export type EntryLocale = {
|
|
7
|
+
/** The entry's segment in this locale; null claims no URL. */
|
|
8
|
+
slug: string | null;
|
|
9
|
+
status: "draft" | "published";
|
|
10
|
+
/** ONLY the .localized() fields, for this locale. */
|
|
11
|
+
fields: Record<string, unknown>;
|
|
12
|
+
draftVersionId: string | null;
|
|
13
|
+
publishedVersionId: string | null;
|
|
14
|
+
updatedAt: number;
|
|
15
|
+
};
|
|
5
16
|
export type EntryRecord = {
|
|
6
17
|
id: string;
|
|
7
18
|
collection: string;
|
|
8
19
|
parentId: string | null;
|
|
20
|
+
/** The SHARED fields — every field without .localized(). */
|
|
9
21
|
fields: Record<string, unknown>;
|
|
10
|
-
|
|
11
|
-
|
|
22
|
+
/** Only the locales present. Presence IS the row. */
|
|
23
|
+
locales: Record<string, EntryLocale>;
|
|
12
24
|
sort: number | null;
|
|
13
|
-
locales: string[] | null;
|
|
14
25
|
createdAt: number;
|
|
15
26
|
updatedAt: number;
|
|
16
27
|
};
|
|
28
|
+
export type EntryVersion = {
|
|
29
|
+
id: string;
|
|
30
|
+
entryId: string;
|
|
31
|
+
locale: string;
|
|
32
|
+
/** The MERGED fields (shared + localized + slug) at that save. */
|
|
33
|
+
fields: Record<string, unknown>;
|
|
34
|
+
createdAt: number;
|
|
35
|
+
};
|
|
17
36
|
export type EntryUsage = {
|
|
18
37
|
sourceKind: string;
|
|
19
38
|
sourceId: string;
|
|
@@ -22,47 +41,65 @@ export type EntryUsage = {
|
|
|
22
41
|
export type ListOptions = {
|
|
23
42
|
/** Absent = the collection's configured order (config); `"manual"` sorts by `sort` asc. */
|
|
24
43
|
order?: EntryOrder;
|
|
44
|
+
/** With `status`: the locale whose row is inspected; the default locale when omitted. */
|
|
45
|
+
locale?: string;
|
|
46
|
+
/** Only entries whose row in `locale` is published. */
|
|
25
47
|
status?: "published";
|
|
26
48
|
/**
|
|
27
49
|
* `null` lists roots; a string id lists that parent's children; omitted
|
|
28
|
-
* (or explicitly `undefined`) applies no parent filter
|
|
29
|
-
* the collection, at every depth.
|
|
50
|
+
* (or explicitly `undefined`) applies no parent filter.
|
|
30
51
|
*/
|
|
31
52
|
parent?: string | null;
|
|
32
53
|
};
|
|
33
54
|
export type CreateEntryOptions = {
|
|
55
|
+
locale: string;
|
|
34
56
|
parentId?: string | null;
|
|
35
|
-
|
|
57
|
+
};
|
|
58
|
+
export type AddEntryLocaleOptions = {
|
|
59
|
+
from: string;
|
|
36
60
|
};
|
|
37
61
|
export type EntryStoreOptions = {
|
|
38
62
|
paths?: PathIndex;
|
|
39
63
|
locales?: LocaleSet;
|
|
40
64
|
};
|
|
41
65
|
export interface EntryStore {
|
|
42
|
-
|
|
66
|
+
/** Creates the node and ONE locale row from the merged `fields`: shared
|
|
67
|
+
* keys go to the node, .localized() keys and `slug` to the row. The
|
|
68
|
+
* parent (if any) must exist in that locale. */
|
|
69
|
+
create(collection: string, fields: Record<string, unknown>, options: CreateEntryOptions): Promise<EntryRecord>;
|
|
43
70
|
get(collection: string, id: string): Promise<EntryRecord | null>;
|
|
44
71
|
list(collection: string, options?: ListOptions): Promise<EntryRecord[]>;
|
|
45
|
-
/** Batched read for the resolver:
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
|
|
72
|
+
/** Batched read for the resolver: the MERGED fields in `locale`, keyed
|
|
73
|
+
* by id. An id that is missing, absent in the locale, or (with
|
|
74
|
+
* `status`) unpublished there is omitted. With `{ status: "published" }`
|
|
75
|
+
* a versioned collection answers from the published snapshot, so a
|
|
76
|
+
* post-publish edit never leaks (DESIGN.md OQ #16). The signature,
|
|
77
|
+
* with the locale bound, IS resolveTree's EntryFetcher. */
|
|
78
|
+
getMany(collection: string, ids: string[], locale: string, options?: {
|
|
51
79
|
status?: "published";
|
|
52
80
|
}): Promise<Record<string, Record<string, unknown>>>;
|
|
53
|
-
|
|
54
|
-
|
|
81
|
+
/** Replaces the merged fields for `locale`: shared keys on the node
|
|
82
|
+
* (every locale sees them), localized keys and the slug on the row. */
|
|
83
|
+
update(collection: string, id: string, locale: string, fields: Record<string, unknown>): Promise<EntryRecord>;
|
|
84
|
+
setStatus(collection: string, id: string, locale: string, status: "draft" | "published"): Promise<EntryRecord>;
|
|
85
|
+
/** Copies `from`'s localized fields and slug as the new row (shared
|
|
86
|
+
* fields need no copy); refused when the entry has the locale or its
|
|
87
|
+
* parent lacks it. */
|
|
88
|
+
addLocale(collection: string, id: string, locale: string, options: AddEntryLocaleOptions): Promise<EntryRecord>;
|
|
89
|
+
/** Deletes the row, its versions and its paths rows; refused for the
|
|
90
|
+
* last locale and while a child has it. */
|
|
91
|
+
removeLocale(collection: string, id: string, locale: string): Promise<EntryRecord>;
|
|
92
|
+
/** Newest first; empty for a collection without `versions`. */
|
|
93
|
+
listVersions(collection: string, id: string, locale: string): Promise<EntryVersion[]>;
|
|
55
94
|
/** Blocked while anything references the entry (the safe-delete guard),
|
|
56
|
-
* and while it still has child entries. */
|
|
95
|
+
* and while it still has child entries. Removes every locale. */
|
|
57
96
|
delete(collection: string, id: string): Promise<void>;
|
|
58
97
|
/**
|
|
59
|
-
* Reparents `id` under `to.parentId
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
* `move` does NOT bump `updatedAt` — reparenting/resequencing is a
|
|
65
|
-
* structure change, not an edit to the entry's content.
|
|
98
|
+
* Reparents `id` under `to.parentId`, which must exist in every locale
|
|
99
|
+
* the entry has. `to.index` is the moved entry's final 0-based position
|
|
100
|
+
* among the destination siblings; omitted, it appends; out-of-range
|
|
101
|
+
* values clamp. Destination siblings are renumbered 1..N afterward.
|
|
102
|
+
* Like `reorder`, `move` does NOT bump `updatedAt`.
|
|
66
103
|
*/
|
|
67
104
|
move(collection: string, id: string, to: {
|
|
68
105
|
parentId: string | null;
|
|
@@ -72,8 +109,7 @@ export interface EntryStore {
|
|
|
72
109
|
reorder(collection: string, orderedIds: string[]): Promise<void>;
|
|
73
110
|
/** The refs index's reverse question: who points at this entry? */
|
|
74
111
|
referencesTo(collection: string, id: string): Promise<EntryUsage[]>;
|
|
75
|
-
/** Reverse lookup by id alone (ids are globally unique) —
|
|
76
|
-
* fan-out walk queries, since it can't know a source's collection. */
|
|
112
|
+
/** Reverse lookup by id alone (ids are globally unique) — the fan-out walk. */
|
|
77
113
|
incoming(targetId: string): Promise<{
|
|
78
114
|
sourceKind: string;
|
|
79
115
|
sourceId: string;
|
|
@@ -88,63 +124,105 @@ export declare const entryErrors: {
|
|
|
88
124
|
hasChildren: (collection: string, id: string) => Error;
|
|
89
125
|
cycle: () => Error;
|
|
90
126
|
foreignParent: () => Error;
|
|
91
|
-
/** Entry slugs are unique among SIBLINGS
|
|
92
|
-
* 2026-09-
|
|
93
|
-
*
|
|
94
|
-
* entries_sibling_slug_key 23505 onto this same error. */
|
|
127
|
+
/** Entry slugs are unique among SIBLINGS per locale — the rule pages
|
|
128
|
+
* have (spec 2026-09-06 §11): both adapters pre-check in code and the
|
|
129
|
+
* paths primary key is the invariant. ONE source for the text. */
|
|
95
130
|
slugTaken: (collection: string) => Error;
|
|
131
|
+
unsupportedLocale: (locale: string) => Error;
|
|
132
|
+
notInLocale: (id: string, locale: string) => Error;
|
|
133
|
+
alreadyInLocale: (id: string, locale: string) => Error;
|
|
134
|
+
parentNotInLocale: (locale: string) => Error;
|
|
135
|
+
lastLocale: (id: string) => Error;
|
|
136
|
+
localeInUse: (locale: string, titles: string[]) => Error;
|
|
137
|
+
moveLocales: (locale: string) => Error;
|
|
138
|
+
};
|
|
139
|
+
/** The keys a locale row owns: every field with .localized(), except
|
|
140
|
+
* `slug`, which is the row's own column (a segment is an address, per
|
|
141
|
+
* locale by nature). */
|
|
142
|
+
export declare function localizedKeys(schema: CollectionSchema<any>): string[];
|
|
143
|
+
export type SplitFields = {
|
|
144
|
+
shared: Record<string, unknown>;
|
|
145
|
+
localized: Record<string, unknown>;
|
|
146
|
+
slug: string | null;
|
|
96
147
|
};
|
|
148
|
+
/** The merged form an editor submits, split the way the tables store it.
|
|
149
|
+
* Every key that is neither localized nor `slug` is shared — declared or
|
|
150
|
+
* not — so a stray key never disappears into a locale row. */
|
|
151
|
+
export declare function splitFields(fields: Record<string, unknown>, schema: CollectionSchema<any>): SplitFields;
|
|
152
|
+
export declare function entryLocaleRow(record: EntryRecord, locale: string): EntryLocale;
|
|
153
|
+
/** The merged view of one locale: shared + that locale's fields + its
|
|
154
|
+
* slug. A fresh object; throws for an absent locale. */
|
|
155
|
+
export declare function entryFieldsIn(record: EntryRecord, locale: string): Record<string, unknown>;
|
|
156
|
+
/** The refs index rows for an entry: the union over every live field set
|
|
157
|
+
* — one per present locale, plus each published snapshot the draft has
|
|
158
|
+
* moved past. Safe-delete must hold while ANY of them shows the target,
|
|
159
|
+
* since the published site still renders it (DESIGN.md §3 "Page edges
|
|
160
|
+
* in refs", the same stance). */
|
|
161
|
+
export declare function entryRefEdges(fieldSets: Record<string, unknown>[], schema: CollectionSchema<any>): RefEdge[];
|
|
162
|
+
/** What a message calls an entry: its title field in that locale, else its id. */
|
|
163
|
+
export declare function entryTitleIn(record: EntryRecord, locale: string, schema: CollectionSchema<any>): string;
|
|
97
164
|
/**
|
|
98
|
-
* Sort comparator for one order rule; `seq` breaks ties by creation.
|
|
99
|
-
*
|
|
100
|
-
* Nulls sort last in BOTH
|
|
101
|
-
* `nullsFirst: false` to match)
|
|
102
|
-
* for non-manual orders
|
|
103
|
-
* must still land newest-first under the default order, not oldest-first
|
|
104
|
-
* — while the manual branch's tiebreak stays ascending (creation order
|
|
105
|
-
* is the natural fallback for otherwise-unordered `sort` values).
|
|
165
|
+
* Sort comparator for one order rule; `seq` breaks ties by creation. A
|
|
166
|
+
* declared order field is SHARED (collection() refuses a localized one),
|
|
167
|
+
* so `e.fields[order.by]` reads the node. Nulls sort last in BOTH
|
|
168
|
+
* directions (the Supabase adapter uses `nullsFirst: false` to match);
|
|
169
|
+
* the creation tiebreak follows `direction` for non-manual orders.
|
|
106
170
|
* Declared fields compare numerically when both values are numbers,
|
|
107
|
-
* otherwise as strings via `localeCompare
|
|
108
|
-
*
|
|
109
|
-
* for
|
|
110
|
-
* included). Two edges still differ: MIXED types in one field — Postgres
|
|
111
|
-
* orders jsonb by type first (null < string < number < boolean < array <
|
|
112
|
-
* object) rather than by `String(...)` — and an explicit JSON `null`
|
|
113
|
-
* VALUE, which is a jsonb null and sorts FIRST in Postgres but last here
|
|
114
|
-
* (a MISSING key is SQL NULL and does sort last, matching). See the
|
|
115
|
-
* package README's follow-ups.
|
|
171
|
+
* otherwise as strings via `localeCompare` — the same order Postgres
|
|
172
|
+
* gives the jsonb VALUE (`fields->x`) for one type; see the README's
|
|
173
|
+
* follow-ups for the mixed-type and JSON-null edges.
|
|
116
174
|
*/
|
|
117
175
|
export declare function compareEntries(order: EntryOrder, seq: (e: EntryRecord) => number): (a: EntryRecord, b: EntryRecord) => number;
|
|
118
176
|
export declare class MemoryEntryStore implements EntryStore {
|
|
119
177
|
private collections;
|
|
120
178
|
private refs;
|
|
121
179
|
private entries;
|
|
180
|
+
private versions;
|
|
181
|
+
/** Insertion order per version — `createdAt` alone can tie within a millisecond. */
|
|
182
|
+
private versionSeq;
|
|
122
183
|
private seq;
|
|
123
184
|
private order;
|
|
124
185
|
private paths;
|
|
125
186
|
private locales;
|
|
126
187
|
constructor(collections: CollectionSchema<any>[], refs?: MemoryRefIndex, options?: EntryStoreOptions);
|
|
188
|
+
private schema;
|
|
127
189
|
private row;
|
|
128
190
|
private must;
|
|
129
191
|
private byId;
|
|
192
|
+
private snapshot;
|
|
130
193
|
private subtree;
|
|
194
|
+
/** An edit bumps the node and the edited row; `move` and `reorder` never call this. */
|
|
195
|
+
private bump;
|
|
196
|
+
/** A versioned collection records every save as a merged snapshot and
|
|
197
|
+
* points the locale's draft at it; others keep the row as the live content. */
|
|
198
|
+
private recordVersion;
|
|
199
|
+
private dropVersions;
|
|
200
|
+
/** Every field set the site can render for this entry: each present
|
|
201
|
+
* locale's live view, plus a published snapshot the draft has moved
|
|
202
|
+
* past (still rendered by the published site). */
|
|
203
|
+
private liveFieldSets;
|
|
131
204
|
private writePaths;
|
|
132
205
|
private writeRefs;
|
|
133
206
|
private siblings;
|
|
134
|
-
/** The
|
|
135
|
-
* unique among the
|
|
136
|
-
*
|
|
137
|
-
* predicate folds "" to null too via nullif). */
|
|
207
|
+
/** The friendly pre-check (spec 2026-09-06 §11): a string slug must be
|
|
208
|
+
* unique among the siblings PRESENT in that locale; null never
|
|
209
|
+
* conflicts. The paths primary key remains the invariant. */
|
|
138
210
|
private assertSiblingSlug;
|
|
211
|
+
/** Same collection, within depth. Returns the parent (null at the root). */
|
|
139
212
|
private assertParent;
|
|
140
|
-
|
|
213
|
+
/** The parent rule: a locale can only be added where the parent has it. */
|
|
214
|
+
private assertParentHasLocale;
|
|
215
|
+
create(collection: string, fields: Record<string, unknown>, options: CreateEntryOptions): Promise<EntryRecord>;
|
|
141
216
|
get(collection: string, id: string): Promise<EntryRecord | null>;
|
|
142
217
|
list(collection: string, options?: ListOptions): Promise<EntryRecord[]>;
|
|
143
|
-
getMany(collection: string, ids: string[], options?: {
|
|
218
|
+
getMany(collection: string, ids: string[], locale: string, options?: {
|
|
144
219
|
status?: "published";
|
|
145
220
|
}): Promise<Record<string, Record<string, unknown>>>;
|
|
146
|
-
update(collection: string, id: string, fields: Record<string, unknown>): Promise<EntryRecord>;
|
|
147
|
-
setStatus(collection: string, id: string, status: "draft" | "published"): Promise<EntryRecord>;
|
|
221
|
+
update(collection: string, id: string, locale: string, fields: Record<string, unknown>): Promise<EntryRecord>;
|
|
222
|
+
setStatus(collection: string, id: string, locale: string, status: "draft" | "published"): Promise<EntryRecord>;
|
|
223
|
+
addLocale(collection: string, id: string, locale: string, options: AddEntryLocaleOptions): Promise<EntryRecord>;
|
|
224
|
+
removeLocale(collection: string, id: string, locale: string): Promise<EntryRecord>;
|
|
225
|
+
listVersions(collection: string, id: string, locale: string): Promise<EntryVersion[]>;
|
|
148
226
|
move(collection: string, id: string, to: {
|
|
149
227
|
parentId: string | null;
|
|
150
228
|
index?: number;
|