smoodly 0.0.6 → 0.0.8
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 +318 -74
- package/dist/admin/client-ops.js +3 -2
- package/dist/admin/editor/EditorView.js +229 -30
- 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/index.d.ts +3 -2
- package/dist/admin/index.js +1 -0
- package/dist/admin/ops-impl.js +218 -36
- package/dist/admin/ops.d.ts +73 -12
- package/dist/admin/serialize.d.ts +11 -0
- package/dist/admin/serialize.js +8 -0
- package/dist/admin/shared-items.d.ts +9 -0
- package/dist/admin/shared-items.js +61 -0
- package/dist/admin/shell/AdminApp.js +11 -34
- 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/SharedForm.d.ts +7 -0
- package/dist/admin/shell/SharedForm.js +146 -0
- package/dist/admin/shell/SharedList.d.ts +6 -0
- package/dist/admin/shell/SharedList.js +62 -0
- 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/shell/shared-list.d.ts +14 -0
- package/dist/admin/shell/shared-list.js +17 -0
- package/dist/admin/tree-ops.d.ts +12 -5
- package/dist/admin/tree-ops.js +39 -8
- package/dist/admin/ui/LocaleSwitcher.d.ts +11 -0
- package/dist/admin/ui/LocaleSwitcher.js +15 -0
- package/dist/collections.d.ts +10 -1
- package/dist/collections.js +16 -0
- package/dist/config.d.ts +3 -0
- package/dist/config.js +58 -4
- package/dist/entry-store.d.ts +141 -55
- package/dist/entry-store.js +317 -87
- package/dist/index.d.ts +11 -7
- package/dist/index.js +8 -5
- package/dist/localize.d.ts +0 -11
- package/dist/localize.js +11 -29
- package/dist/next/page-renderer.d.ts +4 -0
- package/dist/next/page-renderer.js +14 -1
- package/dist/page.js +5 -1
- package/dist/paths.d.ts +46 -27
- package/dist/paths.js +62 -29
- package/dist/refs.js +8 -0
- package/dist/resolve.d.ts +3 -0
- package/dist/resolve.js +38 -0
- package/dist/revalidate.d.ts +4 -0
- package/dist/revalidate.js +6 -1
- package/dist/shared.d.ts +46 -0
- package/dist/shared.js +36 -0
- package/dist/site.d.ts +16 -2
- package/dist/site.js +92 -39
- package/dist/sql-space.d.ts +1 -1
- package/dist/sql-space.js +16 -13
- package/dist/sql.js +95 -70
- package/dist/store.d.ts +66 -32
- package/dist/store.js +205 -90
- package/dist/supabase-entry-store.d.ts +30 -9
- package/dist/supabase-entry-store.js +360 -178
- package/dist/supabase-store.d.ts +21 -9
- package/dist/supabase-store.js +202 -111
- package/dist/zones.d.ts +6 -2
- package/dist/zones.js +8 -2
- package/package.json +1 -1
package/dist/collections.d.ts
CHANGED
|
@@ -9,7 +9,10 @@ export type EntryOrder = "manual" | {
|
|
|
9
9
|
export declare const DEFAULT_ORDER: EntryOrder;
|
|
10
10
|
export declare const BUILT_IN_ORDER_FIELDS: readonly ["createdAt", "updatedAt"];
|
|
11
11
|
export type CollectionSchema<F extends BuilderMap = BuilderMap> = {
|
|
12
|
-
|
|
12
|
+
/** "shared" marks a code-declared singleton (spec 2026-09-07 decision
|
|
13
|
+
* 4): a shared item IS a collection with one entry, so both entry
|
|
14
|
+
* stores accept it in the same list and branch on this. */
|
|
15
|
+
kind: "collection" | "shared";
|
|
13
16
|
name: string;
|
|
14
17
|
title: string;
|
|
15
18
|
titleField?: string;
|
|
@@ -25,6 +28,11 @@ export type CollectionSchema<F extends BuilderMap = BuilderMap> = {
|
|
|
25
28
|
admin?: {
|
|
26
29
|
listColumns?: string[];
|
|
27
30
|
};
|
|
31
|
+
/** Per-locale version history for this collection's entries (spec
|
|
32
|
+
* 2026-09-06 §7): every save becomes an `entry_versions` snapshot and
|
|
33
|
+
* publish moves a pointer, as on pages. Off by default — a People list
|
|
34
|
+
* does not want history; articles do. */
|
|
35
|
+
versions?: boolean;
|
|
28
36
|
/** phantom: the entry value shape, used by f.ref target inference */
|
|
29
37
|
readonly __entry?: NamespaceValues<F>;
|
|
30
38
|
};
|
|
@@ -43,6 +51,7 @@ export declare function collection<F extends BuilderMap>(input: {
|
|
|
43
51
|
admin?: {
|
|
44
52
|
listColumns?: string[];
|
|
45
53
|
};
|
|
54
|
+
versions?: boolean;
|
|
46
55
|
}): CollectionSchema<F>;
|
|
47
56
|
export type Toolset = {
|
|
48
57
|
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.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Descriptor, FieldBuilder } from "./fields.ts";
|
|
2
2
|
import type { CollectionSchema } from "./collections.ts";
|
|
3
|
+
import type { SharedSchema } from "./shared.ts";
|
|
3
4
|
type Registered = {
|
|
4
5
|
schema: {
|
|
5
6
|
kind: string;
|
|
@@ -19,6 +20,8 @@ export type SmoodlyConfig = {
|
|
|
19
20
|
collections?: CollectionSchema<any>[];
|
|
20
21
|
/** Page shapes and entry pages alike — everything that renders a URL. */
|
|
21
22
|
pages?: Registered[];
|
|
23
|
+
/** Shared content: code-declared singletons (spec 2026-09-07). */
|
|
24
|
+
shared?: SharedSchema[];
|
|
22
25
|
};
|
|
23
26
|
locales: {
|
|
24
27
|
default: string;
|
package/dist/config.js
CHANGED
|
@@ -1,17 +1,50 @@
|
|
|
1
|
-
import { assertSegment, perLocaleSegments } from "./paths.js";
|
|
1
|
+
import { assertSegment, fixedPageSegments, perLocaleSegments } from "./paths.js";
|
|
2
|
+
const RESERVED_NAME = "shared"; // the placement node type and the admin route
|
|
2
3
|
function assertUniqueNames(config) {
|
|
3
4
|
const seen = new Set();
|
|
5
|
+
const sectionNameList = (config.registry.sections ?? []).map((s) => s.schema.name);
|
|
6
|
+
const sectionNames = new Set(sectionNameList);
|
|
4
7
|
const all = [
|
|
5
|
-
...
|
|
8
|
+
...sectionNameList,
|
|
6
9
|
...(config.registry.elements ?? []).map((e) => e.schema.name),
|
|
7
10
|
...(config.registry.collections ?? []).map((c) => c.name),
|
|
8
11
|
...(config.registry.pages ?? []).filter((p) => p.schema.kind !== "entryPage").map((p) => p.schema.name),
|
|
9
12
|
];
|
|
10
13
|
for (const name of all) {
|
|
14
|
+
if (name === RESERVED_NAME)
|
|
15
|
+
throw new Error(`smoodly: "${RESERVED_NAME}" is a reserved name.`);
|
|
11
16
|
if (seen.has(name))
|
|
12
17
|
throw new Error(`smoodly: duplicate registered name "${name}".`);
|
|
13
18
|
seen.add(name);
|
|
14
19
|
}
|
|
20
|
+
// Shared items get their own pass, after every other kind is settled: a
|
|
21
|
+
// shared item's name must be unique among shared items, and must not
|
|
22
|
+
// collide with any collection/element/page/section name above — EXCEPT a
|
|
23
|
+
// visual item may reuse the name of the registered section it renders
|
|
24
|
+
// (spec 2026-09-07 §2's example pairs a "footer" section with a "footer"
|
|
25
|
+
// item). That exemption is narrow: it only excuses a match against the
|
|
26
|
+
// item's own, actually-registered section — never against a collection,
|
|
27
|
+
// element, or page that happens to share the string, and never against a
|
|
28
|
+
// second shared item of the same name.
|
|
29
|
+
const sharedSeen = new Set();
|
|
30
|
+
for (const item of config.registry.shared ?? []) {
|
|
31
|
+
const ownSection = item.name === item.section && sectionNames.has(item.name);
|
|
32
|
+
if (item.name === RESERVED_NAME)
|
|
33
|
+
throw new Error(`smoodly: "${RESERVED_NAME}" is a reserved name.`);
|
|
34
|
+
if (sharedSeen.has(item.name) || (seen.has(item.name) && !ownSection)) {
|
|
35
|
+
throw new Error(`smoodly: duplicate registered name "${item.name}".`);
|
|
36
|
+
}
|
|
37
|
+
sharedSeen.add(item.name);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** A visual item renders through a registered section. */
|
|
41
|
+
function assertSharedSections(config) {
|
|
42
|
+
const sections = new Set((config.registry.sections ?? []).map((s) => s.schema.name));
|
|
43
|
+
for (const item of config.registry.shared ?? []) {
|
|
44
|
+
if (item.section !== undefined && !sections.has(item.section)) {
|
|
45
|
+
throw new Error(`smoodly: shared item "${item.name}" renders section "${item.section}", which is not registered.`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
15
48
|
}
|
|
16
49
|
/** Every root-level segment a registration claims, per locale: fixed page
|
|
17
50
|
* slugs and collection paths. Two claims on one (locale, segment) are a
|
|
@@ -19,7 +52,11 @@ function assertUniqueNames(config) {
|
|
|
19
52
|
* EVERY locale of that path, which is the collection's index page. */
|
|
20
53
|
function assertRootSegments(config) {
|
|
21
54
|
const { default: def, supported } = config.locales;
|
|
55
|
+
// Mounts claim exactly the locales their path names; fixed pages fill an
|
|
56
|
+
// omitted locale from the default one, so the check must fill the same
|
|
57
|
+
// way the materializer does or the clash only shows up at materialization.
|
|
22
58
|
const perLocale = (v) => perLocaleSegments(v, supported);
|
|
59
|
+
const pageSegments = (v) => fixedPageSegments(v, config.locales);
|
|
23
60
|
const pages = (config.registry.pages ?? []).filter((p) => p.schema.kind === "page" && p.schema.slug !== undefined);
|
|
24
61
|
const owners = new Map(); // "<locale> <segment>" → page name
|
|
25
62
|
for (const p of pages) {
|
|
@@ -27,7 +64,7 @@ function assertRootSegments(config) {
|
|
|
27
64
|
if (typeof slug !== "string" && slug[def] === undefined) {
|
|
28
65
|
throw new Error(`smoodly: page "${p.schema.name}" declares a per-locale slug without the default locale "${def}".`);
|
|
29
66
|
}
|
|
30
|
-
for (const [locale, segment] of Object.entries(
|
|
67
|
+
for (const [locale, segment] of Object.entries(pageSegments(slug))) {
|
|
31
68
|
assertSegment(segment, `page "${p.schema.name}" fixed slug`);
|
|
32
69
|
const owner = owners.get(`${locale} ${segment}`);
|
|
33
70
|
if (owner) {
|
|
@@ -62,15 +99,32 @@ function assertRootSegments(config) {
|
|
|
62
99
|
if (!pageName)
|
|
63
100
|
continue;
|
|
64
101
|
const page = pages.find((p) => p.schema.name === pageName);
|
|
65
|
-
const everywhere = Object.entries(segments).every(([l, s]) =>
|
|
102
|
+
const everywhere = Object.entries(segments).every(([l, s]) => pageSegments(page.schema.slug)[l] === s);
|
|
66
103
|
if (!everywhere) {
|
|
67
104
|
throw new Error(`smoodly: collection "${c.name}" and page registration "${pageName}" both claim the root segment "${segment}" (${locale}).`);
|
|
68
105
|
}
|
|
69
106
|
}
|
|
70
107
|
}
|
|
71
108
|
}
|
|
109
|
+
/** A language subtag, optionally a script or region subtag (`en`, `fi`,
|
|
110
|
+
* `en-US`, `zh-Hant`). Codes are otherwise opaque strings all the way
|
|
111
|
+
* down — the paths key, the page_locales key — so the only place a
|
|
112
|
+
* typo can be caught is here, at boot (spec 2026-09-06 §5). */
|
|
113
|
+
const LOCALE_CODE_RE = /^[a-z]{2,3}(?:-[A-Za-z]{2,4})?$/;
|
|
114
|
+
function assertLocales(config) {
|
|
115
|
+
const { default: def, supported } = config.locales;
|
|
116
|
+
for (const code of supported) {
|
|
117
|
+
if (!LOCALE_CODE_RE.test(code)) {
|
|
118
|
+
throw new Error(`smoodly: "${code}" is not a valid locale code — use a language tag like "en", "fi", "en-US" or "zh-Hant".`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (!supported.includes(def))
|
|
122
|
+
throw new Error(`smoodly: the default locale "${def}" is not in the supported list.`);
|
|
123
|
+
}
|
|
72
124
|
export function defineConfig(config) {
|
|
125
|
+
assertLocales(config);
|
|
73
126
|
assertUniqueNames(config);
|
|
127
|
+
assertSharedSections(config);
|
|
74
128
|
assertRootSegments(config);
|
|
75
129
|
const depth = config.pages?.tree?.depth ?? 1;
|
|
76
130
|
if (!Number.isInteger(depth) || depth < 1)
|
package/dist/entry-store.d.ts
CHANGED
|
@@ -1,19 +1,39 @@
|
|
|
1
1
|
import type { CollectionSchema, EntryOrder } from "./collections.ts";
|
|
2
|
+
import type { SharedSchema } from "./shared.ts";
|
|
3
|
+
import { type RefEdge } from "./refs.ts";
|
|
2
4
|
import { MemoryRefIndex } from "./ref-index.ts";
|
|
3
5
|
import { type PathIndex } from "./path-index.ts";
|
|
4
6
|
import { type LocaleSet, type PathTarget } from "./paths.ts";
|
|
7
|
+
export type EntryLocale = {
|
|
8
|
+
/** The entry's segment in this locale; null claims no URL. */
|
|
9
|
+
slug: string | null;
|
|
10
|
+
status: "draft" | "published";
|
|
11
|
+
/** ONLY the .localized() fields, for this locale. */
|
|
12
|
+
fields: Record<string, unknown>;
|
|
13
|
+
draftVersionId: string | null;
|
|
14
|
+
publishedVersionId: string | null;
|
|
15
|
+
updatedAt: number;
|
|
16
|
+
};
|
|
5
17
|
export type EntryRecord = {
|
|
6
18
|
id: string;
|
|
7
19
|
collection: string;
|
|
8
20
|
parentId: string | null;
|
|
21
|
+
/** The SHARED fields — every field without .localized(). */
|
|
9
22
|
fields: Record<string, unknown>;
|
|
10
|
-
|
|
11
|
-
|
|
23
|
+
/** Only the locales present. Presence IS the row. */
|
|
24
|
+
locales: Record<string, EntryLocale>;
|
|
12
25
|
sort: number | null;
|
|
13
|
-
locales: string[] | null;
|
|
14
26
|
createdAt: number;
|
|
15
27
|
updatedAt: number;
|
|
16
28
|
};
|
|
29
|
+
export type EntryVersion = {
|
|
30
|
+
id: string;
|
|
31
|
+
entryId: string;
|
|
32
|
+
locale: string;
|
|
33
|
+
/** The MERGED fields (shared + localized + slug) at that save. */
|
|
34
|
+
fields: Record<string, unknown>;
|
|
35
|
+
createdAt: number;
|
|
36
|
+
};
|
|
17
37
|
export type EntryUsage = {
|
|
18
38
|
sourceKind: string;
|
|
19
39
|
sourceId: string;
|
|
@@ -22,47 +42,66 @@ export type EntryUsage = {
|
|
|
22
42
|
export type ListOptions = {
|
|
23
43
|
/** Absent = the collection's configured order (config); `"manual"` sorts by `sort` asc. */
|
|
24
44
|
order?: EntryOrder;
|
|
45
|
+
/** With `status`: the locale whose row is inspected; the default locale when omitted. */
|
|
46
|
+
locale?: string;
|
|
47
|
+
/** Only entries whose row in `locale` is published. */
|
|
25
48
|
status?: "published";
|
|
26
49
|
/**
|
|
27
50
|
* `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.
|
|
51
|
+
* (or explicitly `undefined`) applies no parent filter.
|
|
30
52
|
*/
|
|
31
53
|
parent?: string | null;
|
|
32
54
|
};
|
|
33
55
|
export type CreateEntryOptions = {
|
|
56
|
+
locale: string;
|
|
34
57
|
parentId?: string | null;
|
|
35
|
-
|
|
58
|
+
};
|
|
59
|
+
export type AddEntryLocaleOptions = {
|
|
60
|
+
from: string;
|
|
36
61
|
};
|
|
37
62
|
export type EntryStoreOptions = {
|
|
38
63
|
paths?: PathIndex;
|
|
39
64
|
locales?: LocaleSet;
|
|
65
|
+
shared?: SharedSchema[];
|
|
40
66
|
};
|
|
41
67
|
export interface EntryStore {
|
|
42
|
-
|
|
68
|
+
/** Creates the node and ONE locale row from the merged `fields`: shared
|
|
69
|
+
* keys go to the node, .localized() keys and `slug` to the row. The
|
|
70
|
+
* parent (if any) must exist in that locale. */
|
|
71
|
+
create(collection: string, fields: Record<string, unknown>, options: CreateEntryOptions): Promise<EntryRecord>;
|
|
43
72
|
get(collection: string, id: string): Promise<EntryRecord | null>;
|
|
44
73
|
list(collection: string, options?: ListOptions): Promise<EntryRecord[]>;
|
|
45
|
-
/** Batched read for the resolver:
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
|
|
74
|
+
/** Batched read for the resolver: the MERGED fields in `locale`, keyed
|
|
75
|
+
* by id. An id that is missing, absent in the locale, or (with
|
|
76
|
+
* `status`) unpublished there is omitted. With `{ status: "published" }`
|
|
77
|
+
* a versioned collection answers from the published snapshot, so a
|
|
78
|
+
* post-publish edit never leaks (DESIGN.md OQ #16). The signature,
|
|
79
|
+
* with the locale bound, IS resolveTree's EntryFetcher. */
|
|
80
|
+
getMany(collection: string, ids: string[], locale: string, options?: {
|
|
51
81
|
status?: "published";
|
|
52
82
|
}): Promise<Record<string, Record<string, unknown>>>;
|
|
53
|
-
|
|
54
|
-
|
|
83
|
+
/** Replaces the merged fields for `locale`: shared keys on the node
|
|
84
|
+
* (every locale sees them), localized keys and the slug on the row. */
|
|
85
|
+
update(collection: string, id: string, locale: string, fields: Record<string, unknown>): Promise<EntryRecord>;
|
|
86
|
+
setStatus(collection: string, id: string, locale: string, status: "draft" | "published"): Promise<EntryRecord>;
|
|
87
|
+
/** Copies `from`'s localized fields and slug as the new row (shared
|
|
88
|
+
* fields need no copy); refused when the entry has the locale or its
|
|
89
|
+
* parent lacks it. */
|
|
90
|
+
addLocale(collection: string, id: string, locale: string, options: AddEntryLocaleOptions): Promise<EntryRecord>;
|
|
91
|
+
/** Deletes the row, its versions and its paths rows; refused for the
|
|
92
|
+
* last locale and while a child has it. */
|
|
93
|
+
removeLocale(collection: string, id: string, locale: string): Promise<EntryRecord>;
|
|
94
|
+
/** Newest first; empty for a collection without `versions`. */
|
|
95
|
+
listVersions(collection: string, id: string, locale: string): Promise<EntryVersion[]>;
|
|
55
96
|
/** Blocked while anything references the entry (the safe-delete guard),
|
|
56
|
-
* and while it still has child entries. */
|
|
97
|
+
* and while it still has child entries. Removes every locale. */
|
|
57
98
|
delete(collection: string, id: string): Promise<void>;
|
|
58
99
|
/**
|
|
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.
|
|
100
|
+
* Reparents `id` under `to.parentId`, which must exist in every locale
|
|
101
|
+
* the entry has. `to.index` is the moved entry's final 0-based position
|
|
102
|
+
* among the destination siblings; omitted, it appends; out-of-range
|
|
103
|
+
* values clamp. Destination siblings are renumbered 1..N afterward.
|
|
104
|
+
* Like `reorder`, `move` does NOT bump `updatedAt`.
|
|
66
105
|
*/
|
|
67
106
|
move(collection: string, id: string, to: {
|
|
68
107
|
parentId: string | null;
|
|
@@ -72,8 +111,7 @@ export interface EntryStore {
|
|
|
72
111
|
reorder(collection: string, orderedIds: string[]): Promise<void>;
|
|
73
112
|
/** The refs index's reverse question: who points at this entry? */
|
|
74
113
|
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. */
|
|
114
|
+
/** Reverse lookup by id alone (ids are globally unique) — the fan-out walk. */
|
|
77
115
|
incoming(targetId: string): Promise<{
|
|
78
116
|
sourceKind: string;
|
|
79
117
|
sourceId: string;
|
|
@@ -82,69 +120,117 @@ export interface EntryStore {
|
|
|
82
120
|
pathsOf(collection: string, id: string): Promise<Record<string, string>>;
|
|
83
121
|
}
|
|
84
122
|
export declare function schemaOf(collections: CollectionSchema<any>[], name: string): CollectionSchema<any>;
|
|
123
|
+
/** A shared item is a collection with exactly one entry (spec 2026-09-07). */
|
|
124
|
+
export declare const isSharedSchema: (schema: CollectionSchema<any>) => boolean;
|
|
85
125
|
export declare const DEFAULT_ENTRY_LOCALES: LocaleSet;
|
|
86
126
|
export declare const entryErrors: {
|
|
87
127
|
notFound: (collection: string, id: string) => Error;
|
|
88
128
|
hasChildren: (collection: string, id: string) => Error;
|
|
89
129
|
cycle: () => Error;
|
|
90
130
|
foreignParent: () => Error;
|
|
91
|
-
/** Entry slugs are unique among SIBLINGS
|
|
92
|
-
* 2026-09-
|
|
93
|
-
*
|
|
94
|
-
* entries_sibling_slug_key 23505 onto this same error. */
|
|
131
|
+
/** Entry slugs are unique among SIBLINGS per locale — the rule pages
|
|
132
|
+
* have (spec 2026-09-06 §11): both adapters pre-check in code and the
|
|
133
|
+
* paths primary key is the invariant. ONE source for the text. */
|
|
95
134
|
slugTaken: (collection: string) => Error;
|
|
135
|
+
unsupportedLocale: (locale: string) => Error;
|
|
136
|
+
notInLocale: (id: string, locale: string) => Error;
|
|
137
|
+
alreadyInLocale: (id: string, locale: string) => Error;
|
|
138
|
+
parentNotInLocale: (locale: string) => Error;
|
|
139
|
+
lastLocale: (id: string) => Error;
|
|
140
|
+
localeInUse: (locale: string, titles: string[]) => Error;
|
|
141
|
+
moveLocales: (locale: string) => Error;
|
|
142
|
+
singleton: (name: string) => Error;
|
|
143
|
+
owned: (name: string, verb: string) => Error;
|
|
144
|
+
noSlug: (name: string) => Error;
|
|
145
|
+
everyLocale: (name: string) => Error;
|
|
146
|
+
};
|
|
147
|
+
/** The keys a locale row owns: every field with .localized(), except
|
|
148
|
+
* `slug`, which is the row's own column (a segment is an address, per
|
|
149
|
+
* locale by nature). */
|
|
150
|
+
export declare function localizedKeys(schema: CollectionSchema<any>): string[];
|
|
151
|
+
export type SplitFields = {
|
|
152
|
+
shared: Record<string, unknown>;
|
|
153
|
+
localized: Record<string, unknown>;
|
|
154
|
+
slug: string | null;
|
|
96
155
|
};
|
|
156
|
+
/** The merged form an editor submits, split the way the tables store it.
|
|
157
|
+
* Every key that is neither localized nor `slug` is shared — declared or
|
|
158
|
+
* not — so a stray key never disappears into a locale row. */
|
|
159
|
+
export declare function splitFields(fields: Record<string, unknown>, schema: CollectionSchema<any>): SplitFields;
|
|
160
|
+
export declare function entryLocaleRow(record: EntryRecord, locale: string): EntryLocale;
|
|
161
|
+
/** The merged view of one locale: shared + that locale's fields + its
|
|
162
|
+
* slug. A fresh object; throws for an absent locale. */
|
|
163
|
+
export declare function entryFieldsIn(record: EntryRecord, locale: string): Record<string, unknown>;
|
|
164
|
+
/** The refs index rows for an entry: the union over every live field set
|
|
165
|
+
* — one per present locale, plus each published snapshot the draft has
|
|
166
|
+
* moved past. Safe-delete must hold while ANY of them shows the target,
|
|
167
|
+
* since the published site still renders it (DESIGN.md §3 "Page edges
|
|
168
|
+
* in refs", the same stance). */
|
|
169
|
+
export declare function entryRefEdges(fieldSets: Record<string, unknown>[], schema: CollectionSchema<any>): RefEdge[];
|
|
170
|
+
/** What a message calls an entry: its title field in that locale, else its id. */
|
|
171
|
+
export declare function entryTitleIn(record: EntryRecord, locale: string, schema: CollectionSchema<any>): string;
|
|
97
172
|
/**
|
|
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).
|
|
173
|
+
* Sort comparator for one order rule; `seq` breaks ties by creation. A
|
|
174
|
+
* declared order field is SHARED (collection() refuses a localized one),
|
|
175
|
+
* so `e.fields[order.by]` reads the node. Nulls sort last in BOTH
|
|
176
|
+
* directions (the Supabase adapter uses `nullsFirst: false` to match);
|
|
177
|
+
* the creation tiebreak follows `direction` for non-manual orders.
|
|
106
178
|
* 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.
|
|
179
|
+
* otherwise as strings via `localeCompare` — the same order Postgres
|
|
180
|
+
* gives the jsonb VALUE (`fields->x`) for one type; see the README's
|
|
181
|
+
* follow-ups for the mixed-type and JSON-null edges.
|
|
116
182
|
*/
|
|
117
183
|
export declare function compareEntries(order: EntryOrder, seq: (e: EntryRecord) => number): (a: EntryRecord, b: EntryRecord) => number;
|
|
118
184
|
export declare class MemoryEntryStore implements EntryStore {
|
|
119
|
-
private collections;
|
|
120
185
|
private refs;
|
|
121
186
|
private entries;
|
|
187
|
+
private versions;
|
|
188
|
+
/** Insertion order per version — `createdAt` alone can tie within a millisecond. */
|
|
189
|
+
private versionSeq;
|
|
122
190
|
private seq;
|
|
123
191
|
private order;
|
|
124
192
|
private paths;
|
|
125
193
|
private locales;
|
|
194
|
+
private schemas;
|
|
126
195
|
constructor(collections: CollectionSchema<any>[], refs?: MemoryRefIndex, options?: EntryStoreOptions);
|
|
196
|
+
private schema;
|
|
127
197
|
private row;
|
|
128
198
|
private must;
|
|
129
199
|
private byId;
|
|
200
|
+
private snapshot;
|
|
130
201
|
private subtree;
|
|
202
|
+
/** An edit bumps the node and the edited row; `move` and `reorder` never call this. */
|
|
203
|
+
private bump;
|
|
204
|
+
/** A versioned collection records every save as a merged snapshot and
|
|
205
|
+
* points the locale's draft at it; others keep the row as the live content. */
|
|
206
|
+
private recordVersion;
|
|
207
|
+
private dropVersions;
|
|
208
|
+
/** Every field set the site can render for this entry: each present
|
|
209
|
+
* locale's live view, plus a published snapshot the draft has moved
|
|
210
|
+
* past (still rendered by the published site). */
|
|
211
|
+
private liveFieldSets;
|
|
131
212
|
private writePaths;
|
|
132
213
|
private writeRefs;
|
|
133
214
|
private siblings;
|
|
134
|
-
/** The
|
|
135
|
-
* unique among the
|
|
136
|
-
*
|
|
137
|
-
* predicate folds "" to null too via nullif). */
|
|
215
|
+
/** The friendly pre-check (spec 2026-09-06 §11): a string slug must be
|
|
216
|
+
* unique among the siblings PRESENT in that locale; null never
|
|
217
|
+
* conflicts. The paths primary key remains the invariant. */
|
|
138
218
|
private assertSiblingSlug;
|
|
219
|
+
/** Same collection, within depth. Returns the parent (null at the root). */
|
|
139
220
|
private assertParent;
|
|
140
|
-
|
|
221
|
+
/** The parent rule: a locale can only be added where the parent has it. */
|
|
222
|
+
private assertParentHasLocale;
|
|
223
|
+
create(collection: string, fields: Record<string, unknown>, options: CreateEntryOptions): Promise<EntryRecord>;
|
|
141
224
|
get(collection: string, id: string): Promise<EntryRecord | null>;
|
|
142
225
|
list(collection: string, options?: ListOptions): Promise<EntryRecord[]>;
|
|
143
|
-
getMany(collection: string, ids: string[], options?: {
|
|
226
|
+
getMany(collection: string, ids: string[], locale: string, options?: {
|
|
144
227
|
status?: "published";
|
|
145
228
|
}): 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>;
|
|
229
|
+
update(collection: string, id: string, locale: string, fields: Record<string, unknown>): Promise<EntryRecord>;
|
|
230
|
+
setStatus(collection: string, id: string, locale: string, status: "draft" | "published"): Promise<EntryRecord>;
|
|
231
|
+
addLocale(collection: string, id: string, locale: string, options: AddEntryLocaleOptions): Promise<EntryRecord>;
|
|
232
|
+
removeLocale(collection: string, id: string, locale: string): Promise<EntryRecord>;
|
|
233
|
+
listVersions(collection: string, id: string, locale: string): Promise<EntryVersion[]>;
|
|
148
234
|
move(collection: string, id: string, to: {
|
|
149
235
|
parentId: string | null;
|
|
150
236
|
index?: number;
|