smoodly 0.0.1
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/LICENSE +21 -0
- package/README.md +211 -0
- package/dist/admin/auth.d.ts +10 -0
- package/dist/admin/auth.js +28 -0
- package/dist/admin/client-ops.d.ts +2 -0
- package/dist/admin/client-ops.js +18 -0
- package/dist/admin/editor/EditorView.d.ts +7 -0
- package/dist/admin/editor/EditorView.js +391 -0
- package/dist/admin/editor/autosave.d.ts +31 -0
- package/dist/admin/editor/autosave.js +108 -0
- package/dist/admin/editor/protocol.d.ts +37 -0
- package/dist/admin/editor/protocol.js +1 -0
- package/dist/admin/forms/FieldWidget.d.ts +14 -0
- package/dist/admin/forms/FieldWidget.js +54 -0
- package/dist/admin/forms/tabs.d.ts +11 -0
- package/dist/admin/forms/tabs.js +20 -0
- package/dist/admin/index.d.ts +10 -0
- package/dist/admin/index.js +8 -0
- package/dist/admin/next/bridge.d.ts +1 -0
- package/dist/admin/next/bridge.js +66 -0
- package/dist/admin/next/create-admin.d.ts +18 -0
- package/dist/admin/next/create-admin.js +14 -0
- package/dist/admin/next/index.d.ts +6 -0
- package/dist/admin/next/index.js +3 -0
- package/dist/admin/next/op-handler.d.ts +11 -0
- package/dist/admin/next/op-handler.js +23 -0
- package/dist/admin/ops-impl.d.ts +20 -0
- package/dist/admin/ops-impl.js +189 -0
- package/dist/admin/ops.d.ts +52 -0
- package/dist/admin/ops.js +1 -0
- package/dist/admin/page-path.d.ts +1 -0
- package/dist/admin/page-path.js +6 -0
- package/dist/admin/richtext.d.ts +14 -0
- package/dist/admin/richtext.js +17 -0
- package/dist/admin/serialize.d.ts +40 -0
- package/dist/admin/serialize.js +49 -0
- package/dist/admin/shell/AdminApp.d.ts +9 -0
- package/dist/admin/shell/AdminApp.js +122 -0
- package/dist/admin/shell/EntryForm.d.ts +8 -0
- package/dist/admin/shell/EntryForm.js +96 -0
- package/dist/admin/shell/ListView.d.ts +15 -0
- package/dist/admin/shell/ListView.js +14 -0
- package/dist/admin/shell/LoginView.d.ts +5 -0
- package/dist/admin/shell/LoginView.js +47 -0
- package/dist/admin/shell/pages-list.d.ts +22 -0
- package/dist/admin/shell/pages-list.js +31 -0
- package/dist/admin/shell/session.d.ts +19 -0
- package/dist/admin/shell/session.js +37 -0
- package/dist/admin/supabase-auth.d.ts +7 -0
- package/dist/admin/supabase-auth.js +23 -0
- package/dist/admin/tree-ops.d.ts +19 -0
- package/dist/admin/tree-ops.js +112 -0
- package/dist/admin/validate.d.ts +10 -0
- package/dist/admin/validate.js +19 -0
- package/dist/client.d.ts +11 -0
- package/dist/client.js +19 -0
- package/dist/collections.d.ts +39 -0
- package/dist/collections.js +22 -0
- package/dist/config.d.ts +49 -0
- package/dist/config.js +54 -0
- package/dist/entry-store.d.ts +74 -0
- package/dist/entry-store.js +132 -0
- package/dist/env.d.ts +16 -0
- package/dist/env.js +22 -0
- package/dist/fields.d.ts +67 -0
- package/dist/fields.js +41 -0
- package/dist/index.d.ts +103 -0
- package/dist/index.js +29 -0
- package/dist/next/index.d.ts +3 -0
- package/dist/next/index.js +2 -0
- package/dist/next/meta.d.ts +10 -0
- package/dist/next/meta.js +37 -0
- package/dist/next/page-renderer.d.ts +36 -0
- package/dist/next/page-renderer.js +69 -0
- package/dist/page.d.ts +22 -0
- package/dist/page.js +35 -0
- package/dist/pairing.d.ts +33 -0
- package/dist/pairing.js +26 -0
- package/dist/ref-index.d.ts +19 -0
- package/dist/ref-index.js +0 -0
- package/dist/refs.d.ts +20 -0
- package/dist/refs.js +49 -0
- package/dist/render.d.ts +48 -0
- package/dist/render.js +41 -0
- package/dist/resolve.d.ts +22 -0
- package/dist/resolve.js +128 -0
- package/dist/revalidate.d.ts +8 -0
- package/dist/revalidate.js +34 -0
- package/dist/schema.d.ts +57 -0
- package/dist/schema.js +40 -0
- package/dist/section-wrapper.d.ts +11 -0
- package/dist/section-wrapper.js +19 -0
- package/dist/sql-space.d.ts +6 -0
- package/dist/sql-space.js +172 -0
- package/dist/sql.d.ts +3 -0
- package/dist/sql.js +162 -0
- package/dist/store.d.ts +73 -0
- package/dist/store.js +145 -0
- package/dist/supabase-entry-store.d.ts +26 -0
- package/dist/supabase-entry-store.js +191 -0
- package/dist/supabase-store.d.ts +26 -0
- package/dist/supabase-store.js +180 -0
- package/dist/zones.d.ts +31 -0
- package/dist/zones.js +23 -0
- package/package.json +50 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Descriptor } from "./fields.ts";
|
|
2
|
+
import type { CollectionSchema } from "./collections.ts";
|
|
3
|
+
import type { PageTree } from "./render.tsx";
|
|
4
|
+
export type EntryFetcher = (collection: string, ids: string[]) => Promise<Record<string, Record<string, unknown>>>;
|
|
5
|
+
type SchemaLike = {
|
|
6
|
+
name: string;
|
|
7
|
+
fields: Record<string, Descriptor>;
|
|
8
|
+
};
|
|
9
|
+
type ResolveOptions = {
|
|
10
|
+
sections: ({
|
|
11
|
+
name: string;
|
|
12
|
+
fields: Record<string, Descriptor>;
|
|
13
|
+
} | {
|
|
14
|
+
schema: SchemaLike;
|
|
15
|
+
})[];
|
|
16
|
+
collections: CollectionSchema<any>[];
|
|
17
|
+
fetch: EntryFetcher;
|
|
18
|
+
/** Optional safety valve on chain length; unlimited by default. */
|
|
19
|
+
maxDepth?: number;
|
|
20
|
+
};
|
|
21
|
+
export declare function resolveTree(tree: PageTree, options: ResolveOptions): Promise<PageTree>;
|
|
22
|
+
export {};
|
package/dist/resolve.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// The batched ref-resolution pass (DESIGN.md §3): one walk collects every
|
|
2
|
+
// ref in the tree, fetches per collection in single batched calls, and
|
|
3
|
+
// hands components hydrated entries. The ref site alone decides depth:
|
|
4
|
+
// bare refs hydrate one entry (refs stay ids), `.resolve("key")` follows
|
|
5
|
+
// the named refs one level, and bare `.resolve()` follows the whole chain.
|
|
6
|
+
// Cycles are cut per path — a ref back to an entry already on its own
|
|
7
|
+
// chain gets a shallow copy (refs as ids), so traversal terminates by
|
|
8
|
+
// construction and the output stays JSON-serializable.
|
|
9
|
+
function targetOf(d) {
|
|
10
|
+
const t = d.target?.();
|
|
11
|
+
if (!t?.name)
|
|
12
|
+
throw new Error("smoodly: ref descriptor has no resolvable target.");
|
|
13
|
+
return t.name;
|
|
14
|
+
}
|
|
15
|
+
function modeOf(d) {
|
|
16
|
+
const r = d.resolve;
|
|
17
|
+
if (r === "full")
|
|
18
|
+
return "full";
|
|
19
|
+
if (r && typeof r === "object")
|
|
20
|
+
return r;
|
|
21
|
+
return "shallow";
|
|
22
|
+
}
|
|
23
|
+
export async function resolveTree(tree, options) {
|
|
24
|
+
const maxDepth = options.maxDepth ?? Infinity;
|
|
25
|
+
const sectionSchemas = new Map(options.sections.map((s) => {
|
|
26
|
+
const schema = "schema" in s ? s.schema : s;
|
|
27
|
+
return [schema.name, schema];
|
|
28
|
+
}));
|
|
29
|
+
const collectionSchemas = new Map(options.collections.map((c) => [c.name, c]));
|
|
30
|
+
const resolved = structuredClone(tree);
|
|
31
|
+
// every entry is fetched once; cycle cuts reuse cached ancestors
|
|
32
|
+
const cache = new Map();
|
|
33
|
+
// seed tasks from every node's ref-typed fields
|
|
34
|
+
let tasks = [];
|
|
35
|
+
for (const nodes of Object.values(resolved.zones ?? {})) {
|
|
36
|
+
for (const node of nodes) {
|
|
37
|
+
const schema = sectionSchemas.get(node.type);
|
|
38
|
+
if (!schema)
|
|
39
|
+
continue;
|
|
40
|
+
collectTasks(node.fields, schema.fields, tasks);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
for (let round = 0; round < maxDepth && tasks.length > 0; round++) {
|
|
44
|
+
// batch: one fetch per collection with unique uncached ids
|
|
45
|
+
const wanted = new Map();
|
|
46
|
+
for (const task of tasks) {
|
|
47
|
+
for (const id of Array.isArray(task.id) ? task.id : [task.id]) {
|
|
48
|
+
if (cache.get(task.collection)?.has(id))
|
|
49
|
+
continue;
|
|
50
|
+
const set = wanted.get(task.collection) ?? new Set();
|
|
51
|
+
set.add(id);
|
|
52
|
+
wanted.set(task.collection, set);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
await Promise.all([...wanted.entries()].map(async ([collection, ids]) => {
|
|
56
|
+
const fetched = await options.fetch(collection, [...ids]);
|
|
57
|
+
const table = cache.get(collection) ?? new Map();
|
|
58
|
+
for (const [id, entry] of Object.entries(fetched))
|
|
59
|
+
table.set(id, entry);
|
|
60
|
+
cache.set(collection, table);
|
|
61
|
+
}));
|
|
62
|
+
// apply results and queue follow-ups per the task's mode
|
|
63
|
+
const next = [];
|
|
64
|
+
for (const task of tasks) {
|
|
65
|
+
const hydrate = (id) => {
|
|
66
|
+
const raw = cache.get(task.collection)?.get(id);
|
|
67
|
+
if (!raw)
|
|
68
|
+
return null;
|
|
69
|
+
const entry = structuredClone(raw);
|
|
70
|
+
const pathKey = `${task.collection}:${id}`;
|
|
71
|
+
// cycle: this entry is its own ancestor — shallow copy, no recursion
|
|
72
|
+
if (task.ancestors.includes(pathKey))
|
|
73
|
+
return entry;
|
|
74
|
+
if (task.mode === "shallow")
|
|
75
|
+
return entry;
|
|
76
|
+
const targetSchema = collectionSchemas.get(task.collection);
|
|
77
|
+
if (!targetSchema)
|
|
78
|
+
return entry;
|
|
79
|
+
const ancestors = [...task.ancestors, pathKey];
|
|
80
|
+
for (const [key, d] of Object.entries(targetSchema.fields)) {
|
|
81
|
+
if (d.type !== "ref" && d.type !== "refList")
|
|
82
|
+
continue;
|
|
83
|
+
let mode;
|
|
84
|
+
if (task.mode === "full") {
|
|
85
|
+
mode = "full";
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
const on = task.mode[key];
|
|
89
|
+
if (!on)
|
|
90
|
+
continue;
|
|
91
|
+
mode = typeof on === "object" ? on : "shallow";
|
|
92
|
+
}
|
|
93
|
+
const value = entry[key];
|
|
94
|
+
if (value == null)
|
|
95
|
+
continue;
|
|
96
|
+
next.push({
|
|
97
|
+
holder: entry,
|
|
98
|
+
key,
|
|
99
|
+
collection: targetOf(d),
|
|
100
|
+
id: value,
|
|
101
|
+
isList: d.type === "refList",
|
|
102
|
+
mode,
|
|
103
|
+
ancestors,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
return entry;
|
|
107
|
+
};
|
|
108
|
+
task.holder[task.key] = task.isList
|
|
109
|
+
? task.id.map(hydrate)
|
|
110
|
+
: hydrate(task.id);
|
|
111
|
+
}
|
|
112
|
+
tasks = next;
|
|
113
|
+
}
|
|
114
|
+
return resolved;
|
|
115
|
+
}
|
|
116
|
+
function collectTasks(values, descriptors, out) {
|
|
117
|
+
for (const [key, d] of Object.entries(descriptors)) {
|
|
118
|
+
const value = values[key];
|
|
119
|
+
if (value == null)
|
|
120
|
+
continue;
|
|
121
|
+
if (d.type === "ref" && typeof value === "string") {
|
|
122
|
+
out.push({ holder: values, key, collection: targetOf(d), id: value, isList: false, mode: modeOf(d), ancestors: [] });
|
|
123
|
+
}
|
|
124
|
+
else if (d.type === "refList" && Array.isArray(value)) {
|
|
125
|
+
out.push({ holder: values, key, collection: targetOf(d), id: value, isList: true, mode: modeOf(d), ancestors: [] });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { EntryStore } from "./entry-store.ts";
|
|
2
|
+
import type { PageStore } from "./store.ts";
|
|
3
|
+
export declare function affectedPageSlugs(stores: {
|
|
4
|
+
pages: PageStore;
|
|
5
|
+
entries: EntryStore;
|
|
6
|
+
}, entryId: string, options?: {
|
|
7
|
+
maxDepth?: number;
|
|
8
|
+
}): Promise<string[]>;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// The revalidation fan-out (DESIGN.md §3): an entry changed — which
|
|
2
|
+
// published pages must re-render? Walks the refs index in REVERSE
|
|
3
|
+
// (entry ← entry ← page), breadth-first with a visited set so cycles
|
|
4
|
+
// terminate; `maxDepth` is an optional safety valve like resolveTree's.
|
|
5
|
+
// Framework-free on purpose: returns slugs, and the Next.js glue maps
|
|
6
|
+
// them to `revalidateTag("page:" + slug)`.
|
|
7
|
+
export async function affectedPageSlugs(stores, entryId, options = {}) {
|
|
8
|
+
const maxDepth = options.maxDepth ?? Infinity;
|
|
9
|
+
const seen = new Set([entryId]);
|
|
10
|
+
const pageIds = new Set();
|
|
11
|
+
let frontier = [entryId];
|
|
12
|
+
for (let depth = 0; depth < maxDepth && frontier.length > 0; depth++) {
|
|
13
|
+
const next = [];
|
|
14
|
+
for (const id of frontier) {
|
|
15
|
+
for (const source of await stores.entries.incoming(id)) {
|
|
16
|
+
if (source.sourceKind === "page") {
|
|
17
|
+
pageIds.add(source.sourceId);
|
|
18
|
+
}
|
|
19
|
+
else if (source.sourceKind === "entry" && !seen.has(source.sourceId)) {
|
|
20
|
+
seen.add(source.sourceId);
|
|
21
|
+
next.push(source.sourceId);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
frontier = next;
|
|
26
|
+
}
|
|
27
|
+
const slugs = [];
|
|
28
|
+
for (const id of pageIds) {
|
|
29
|
+
const page = await stores.pages.getPageById(id);
|
|
30
|
+
if (page?.publishedVersionId)
|
|
31
|
+
slugs.push(page.slug);
|
|
32
|
+
}
|
|
33
|
+
return slugs;
|
|
34
|
+
}
|
package/dist/schema.d.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { Descriptor, FieldBuilder, ValueOf } from "./fields.ts";
|
|
2
|
+
import { pageSchema } from "./page.ts";
|
|
3
|
+
export type BuilderMap = Record<string, FieldBuilder<any, any>>;
|
|
4
|
+
export type SectionSchema<F extends BuilderMap = BuilderMap, S extends BuilderMap = BuilderMap> = {
|
|
5
|
+
kind: "section";
|
|
6
|
+
name: string;
|
|
7
|
+
title: string;
|
|
8
|
+
icon?: string;
|
|
9
|
+
description?: string;
|
|
10
|
+
fields: Record<string, Descriptor>;
|
|
11
|
+
styles: Record<string, Descriptor>;
|
|
12
|
+
meta: Record<string, Descriptor>;
|
|
13
|
+
sample?: Record<string, unknown>;
|
|
14
|
+
styleDefaults?: false | {
|
|
15
|
+
omit: string[];
|
|
16
|
+
};
|
|
17
|
+
/** phantom: builder types for Props inference */
|
|
18
|
+
readonly __f?: F;
|
|
19
|
+
readonly __s?: S;
|
|
20
|
+
};
|
|
21
|
+
export type ElementSchema<F extends BuilderMap = BuilderMap, S extends BuilderMap = BuilderMap> = Omit<SectionSchema<F, S>, "kind"> & {
|
|
22
|
+
kind: "element";
|
|
23
|
+
contexts: ("richtext" | "column")[];
|
|
24
|
+
};
|
|
25
|
+
type OptionalKeys<M extends BuilderMap> = {
|
|
26
|
+
[K in keyof M]: M[K] extends {
|
|
27
|
+
__flags?: infer Fl;
|
|
28
|
+
} ? (Fl extends {
|
|
29
|
+
optional: true;
|
|
30
|
+
} ? K : never) : never;
|
|
31
|
+
}[keyof M];
|
|
32
|
+
export type NamespaceValues<M extends BuilderMap> = {
|
|
33
|
+
[K in Exclude<keyof M, OptionalKeys<M>>]: ValueOf<M[K]>;
|
|
34
|
+
} & {
|
|
35
|
+
[K in OptionalKeys<M>]?: ValueOf<M[K]>;
|
|
36
|
+
};
|
|
37
|
+
type SectionInput<F extends BuilderMap, S extends BuilderMap, M extends BuilderMap> = {
|
|
38
|
+
name: string;
|
|
39
|
+
title: string;
|
|
40
|
+
icon?: string;
|
|
41
|
+
description?: string;
|
|
42
|
+
fields?: F;
|
|
43
|
+
styles?: S;
|
|
44
|
+
meta?: M;
|
|
45
|
+
sample?: Partial<NamespaceValues<F> & NamespaceValues<S>>;
|
|
46
|
+
styleDefaults?: false | {
|
|
47
|
+
omit: string[];
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
export declare const schema: {
|
|
51
|
+
page: typeof pageSchema;
|
|
52
|
+
section<F extends BuilderMap = {}, S extends BuilderMap = {}, M extends BuilderMap = {}>(input: SectionInput<F, S, M>): SectionSchema<F, S>;
|
|
53
|
+
element<F extends BuilderMap = {}, S extends BuilderMap = {}, M extends BuilderMap = {}>(input: SectionInput<F, S, M> & {
|
|
54
|
+
contexts: ("richtext" | "column")[];
|
|
55
|
+
}): ElementSchema<F, S>;
|
|
56
|
+
};
|
|
57
|
+
export {};
|
package/dist/schema.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { pageSchema } from "./page.js";
|
|
2
|
+
function toDescriptors(map) {
|
|
3
|
+
return Object.fromEntries(Object.entries(map ?? {}).map(([k, b]) => [k, b.descriptor]));
|
|
4
|
+
}
|
|
5
|
+
function assertNotLocalized(ns, map) {
|
|
6
|
+
for (const [key, d] of Object.entries(map)) {
|
|
7
|
+
if (d.localized) {
|
|
8
|
+
throw new Error(`smoodly: "${key}" in ${ns} uses .localized(), but localization is fields-only — ${ns} are never translated.`);
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
function baseSchema(input) {
|
|
13
|
+
if (!input?.name)
|
|
14
|
+
throw new Error("smoodly: a schema requires a name.");
|
|
15
|
+
const fields = toDescriptors(input.fields);
|
|
16
|
+
const styles = toDescriptors(input.styles);
|
|
17
|
+
const meta = toDescriptors(input.meta);
|
|
18
|
+
assertNotLocalized("styles", styles);
|
|
19
|
+
assertNotLocalized("meta", meta);
|
|
20
|
+
return {
|
|
21
|
+
name: input.name,
|
|
22
|
+
title: input.title,
|
|
23
|
+
icon: input.icon,
|
|
24
|
+
description: input.description,
|
|
25
|
+
fields,
|
|
26
|
+
styles,
|
|
27
|
+
meta,
|
|
28
|
+
sample: input.sample,
|
|
29
|
+
styleDefaults: input.styleDefaults,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
export const schema = {
|
|
33
|
+
page: pageSchema,
|
|
34
|
+
section(input) {
|
|
35
|
+
return { kind: "section", ...baseSchema(input) };
|
|
36
|
+
},
|
|
37
|
+
element(input) {
|
|
38
|
+
return { kind: "element", contexts: input.contexts, ...baseSchema(input) };
|
|
39
|
+
},
|
|
40
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { CSSProperties, ReactNode } from "react";
|
|
2
|
+
export declare function Section({ as, styles, className, style, children, }: {
|
|
3
|
+
as?: keyof React.JSX.IntrinsicElements;
|
|
4
|
+
styles: Record<string, unknown>;
|
|
5
|
+
className?: string;
|
|
6
|
+
style?: CSSProperties;
|
|
7
|
+
children?: ReactNode;
|
|
8
|
+
}): import("react").ReactElement<{
|
|
9
|
+
className: string | undefined;
|
|
10
|
+
style: CSSProperties | undefined;
|
|
11
|
+
}, string | import("react").JSXElementConstructor<any>>;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { createElement } from "react";
|
|
2
|
+
// The root wrapper every section renders through. Owns the mechanical DOM
|
|
3
|
+
// work on the real root element (no extra div): element choice via `as`,
|
|
4
|
+
// string-valued styles emitted as kebab-cased data attributes. Built-in
|
|
5
|
+
// meta (anchor, visibility) and editor chrome attach here when the
|
|
6
|
+
// renderer and admin land. Bounded magic: everything else stays the dev's
|
|
7
|
+
// job via normal className/style props.
|
|
8
|
+
const kebab = (s) => s.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase());
|
|
9
|
+
export function Section({ as = "section", styles, className, style, children, }) {
|
|
10
|
+
const { __smoodlyNode, ...rest } = (styles ?? {});
|
|
11
|
+
const dataAttrs = {};
|
|
12
|
+
if (typeof __smoodlyNode === "string")
|
|
13
|
+
dataAttrs["data-smoodly-node"] = __smoodlyNode;
|
|
14
|
+
for (const [key, value] of Object.entries(rest)) {
|
|
15
|
+
if (typeof value === "string")
|
|
16
|
+
dataAttrs[`data-${kebab(key)}`] = value;
|
|
17
|
+
}
|
|
18
|
+
return createElement(as, { className, style, ...dataAttrs }, children);
|
|
19
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare const DEFAULT_SPACE = "'00000000-0000-0000-0000-000000000000'";
|
|
2
|
+
export declare const SPACE_COL = "\"space_id\" uuid not null default '00000000-0000-0000-0000-000000000000'";
|
|
3
|
+
export declare const CONTENT_TABLES: readonly ["pages", "page_versions", "saved_sections", "global_sections", "entries", "refs"];
|
|
4
|
+
export type ContentTable = (typeof CONTENT_TABLES)[number];
|
|
5
|
+
export declare function currentSpaceSQL(): string;
|
|
6
|
+
export declare function spaceLayerSQL(): string;
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// The space layer of the stable schema (spec 2026-09-04 §2–§3). A space
|
|
2
|
+
// is one cloud environment; a self-host install is one space with the
|
|
3
|
+
// default id. Everything that needs "which space is this request in"
|
|
4
|
+
// reads smoodly_current_space() — the adapters never mention spaces.
|
|
5
|
+
export const DEFAULT_SPACE = `'00000000-0000-0000-0000-000000000000'`;
|
|
6
|
+
export const SPACE_COL = `"space_id" uuid not null default ${DEFAULT_SPACE}`;
|
|
7
|
+
export const CONTENT_TABLES = [
|
|
8
|
+
"pages",
|
|
9
|
+
"page_versions",
|
|
10
|
+
"saved_sections",
|
|
11
|
+
"global_sections",
|
|
12
|
+
"entries",
|
|
13
|
+
"refs",
|
|
14
|
+
];
|
|
15
|
+
export function currentSpaceSQL() {
|
|
16
|
+
return `
|
|
17
|
+
-- The space a request acts in: the key's claim in Smoodly Cloud, the
|
|
18
|
+
-- default space in self-host (service role, anon key). Policies, the
|
|
19
|
+
-- insert trigger and the reorder RPC all read this ONE function.
|
|
20
|
+
create or replace function "smoodly_current_space"() returns uuid
|
|
21
|
+
language sql stable as $$
|
|
22
|
+
select coalesce((auth.jwt() ->> 'space_id')::uuid, ${DEFAULT_SPACE});
|
|
23
|
+
$$;`.trim();
|
|
24
|
+
}
|
|
25
|
+
/** Which rows a read key (or the anon key, which has no claim) may see.
|
|
26
|
+
* null = write keys only — refs and the section library are editor
|
|
27
|
+
* tooling; editors is admission data; nothing a browser needs. The
|
|
28
|
+
* empty string means "read policy, no extra predicate" (global_sections
|
|
29
|
+
* is fully readable once it's in-space) — distinct from null. */
|
|
30
|
+
const PUBLISHED_READ = {
|
|
31
|
+
pages: `"status" = 'published'`,
|
|
32
|
+
entries: `"status" = 'published'`,
|
|
33
|
+
page_versions: `exists (select 1 from "pages" p where p."published_version_id" = "page_versions"."id")`,
|
|
34
|
+
global_sections: ``,
|
|
35
|
+
saved_sections: null,
|
|
36
|
+
refs: null,
|
|
37
|
+
editors: null,
|
|
38
|
+
};
|
|
39
|
+
/** Tables whose existing indexes do not lead with space_id. */
|
|
40
|
+
const NEEDS_SPACE_INDEX = ["page_versions", "saved_sections", "global_sections", "refs"];
|
|
41
|
+
export function spaceLayerSQL() {
|
|
42
|
+
const statements = [];
|
|
43
|
+
statements.push(`
|
|
44
|
+
-- ── Cloud state (spec 2026-09-04 §1): three ordinary tables, shipped
|
|
45
|
+
-- for everyone with ONE definition. Self-host fills them by hand or
|
|
46
|
+
-- leaves them empty; smoodly.io writes rows with the service role.
|
|
47
|
+
-- There is never cloud-only SQL on a content database.
|
|
48
|
+
create table if not exists "editors" (
|
|
49
|
+
${SPACE_COL},
|
|
50
|
+
"user_id" uuid not null,
|
|
51
|
+
"role" text not null default 'editor',
|
|
52
|
+
primary key ("space_id", "user_id")
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
create table if not exists "space_keys" (
|
|
56
|
+
"id" uuid primary key,
|
|
57
|
+
"space_id" uuid not null,
|
|
58
|
+
"kind" text not null check ("kind" in ('read', 'write')),
|
|
59
|
+
"revoked_at" timestamptz
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
create table if not exists "space_caps" (
|
|
63
|
+
"space_id" uuid primary key,
|
|
64
|
+
"row_cap" integer not null
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
alter table "editors" enable row level security;
|
|
68
|
+
alter table "space_keys" enable row level security;
|
|
69
|
+
alter table "space_caps" enable row level security;
|
|
70
|
+
|
|
71
|
+
-- ── Claim readers. The two lookups run as definer so they can read the
|
|
72
|
+
-- service-role-only tables from inside a policy; search_path is pinned.
|
|
73
|
+
create or replace function "smoodly_key_kind"() returns text
|
|
74
|
+
language sql stable as $$
|
|
75
|
+
select auth.jwt() ->> 'kind';
|
|
76
|
+
$$;
|
|
77
|
+
|
|
78
|
+
create or replace function "smoodly_space_key_revoked"() returns boolean
|
|
79
|
+
language sql stable security definer set search_path = public as $$
|
|
80
|
+
select coalesce(
|
|
81
|
+
(select k."revoked_at" is not null from "space_keys" k
|
|
82
|
+
where k."id" = (auth.jwt() ->> 'key_id')::uuid),
|
|
83
|
+
false);
|
|
84
|
+
$$;
|
|
85
|
+
|
|
86
|
+
create or replace function "smoodly_space_row_cap"() returns integer
|
|
87
|
+
language sql stable security definer set search_path = public as $$
|
|
88
|
+
select c."row_cap" from "space_caps" c where c."space_id" = "smoodly_current_space"();
|
|
89
|
+
$$;
|
|
90
|
+
|
|
91
|
+
-- ── Triggers. A key's claim wins over whatever the insert said, so an
|
|
92
|
+
-- adapter never has to know its space and cannot write into another.
|
|
93
|
+
-- No claim (service role, anon) leaves the given or default value —
|
|
94
|
+
-- that is how smoodly.io writes cloud-state rows for a chosen space.
|
|
95
|
+
create or replace function "smoodly_set_space"() returns trigger
|
|
96
|
+
language plpgsql as $$
|
|
97
|
+
begin
|
|
98
|
+
if auth.jwt() ->> 'space_id' is not null then
|
|
99
|
+
new."space_id" := (auth.jwt() ->> 'space_id')::uuid;
|
|
100
|
+
end if;
|
|
101
|
+
return new;
|
|
102
|
+
end;
|
|
103
|
+
$$;
|
|
104
|
+
|
|
105
|
+
-- Per table, per space. No space_caps row = no cap (self-host). Trigger
|
|
106
|
+
-- a_ (smoodly_set_space) runs first, so new.space_id already equals the
|
|
107
|
+
-- current space for a keyed insert; smoodly_space_row_cap() reads the
|
|
108
|
+
-- current space itself, so this never needs the row's space_id as an
|
|
109
|
+
-- argument (which would let one space's insert probe another's cap).
|
|
110
|
+
-- A service-role insert that names another space explicitly is a
|
|
111
|
+
-- different case: there is no JWT claim, so smoodly_current_space()
|
|
112
|
+
-- still resolves to the default space, and the cap read is the default
|
|
113
|
+
-- space's (none) — the service role is never capped by another space's row.
|
|
114
|
+
-- search_path is pinned empty, so every reference below is schema-qualified.
|
|
115
|
+
create or replace function "smoodly_enforce_row_cap"() returns trigger
|
|
116
|
+
language plpgsql set search_path = '' as $$
|
|
117
|
+
declare
|
|
118
|
+
cap integer;
|
|
119
|
+
n bigint;
|
|
120
|
+
begin
|
|
121
|
+
cap := public."smoodly_space_row_cap"();
|
|
122
|
+
if cap is null then
|
|
123
|
+
return new;
|
|
124
|
+
end if;
|
|
125
|
+
-- Advisory only (free-tier abuse guard), not a hard invariant: two
|
|
126
|
+
-- concurrent inserts can both observe n = cap - 1 and both pass.
|
|
127
|
+
execute format('select count(*) from %I.%I where "space_id" = $1', tg_table_schema, tg_table_name)
|
|
128
|
+
into n using new."space_id";
|
|
129
|
+
if n >= cap then
|
|
130
|
+
raise exception 'smoodly: row cap (%) reached for this space in %', cap, tg_table_name
|
|
131
|
+
using errcode = 'check_violation';
|
|
132
|
+
end if;
|
|
133
|
+
return new;
|
|
134
|
+
end;
|
|
135
|
+
$$;`.trim());
|
|
136
|
+
for (const table of NEEDS_SPACE_INDEX) {
|
|
137
|
+
statements.push(`create index if not exists "${table}_space_idx" on "${table}" ("space_id");`);
|
|
138
|
+
}
|
|
139
|
+
// Trigger names sort a_ before b_: same-event triggers fire alphabetically.
|
|
140
|
+
for (const table of [...CONTENT_TABLES, "editors"]) {
|
|
141
|
+
statements.push(`drop trigger if exists "smoodly_a_set_space" on "${table}";\n` +
|
|
142
|
+
`create trigger "smoodly_a_set_space" before insert on "${table}"\n` +
|
|
143
|
+
` for each row execute function "smoodly_set_space"();`);
|
|
144
|
+
}
|
|
145
|
+
for (const table of CONTENT_TABLES) {
|
|
146
|
+
statements.push(`drop trigger if exists "smoodly_b_row_cap" on "${table}";\n` +
|
|
147
|
+
`create trigger "smoodly_b_row_cap" before insert on "${table}"\n` +
|
|
148
|
+
` for each row execute function "smoodly_enforce_row_cap"();`);
|
|
149
|
+
}
|
|
150
|
+
// Policies (permissive, so read OR write admits). The service role
|
|
151
|
+
// bypasses them all — self-host adapters are untouched. Each function
|
|
152
|
+
// call sits in its own scalar subquery so the planner hoists it into
|
|
153
|
+
// an InitPlan, evaluated once per statement rather than once per row
|
|
154
|
+
// — needed for smoodly_space_key_revoked, a security definer function
|
|
155
|
+
// Postgres never inlines; applied to all three calls for the same win.
|
|
156
|
+
const own = `"space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"())`;
|
|
157
|
+
const write = `${own} and (select "smoodly_key_kind"()) = 'write'`;
|
|
158
|
+
for (const table of [...CONTENT_TABLES, "editors"]) {
|
|
159
|
+
statements.push(`drop policy if exists "smoodly_write" on "${table}";\n` +
|
|
160
|
+
`create policy "smoodly_write" on "${table}" for all\n` +
|
|
161
|
+
` using (${write})\n` +
|
|
162
|
+
` with check (${write});`);
|
|
163
|
+
const published = PUBLISHED_READ[table];
|
|
164
|
+
if (published !== null) {
|
|
165
|
+
const predicate = `${own}${published ? ` and ${published}` : ""}`;
|
|
166
|
+
statements.push(`drop policy if exists "smoodly_read" on "${table}";\n` +
|
|
167
|
+
`create policy "smoodly_read" on "${table}" for select\n` +
|
|
168
|
+
` using (${predicate});`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return statements.join("\n\n");
|
|
172
|
+
}
|
package/dist/sql.d.ts
ADDED
package/dist/sql.js
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// Schema → SQL generation (Postgres/Supabase) for the stable schema
|
|
2
|
+
// (DESIGN.md §3): all collection content lives in the one `entries`
|
|
3
|
+
// table, applied once and never migrated. Per collection we emit only
|
|
4
|
+
// disposable guardrails — a published-only read view, created with
|
|
5
|
+
// security_invoker so it runs under the caller's own RLS policies
|
|
6
|
+
// rather than the view owner's privileges (PostgREST-native reads,
|
|
7
|
+
// SQL-queryable eject story) — and partial expression indexes (slug
|
|
8
|
+
// uniqueness, declared orderBy fields). Views project the default
|
|
9
|
+
// locale; the `i18n` overlay is the resolver/API's business.
|
|
10
|
+
import { currentSpaceSQL, SPACE_COL, spaceLayerSQL } from "./sql-space.js";
|
|
11
|
+
const snake = (s) => s.replace(/[A-Z]/g, (c) => "_" + c.toLowerCase());
|
|
12
|
+
const q = (s) => `"${s}"`;
|
|
13
|
+
// Casts are fine in views (unlike index expressions, which must be
|
|
14
|
+
// immutable — orderBy indexes stay on the text value; ISO dates sort
|
|
15
|
+
// correctly as text).
|
|
16
|
+
function fieldExpr(key, d) {
|
|
17
|
+
switch (d.type) {
|
|
18
|
+
case "text":
|
|
19
|
+
case "link":
|
|
20
|
+
case "slug":
|
|
21
|
+
case "select":
|
|
22
|
+
case "ref": // stored as the target entry's id string
|
|
23
|
+
return `"fields"->>'${key}'`;
|
|
24
|
+
case "number":
|
|
25
|
+
return `("fields"->>'${key}')::numeric`;
|
|
26
|
+
case "date":
|
|
27
|
+
return `("fields"->>'${key}')::timestamptz`;
|
|
28
|
+
default:
|
|
29
|
+
return `"fields"->'${key}'`; // image, video, file, object, richtext, dataAttributes, refList
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export function collectionSQL(collection) {
|
|
33
|
+
const name = collection.name;
|
|
34
|
+
const columns = ['"id"', '"sort"', '"locales"', '"created_at"', '"updated_at"'];
|
|
35
|
+
for (const [key, d] of Object.entries(collection.fields)) {
|
|
36
|
+
columns.push(`${fieldExpr(key, d)} as ${q(snake(key))}`);
|
|
37
|
+
}
|
|
38
|
+
const statements = [
|
|
39
|
+
[
|
|
40
|
+
`create or replace view ${q(name)} with (security_invoker = true) as`,
|
|
41
|
+
`select`,
|
|
42
|
+
columns.map((c) => " " + c).join(",\n"),
|
|
43
|
+
`from "entries"`,
|
|
44
|
+
`where "collection" = '${name}' and "status" = 'published';`,
|
|
45
|
+
].join("\n"),
|
|
46
|
+
];
|
|
47
|
+
// slug uniqueness is the generic core index (space_id, collection, slug);
|
|
48
|
+
// only declared orderBy fields need per-collection expression indexes
|
|
49
|
+
for (const key of Object.keys(collection.admin?.orderBy ?? {})) {
|
|
50
|
+
statements.push(`create index if not exists ${q(`entries_${name}_${snake(key)}_idx`)}\n` +
|
|
51
|
+
` on "entries" (("fields"->>'${key}')) where "collection" = '${name}';`);
|
|
52
|
+
}
|
|
53
|
+
return statements.join("\n\n");
|
|
54
|
+
}
|
|
55
|
+
export function coreTablesSQL() {
|
|
56
|
+
return [
|
|
57
|
+
currentSpaceSQL(),
|
|
58
|
+
`
|
|
59
|
+
create table if not exists "pages" (
|
|
60
|
+
"id" uuid primary key default gen_random_uuid(),
|
|
61
|
+
${SPACE_COL},
|
|
62
|
+
"slug" text not null,
|
|
63
|
+
"template" text not null,
|
|
64
|
+
"status" text not null default 'draft',
|
|
65
|
+
"sort" numeric,
|
|
66
|
+
"locales" text[],
|
|
67
|
+
"draft_version_id" uuid,
|
|
68
|
+
"published_version_id" uuid,
|
|
69
|
+
"created_at" timestamptz not null default now(),
|
|
70
|
+
"updated_at" timestamptz not null default now(),
|
|
71
|
+
unique ("space_id", "slug")
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
create table if not exists "page_versions" (
|
|
75
|
+
"id" uuid primary key default gen_random_uuid(),
|
|
76
|
+
${SPACE_COL},
|
|
77
|
+
"page_id" uuid not null references "pages" ("id") on delete cascade,
|
|
78
|
+
"tree" jsonb not null,
|
|
79
|
+
"created_by" text,
|
|
80
|
+
"created_at" timestamptz not null default now()
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
create table if not exists "saved_sections" (
|
|
84
|
+
"id" uuid primary key default gen_random_uuid(),
|
|
85
|
+
${SPACE_COL},
|
|
86
|
+
"title" text not null,
|
|
87
|
+
"fragment" jsonb not null,
|
|
88
|
+
"created_at" timestamptz not null default now()
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
create table if not exists "global_sections" (
|
|
92
|
+
"id" uuid primary key default gen_random_uuid(),
|
|
93
|
+
${SPACE_COL},
|
|
94
|
+
"type" text not null,
|
|
95
|
+
"content" jsonb not null,
|
|
96
|
+
"updated_at" timestamptz not null default now()
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
create table if not exists "entries" (
|
|
100
|
+
"id" uuid primary key default gen_random_uuid(),
|
|
101
|
+
${SPACE_COL},
|
|
102
|
+
"collection" text not null,
|
|
103
|
+
"fields" jsonb not null default '{}'::jsonb,
|
|
104
|
+
"i18n" jsonb,
|
|
105
|
+
"status" text not null default 'draft',
|
|
106
|
+
"sort" numeric,
|
|
107
|
+
"locales" text[],
|
|
108
|
+
"created_at" timestamptz not null default now(),
|
|
109
|
+
"updated_at" timestamptz not null default now()
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
create unique index if not exists "entries_slug_key"
|
|
113
|
+
on "entries" ("space_id", "collection", ("fields"->>'slug'));
|
|
114
|
+
|
|
115
|
+
create index if not exists "entries_collection_status_idx"
|
|
116
|
+
on "entries" ("space_id", "collection", "status");
|
|
117
|
+
|
|
118
|
+
create index if not exists "entries_collection_sort_idx"
|
|
119
|
+
on "entries" ("space_id", "collection", "sort");
|
|
120
|
+
|
|
121
|
+
-- v1 reorder (DESIGN.md §3 Manual ordering): rewrite sort 1..N in ONE
|
|
122
|
+
-- statement. Deliberately does not touch updated_at — a drag must not
|
|
123
|
+
-- make the whole collection look freshly edited. Scoped to the current
|
|
124
|
+
-- space by the same function the policies use.
|
|
125
|
+
create or replace function "smoodly_reorder"(
|
|
126
|
+
p_collection text,
|
|
127
|
+
p_ids uuid[]
|
|
128
|
+
) returns void language sql as $$
|
|
129
|
+
update "entries" e set "sort" = u.ord
|
|
130
|
+
from unnest(p_ids) with ordinality as u(id, ord)
|
|
131
|
+
where e."id" = u.id
|
|
132
|
+
and e."collection" = p_collection
|
|
133
|
+
and e."space_id" = "smoodly_current_space"();
|
|
134
|
+
$$;
|
|
135
|
+
|
|
136
|
+
create table if not exists "refs" (
|
|
137
|
+
"source_kind" text not null,
|
|
138
|
+
"source_id" uuid not null,
|
|
139
|
+
${SPACE_COL},
|
|
140
|
+
"source_field" text not null,
|
|
141
|
+
"target_collection" text not null,
|
|
142
|
+
"target_id" uuid not null,
|
|
143
|
+
primary key ("source_kind", "source_id", "source_field", "target_id")
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
-- RLS on every core table. The service role bypasses it (self-host
|
|
147
|
+
-- adapters untouched). The policy set that follows (space layer) admits
|
|
148
|
+
-- a key to its own space: everything for a write key, published rows for
|
|
149
|
+
-- a read key or the anon key (default space). Drafts stay unreachable
|
|
150
|
+
-- to anything but a write key. The generated per-collection views
|
|
151
|
+
-- (collectionSQL) run with security_invoker = true, so they are subject
|
|
152
|
+
-- to this same smoodly_read policy rather than their owner's privileges.
|
|
153
|
+
alter table "pages" enable row level security;
|
|
154
|
+
alter table "page_versions" enable row level security;
|
|
155
|
+
alter table "saved_sections" enable row level security;
|
|
156
|
+
alter table "global_sections" enable row level security;
|
|
157
|
+
alter table "entries" enable row level security;
|
|
158
|
+
alter table "refs" enable row level security;
|
|
159
|
+
`.trim(),
|
|
160
|
+
spaceLayerSQL(),
|
|
161
|
+
].join("\n\n");
|
|
162
|
+
}
|