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
package/dist/store.d.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { PageTree } from "./render.tsx";
|
|
2
|
+
import type { EntryStore } from "./entry-store.ts";
|
|
3
|
+
import { type RefEdge, type SectionLike } from "./refs.ts";
|
|
4
|
+
import type { MemoryRefIndex } from "./ref-index.ts";
|
|
5
|
+
export type PageRecord = {
|
|
6
|
+
id: string;
|
|
7
|
+
slug: string;
|
|
8
|
+
template: string;
|
|
9
|
+
status: "draft" | "published";
|
|
10
|
+
draftVersionId: string;
|
|
11
|
+
publishedVersionId: string | null;
|
|
12
|
+
};
|
|
13
|
+
export type PageVersion = {
|
|
14
|
+
id: string;
|
|
15
|
+
pageId: string;
|
|
16
|
+
tree: PageTree;
|
|
17
|
+
createdAt: number;
|
|
18
|
+
};
|
|
19
|
+
export interface PageStore {
|
|
20
|
+
createPage(input: {
|
|
21
|
+
slug: string;
|
|
22
|
+
template: string;
|
|
23
|
+
}): Promise<PageRecord>;
|
|
24
|
+
getPage(slug: string): Promise<PageRecord | null>;
|
|
25
|
+
/** By-id lookup — the fan-out walk holds page ids, not slugs. */
|
|
26
|
+
getPageById(id: string): Promise<PageRecord | null>;
|
|
27
|
+
saveDraft(slug: string, tree: PageTree): Promise<PageVersion>;
|
|
28
|
+
publish(slug: string): Promise<PageRecord>;
|
|
29
|
+
getDraftTree(slug: string): Promise<PageTree | null>;
|
|
30
|
+
getPublishedTree(slug: string): Promise<PageTree | null>;
|
|
31
|
+
listVersions(slug: string): Promise<PageVersion[]>;
|
|
32
|
+
/** All pages, slug-ascending — the admin's pages list. */
|
|
33
|
+
listPages(): Promise<PageRecord[]>;
|
|
34
|
+
/** Removes the page, its versions and its refs rows. Missing slug: no-op. */
|
|
35
|
+
deletePage(slug: string): Promise<void>;
|
|
36
|
+
}
|
|
37
|
+
/** What a PageStore needs beyond its tables to maintain the refs index
|
|
38
|
+
* and gate publishes. All optional: without `sections` no refs are
|
|
39
|
+
* written; without `entries` publishing is not integrity-checked. */
|
|
40
|
+
export type PageStoreOptions = {
|
|
41
|
+
sections?: SectionLike[];
|
|
42
|
+
entries?: EntryStore;
|
|
43
|
+
};
|
|
44
|
+
/** A page's refs rows cover BOTH live trees — edges(draft) ∪
|
|
45
|
+
* edges(published) — so an entry stays delete-guarded while either tree
|
|
46
|
+
* shows it. Callers dedupe on write (the SQL PK does it for real). */
|
|
47
|
+
export declare function pageRefEdges(draft: PageTree, published: PageTree | null, sections: SectionLike[]): RefEdge[];
|
|
48
|
+
/** The publish-integrity gate (DESIGN.md §3): a page must not go live
|
|
49
|
+
* showing entries the published site can't resolve — every referenced
|
|
50
|
+
* entry must exist and be published. Throws naming the offenders. */
|
|
51
|
+
export declare function assertPublishable(slug: string, tree: PageTree, sections: SectionLike[], entries: EntryStore): Promise<void>;
|
|
52
|
+
export declare class MemoryPageStore implements PageStore {
|
|
53
|
+
private options;
|
|
54
|
+
private pages;
|
|
55
|
+
private versions;
|
|
56
|
+
constructor(options?: PageStoreOptions & {
|
|
57
|
+
refs?: MemoryRefIndex;
|
|
58
|
+
});
|
|
59
|
+
private writeRefs;
|
|
60
|
+
createPage({ slug, template }: {
|
|
61
|
+
slug: string;
|
|
62
|
+
template: string;
|
|
63
|
+
}): Promise<PageRecord>;
|
|
64
|
+
getPage(slug: string): Promise<PageRecord | null>;
|
|
65
|
+
getPageById(id: string): Promise<PageRecord | null>;
|
|
66
|
+
saveDraft(slug: string, tree: PageTree): Promise<PageVersion>;
|
|
67
|
+
publish(slug: string): Promise<PageRecord>;
|
|
68
|
+
getDraftTree(slug: string): Promise<PageTree | null>;
|
|
69
|
+
getPublishedTree(slug: string): Promise<PageTree | null>;
|
|
70
|
+
listVersions(slug: string): Promise<PageVersion[]>;
|
|
71
|
+
listPages(): Promise<PageRecord[]>;
|
|
72
|
+
deletePage(slug: string): Promise<void>;
|
|
73
|
+
}
|
package/dist/store.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// The page persistence contract. Writes always go through this API —
|
|
2
|
+
// never raw table access — so validation, versioning and publish hooks
|
|
3
|
+
// can't be bypassed (DESIGN.md §1). MemoryPageStore is the reference
|
|
4
|
+
// implementation and the contract-test subject; the Supabase adapter
|
|
5
|
+
// implements the same interface over pages/page_versions.
|
|
6
|
+
import { collectTreeRefs } from "./refs.js";
|
|
7
|
+
/** A page's refs rows cover BOTH live trees — edges(draft) ∪
|
|
8
|
+
* edges(published) — so an entry stays delete-guarded while either tree
|
|
9
|
+
* shows it. Callers dedupe on write (the SQL PK does it for real). */
|
|
10
|
+
export function pageRefEdges(draft, published, sections) {
|
|
11
|
+
const edges = collectTreeRefs(draft, sections);
|
|
12
|
+
if (published)
|
|
13
|
+
edges.push(...collectTreeRefs(published, sections));
|
|
14
|
+
return edges;
|
|
15
|
+
}
|
|
16
|
+
/** The publish-integrity gate (DESIGN.md §3): a page must not go live
|
|
17
|
+
* showing entries the published site can't resolve — every referenced
|
|
18
|
+
* entry must exist and be published. Throws naming the offenders. */
|
|
19
|
+
export async function assertPublishable(slug, tree, sections, entries) {
|
|
20
|
+
const targets = new Map();
|
|
21
|
+
for (const edge of collectTreeRefs(tree, sections)) {
|
|
22
|
+
if (!targets.has(edge.targetId))
|
|
23
|
+
targets.set(edge.targetId, { collection: edge.targetCollection, field: edge.field });
|
|
24
|
+
}
|
|
25
|
+
const offenders = [];
|
|
26
|
+
for (const [id, { collection, field }] of targets) {
|
|
27
|
+
const entry = await entries.get(collection, id);
|
|
28
|
+
if (entry?.status !== "published")
|
|
29
|
+
offenders.push(`${collection}/${id} (${field}${entry ? "" : ", missing"})`);
|
|
30
|
+
}
|
|
31
|
+
if (offenders.length > 0) {
|
|
32
|
+
throw new Error(`smoodly: cannot publish "${slug}" — it references entries that are not published: ${offenders.join(", ")}.`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
let counter = 0;
|
|
36
|
+
const uid = () => `id-${++counter}-${Date.now().toString(36)}`;
|
|
37
|
+
export class MemoryPageStore {
|
|
38
|
+
constructor(options = {}) {
|
|
39
|
+
this.options = options;
|
|
40
|
+
this.pages = new Map();
|
|
41
|
+
this.versions = new Map();
|
|
42
|
+
}
|
|
43
|
+
writeRefs(pageId, draft, published) {
|
|
44
|
+
const { sections, refs } = this.options;
|
|
45
|
+
if (!sections || !refs)
|
|
46
|
+
return;
|
|
47
|
+
refs.rewrite({ sourceKind: "page", sourceId: pageId }, pageRefEdges(draft, published, sections));
|
|
48
|
+
}
|
|
49
|
+
async createPage({ slug, template }) {
|
|
50
|
+
if (this.pages.has(slug))
|
|
51
|
+
throw new Error(`smoodly: a page with slug "${slug}" already exists.`);
|
|
52
|
+
const pageId = uid();
|
|
53
|
+
const version = { id: uid(), pageId, tree: { zones: {} }, createdAt: Date.now() };
|
|
54
|
+
this.versions.set(version.id, version);
|
|
55
|
+
const page = {
|
|
56
|
+
id: pageId,
|
|
57
|
+
slug,
|
|
58
|
+
template,
|
|
59
|
+
status: "draft",
|
|
60
|
+
draftVersionId: version.id,
|
|
61
|
+
publishedVersionId: null,
|
|
62
|
+
};
|
|
63
|
+
this.pages.set(slug, page);
|
|
64
|
+
return { ...page };
|
|
65
|
+
}
|
|
66
|
+
async getPage(slug) {
|
|
67
|
+
const page = this.pages.get(slug);
|
|
68
|
+
return page ? { ...page } : null;
|
|
69
|
+
}
|
|
70
|
+
async getPageById(id) {
|
|
71
|
+
for (const page of this.pages.values())
|
|
72
|
+
if (page.id === id)
|
|
73
|
+
return { ...page };
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
async saveDraft(slug, tree) {
|
|
77
|
+
const page = this.pages.get(slug);
|
|
78
|
+
if (!page)
|
|
79
|
+
throw new Error(`smoodly: no page with slug "${slug}".`);
|
|
80
|
+
const version = {
|
|
81
|
+
id: uid(),
|
|
82
|
+
pageId: page.id,
|
|
83
|
+
tree: structuredClone(tree),
|
|
84
|
+
createdAt: Date.now(),
|
|
85
|
+
};
|
|
86
|
+
this.versions.set(version.id, version);
|
|
87
|
+
page.draftVersionId = version.id;
|
|
88
|
+
const published = page.publishedVersionId
|
|
89
|
+
? (this.versions.get(page.publishedVersionId)?.tree ?? null)
|
|
90
|
+
: null;
|
|
91
|
+
this.writeRefs(page.id, version.tree, published);
|
|
92
|
+
return { ...version };
|
|
93
|
+
}
|
|
94
|
+
async publish(slug) {
|
|
95
|
+
const page = this.pages.get(slug);
|
|
96
|
+
if (!page)
|
|
97
|
+
throw new Error(`smoodly: no page with slug "${slug}".`);
|
|
98
|
+
const { sections, entries } = this.options;
|
|
99
|
+
if (sections && entries) {
|
|
100
|
+
const draft = this.versions.get(page.draftVersionId)?.tree ?? { zones: {} };
|
|
101
|
+
await assertPublishable(slug, draft, sections, entries);
|
|
102
|
+
}
|
|
103
|
+
page.publishedVersionId = page.draftVersionId;
|
|
104
|
+
page.status = "published";
|
|
105
|
+
const tree = this.versions.get(page.draftVersionId)?.tree ?? { zones: {} };
|
|
106
|
+
this.writeRefs(page.id, tree, null); // draft IS published now — one tree
|
|
107
|
+
return { ...page };
|
|
108
|
+
}
|
|
109
|
+
async getDraftTree(slug) {
|
|
110
|
+
const page = this.pages.get(slug);
|
|
111
|
+
if (!page)
|
|
112
|
+
return null;
|
|
113
|
+
return structuredClone(this.versions.get(page.draftVersionId)?.tree ?? null);
|
|
114
|
+
}
|
|
115
|
+
async getPublishedTree(slug) {
|
|
116
|
+
const page = this.pages.get(slug);
|
|
117
|
+
if (!page?.publishedVersionId)
|
|
118
|
+
return null;
|
|
119
|
+
return structuredClone(this.versions.get(page.publishedVersionId)?.tree ?? null);
|
|
120
|
+
}
|
|
121
|
+
async listVersions(slug) {
|
|
122
|
+
const page = this.pages.get(slug);
|
|
123
|
+
if (!page)
|
|
124
|
+
return [];
|
|
125
|
+
return [...this.versions.values()]
|
|
126
|
+
.filter((v) => v.pageId === page.id)
|
|
127
|
+
.sort((a, b) => b.createdAt - a.createdAt || b.id.localeCompare(a.id))
|
|
128
|
+
.map((v) => ({ ...v, tree: structuredClone(v.tree) }));
|
|
129
|
+
}
|
|
130
|
+
async listPages() {
|
|
131
|
+
return [...this.pages.values()]
|
|
132
|
+
.sort((a, b) => a.slug.localeCompare(b.slug))
|
|
133
|
+
.map((p) => ({ ...p }));
|
|
134
|
+
}
|
|
135
|
+
async deletePage(slug) {
|
|
136
|
+
const page = this.pages.get(slug);
|
|
137
|
+
if (!page)
|
|
138
|
+
return;
|
|
139
|
+
for (const [id, v] of this.versions)
|
|
140
|
+
if (v.pageId === page.id)
|
|
141
|
+
this.versions.delete(id);
|
|
142
|
+
this.options.refs?.remove({ sourceKind: "page", sourceId: page.id });
|
|
143
|
+
this.pages.delete(slug);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { SupabaseClient } from "@supabase/supabase-js";
|
|
2
|
+
import type { CollectionSchema } from "./collections.ts";
|
|
3
|
+
import type { EntryRecord, EntryStore, EntryUsage, ListOptions } from "./entry-store.ts";
|
|
4
|
+
export declare class SupabaseEntryStore implements EntryStore {
|
|
5
|
+
private db;
|
|
6
|
+
private collections;
|
|
7
|
+
constructor(db: SupabaseClient, collections: CollectionSchema<any>[]);
|
|
8
|
+
private fail;
|
|
9
|
+
private writeRefs;
|
|
10
|
+
create(collection: string, fields: Record<string, unknown>): Promise<EntryRecord>;
|
|
11
|
+
get(collection: string, id: string): Promise<EntryRecord | null>;
|
|
12
|
+
list(collection: string, options?: ListOptions): Promise<EntryRecord[]>;
|
|
13
|
+
getMany(collection: string, ids: string[], options?: {
|
|
14
|
+
status?: "published";
|
|
15
|
+
}): Promise<Record<string, Record<string, unknown>>>;
|
|
16
|
+
private patch;
|
|
17
|
+
update(collection: string, id: string, fields: Record<string, unknown>): Promise<EntryRecord>;
|
|
18
|
+
setStatus(collection: string, id: string, status: "draft" | "published"): Promise<EntryRecord>;
|
|
19
|
+
delete(collection: string, id: string): Promise<void>;
|
|
20
|
+
reorder(collection: string, orderedIds: string[]): Promise<void>;
|
|
21
|
+
incoming(targetId: string): Promise<{
|
|
22
|
+
sourceKind: string;
|
|
23
|
+
sourceId: string;
|
|
24
|
+
}[]>;
|
|
25
|
+
referencesTo(collection: string, id: string): Promise<EntryUsage[]>;
|
|
26
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// The Supabase adapter for the EntryStore contract, over entries + refs.
|
|
2
|
+
// Service-role client, server-side only (writes bypass RLS by design —
|
|
3
|
+
// DESIGN.md §1). Reorder calls the smoodly_reorder SQL function: one
|
|
4
|
+
// statement for the whole collection, updated_at untouched.
|
|
5
|
+
import { schemaOf } from "./entry-store.js";
|
|
6
|
+
import { collectEntryRefs } from "./refs.js";
|
|
7
|
+
// entries.id is a Postgres uuid column: filtering on a non-UUID string
|
|
8
|
+
// throws invalid_text_representation. A non-UUID id can't exist in the
|
|
9
|
+
// table, so treat it the same as a missing one — getMany silently omits
|
|
10
|
+
// it, patch raises the contract's "no entry" error — rather than letting
|
|
11
|
+
// the cast error leak through.
|
|
12
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
13
|
+
function toRecord(row) {
|
|
14
|
+
return {
|
|
15
|
+
id: row.id,
|
|
16
|
+
collection: row.collection,
|
|
17
|
+
fields: row.fields,
|
|
18
|
+
status: row.status,
|
|
19
|
+
sort: row.sort,
|
|
20
|
+
createdAt: Date.parse(row.created_at),
|
|
21
|
+
updatedAt: Date.parse(row.updated_at),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export class SupabaseEntryStore {
|
|
25
|
+
constructor(db, collections) {
|
|
26
|
+
this.db = db;
|
|
27
|
+
this.collections = collections;
|
|
28
|
+
}
|
|
29
|
+
fail(message) {
|
|
30
|
+
throw new Error(`smoodly: ${message}`);
|
|
31
|
+
}
|
|
32
|
+
async writeRefs(collection, id, fields) {
|
|
33
|
+
const schema = schemaOf(this.collections, collection);
|
|
34
|
+
const deleted = await this.db
|
|
35
|
+
.from("refs")
|
|
36
|
+
.delete()
|
|
37
|
+
.eq("source_kind", "entry")
|
|
38
|
+
.eq("source_id", id);
|
|
39
|
+
if (deleted.error)
|
|
40
|
+
this.fail(deleted.error.message);
|
|
41
|
+
// dedupe: the refs PK is (source_kind, source_id, source_field, target_id)
|
|
42
|
+
const rows = new Map();
|
|
43
|
+
for (const edge of collectEntryRefs(fields, schema)) {
|
|
44
|
+
rows.set(`${edge.field}\x00${edge.targetId}`, {
|
|
45
|
+
source_kind: "entry",
|
|
46
|
+
source_id: id,
|
|
47
|
+
source_field: edge.field,
|
|
48
|
+
target_collection: edge.targetCollection,
|
|
49
|
+
target_id: edge.targetId,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
if (rows.size > 0) {
|
|
53
|
+
const inserted = await this.db.from("refs").insert([...rows.values()]);
|
|
54
|
+
if (inserted.error)
|
|
55
|
+
this.fail(inserted.error.message);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
async create(collection, fields) {
|
|
59
|
+
schemaOf(this.collections, collection);
|
|
60
|
+
const res = await this.db.from("entries").insert({ collection, fields }).select("*").single();
|
|
61
|
+
if (res.error)
|
|
62
|
+
this.fail(res.error.message);
|
|
63
|
+
const row = res.data;
|
|
64
|
+
await this.writeRefs(collection, row.id, fields);
|
|
65
|
+
return toRecord(row);
|
|
66
|
+
}
|
|
67
|
+
async get(collection, id) {
|
|
68
|
+
const res = await this.db
|
|
69
|
+
.from("entries")
|
|
70
|
+
.select("*")
|
|
71
|
+
.eq("collection", collection)
|
|
72
|
+
.eq("id", id)
|
|
73
|
+
.maybeSingle();
|
|
74
|
+
if (res.error)
|
|
75
|
+
this.fail(res.error.message);
|
|
76
|
+
return res.data ? toRecord(res.data) : null;
|
|
77
|
+
}
|
|
78
|
+
async list(collection, options) {
|
|
79
|
+
schemaOf(this.collections, collection);
|
|
80
|
+
let query = this.db.from("entries").select("*").eq("collection", collection);
|
|
81
|
+
if (options?.status)
|
|
82
|
+
query = query.eq("status", options.status);
|
|
83
|
+
const order = options?.order ?? "created";
|
|
84
|
+
if (order === "updated")
|
|
85
|
+
query = query.order("updated_at", { ascending: false });
|
|
86
|
+
else if (order === "manual")
|
|
87
|
+
query = query
|
|
88
|
+
.order("sort", { ascending: true, nullsFirst: false })
|
|
89
|
+
.order("created_at", { ascending: true });
|
|
90
|
+
else
|
|
91
|
+
query = query.order("created_at", { ascending: true }).order("id", { ascending: true });
|
|
92
|
+
const res = await query;
|
|
93
|
+
if (res.error)
|
|
94
|
+
this.fail(res.error.message);
|
|
95
|
+
return res.data.map(toRecord);
|
|
96
|
+
}
|
|
97
|
+
async getMany(collection, ids, options) {
|
|
98
|
+
schemaOf(this.collections, collection);
|
|
99
|
+
const uuidIds = ids.filter((id) => UUID_RE.test(id));
|
|
100
|
+
if (uuidIds.length === 0)
|
|
101
|
+
return {};
|
|
102
|
+
let query = this.db
|
|
103
|
+
.from("entries")
|
|
104
|
+
.select("id, fields")
|
|
105
|
+
.eq("collection", collection)
|
|
106
|
+
.in("id", uuidIds);
|
|
107
|
+
if (options?.status)
|
|
108
|
+
query = query.eq("status", options.status);
|
|
109
|
+
const res = await query;
|
|
110
|
+
if (res.error)
|
|
111
|
+
this.fail(res.error.message);
|
|
112
|
+
const out = {};
|
|
113
|
+
for (const row of res.data) {
|
|
114
|
+
out[row.id] = row.fields;
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
async patch(collection, id, patch) {
|
|
119
|
+
if (!UUID_RE.test(id))
|
|
120
|
+
this.fail(`no entry "${id}" in "${collection}".`);
|
|
121
|
+
const res = await this.db
|
|
122
|
+
.from("entries")
|
|
123
|
+
.update({ ...patch, updated_at: new Date().toISOString() })
|
|
124
|
+
.eq("collection", collection)
|
|
125
|
+
.eq("id", id)
|
|
126
|
+
.select("*")
|
|
127
|
+
.maybeSingle();
|
|
128
|
+
if (res.error)
|
|
129
|
+
this.fail(res.error.message);
|
|
130
|
+
if (!res.data)
|
|
131
|
+
this.fail(`no entry "${id}" in "${collection}".`);
|
|
132
|
+
return toRecord(res.data);
|
|
133
|
+
}
|
|
134
|
+
async update(collection, id, fields) {
|
|
135
|
+
const record = await this.patch(collection, id, { fields });
|
|
136
|
+
await this.writeRefs(collection, id, fields);
|
|
137
|
+
return record;
|
|
138
|
+
}
|
|
139
|
+
async setStatus(collection, id, status) {
|
|
140
|
+
return this.patch(collection, id, { status });
|
|
141
|
+
}
|
|
142
|
+
async delete(collection, id) {
|
|
143
|
+
const usage = await this.referencesTo(collection, id);
|
|
144
|
+
if (usage.length > 0) {
|
|
145
|
+
this.fail(`"${collection}/${id}" is referenced by ${usage.length} document(s) — remove those references first.`);
|
|
146
|
+
}
|
|
147
|
+
const refs = await this.db.from("refs").delete().eq("source_kind", "entry").eq("source_id", id);
|
|
148
|
+
if (refs.error)
|
|
149
|
+
this.fail(refs.error.message);
|
|
150
|
+
const res = await this.db.from("entries").delete().eq("collection", collection).eq("id", id);
|
|
151
|
+
if (res.error)
|
|
152
|
+
this.fail(res.error.message);
|
|
153
|
+
}
|
|
154
|
+
async reorder(collection, orderedIds) {
|
|
155
|
+
schemaOf(this.collections, collection);
|
|
156
|
+
const res = await this.db.rpc("smoodly_reorder", {
|
|
157
|
+
p_collection: collection,
|
|
158
|
+
p_ids: orderedIds,
|
|
159
|
+
});
|
|
160
|
+
if (res.error)
|
|
161
|
+
this.fail(res.error.message);
|
|
162
|
+
}
|
|
163
|
+
async incoming(targetId) {
|
|
164
|
+
const res = await this.db
|
|
165
|
+
.from("refs")
|
|
166
|
+
.select("source_kind, source_id")
|
|
167
|
+
.eq("target_id", targetId);
|
|
168
|
+
if (res.error)
|
|
169
|
+
this.fail(res.error.message);
|
|
170
|
+
const seen = new Set(); // one row per source (multiple fields collapse)
|
|
171
|
+
const out = [];
|
|
172
|
+
for (const r of res.data) {
|
|
173
|
+
const key = `${r.source_kind} ${r.source_id}`;
|
|
174
|
+
if (seen.has(key))
|
|
175
|
+
continue;
|
|
176
|
+
seen.add(key);
|
|
177
|
+
out.push({ sourceKind: r.source_kind, sourceId: r.source_id });
|
|
178
|
+
}
|
|
179
|
+
return out;
|
|
180
|
+
}
|
|
181
|
+
async referencesTo(collection, id) {
|
|
182
|
+
const res = await this.db
|
|
183
|
+
.from("refs")
|
|
184
|
+
.select("source_kind, source_id, source_field")
|
|
185
|
+
.eq("target_collection", collection)
|
|
186
|
+
.eq("target_id", id);
|
|
187
|
+
if (res.error)
|
|
188
|
+
this.fail(res.error.message);
|
|
189
|
+
return res.data.map((r) => ({ sourceKind: r.source_kind, sourceId: r.source_id, sourceField: r.source_field }));
|
|
190
|
+
}
|
|
191
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { SupabaseClient } from "@supabase/supabase-js";
|
|
2
|
+
import { type PageRecord, type PageStore, type PageStoreOptions, type PageVersion } from "./store.ts";
|
|
3
|
+
import type { PageTree } from "./render.tsx";
|
|
4
|
+
export declare class SupabasePageStore implements PageStore {
|
|
5
|
+
private db;
|
|
6
|
+
private options;
|
|
7
|
+
constructor(db: SupabaseClient, options?: PageStoreOptions);
|
|
8
|
+
private writeRefs;
|
|
9
|
+
private pageRow;
|
|
10
|
+
private insertVersion;
|
|
11
|
+
private setPointers;
|
|
12
|
+
createPage({ slug, template }: {
|
|
13
|
+
slug: string;
|
|
14
|
+
template: string;
|
|
15
|
+
}): Promise<PageRecord>;
|
|
16
|
+
getPage(slug: string): Promise<PageRecord | null>;
|
|
17
|
+
getPageById(id: string): Promise<PageRecord | null>;
|
|
18
|
+
saveDraft(slug: string, tree: PageTree): Promise<PageVersion>;
|
|
19
|
+
publish(slug: string): Promise<PageRecord>;
|
|
20
|
+
private versionTree;
|
|
21
|
+
getDraftTree(slug: string): Promise<PageTree | null>;
|
|
22
|
+
getPublishedTree(slug: string): Promise<PageTree | null>;
|
|
23
|
+
listVersions(slug: string): Promise<PageVersion[]>;
|
|
24
|
+
listPages(): Promise<PageRecord[]>;
|
|
25
|
+
deletePage(slug: string): Promise<void>;
|
|
26
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// The Supabase adapter for the PageStore contract, over pages +
|
|
2
|
+
// page_versions. Server-side only, service-role client — writes bypass
|
|
3
|
+
// RLS by design because they flow through Smoodly's API (DESIGN.md §1).
|
|
4
|
+
// No FK links pages' version pointers to page_versions (they'd be
|
|
5
|
+
// circular), so tree reads are two indexed lookups, not embeds.
|
|
6
|
+
import { assertPublishable, pageRefEdges } from "./store.js";
|
|
7
|
+
const UNIQUE_VIOLATION = "23505";
|
|
8
|
+
function toRecord(row) {
|
|
9
|
+
return {
|
|
10
|
+
id: row.id,
|
|
11
|
+
slug: row.slug,
|
|
12
|
+
template: row.template,
|
|
13
|
+
status: row.status,
|
|
14
|
+
draftVersionId: row.draft_version_id,
|
|
15
|
+
publishedVersionId: row.published_version_id,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
function toVersion(row) {
|
|
19
|
+
return { id: row.id, pageId: row.page_id, tree: row.tree, createdAt: Date.parse(row.created_at) };
|
|
20
|
+
}
|
|
21
|
+
export class SupabasePageStore {
|
|
22
|
+
constructor(db, options = {}) {
|
|
23
|
+
this.db = db;
|
|
24
|
+
this.options = options;
|
|
25
|
+
}
|
|
26
|
+
async writeRefs(pageId, edges) {
|
|
27
|
+
const deleted = await this.db
|
|
28
|
+
.from("refs")
|
|
29
|
+
.delete()
|
|
30
|
+
.eq("source_kind", "page")
|
|
31
|
+
.eq("source_id", pageId);
|
|
32
|
+
if (deleted.error)
|
|
33
|
+
throw new Error(`smoodly: ${deleted.error.message}`);
|
|
34
|
+
// dedupe: the refs PK is (source_kind, source_id, source_field, target_id)
|
|
35
|
+
const rows = new Map();
|
|
36
|
+
for (const edge of edges) {
|
|
37
|
+
rows.set(`${edge.field} ${edge.targetId}`, {
|
|
38
|
+
source_kind: "page",
|
|
39
|
+
source_id: pageId,
|
|
40
|
+
source_field: edge.field,
|
|
41
|
+
target_collection: edge.targetCollection,
|
|
42
|
+
target_id: edge.targetId,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
if (rows.size > 0) {
|
|
46
|
+
const inserted = await this.db.from("refs").insert([...rows.values()]);
|
|
47
|
+
if (inserted.error)
|
|
48
|
+
throw new Error(`smoodly: ${inserted.error.message}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
async pageRow(slug) {
|
|
52
|
+
const res = await this.db.from("pages").select("*").eq("slug", slug).maybeSingle();
|
|
53
|
+
if (res.error)
|
|
54
|
+
throw new Error(`smoodly: ${res.error.message}`);
|
|
55
|
+
return res.data ?? null;
|
|
56
|
+
}
|
|
57
|
+
async insertVersion(pageId, tree) {
|
|
58
|
+
const res = await this.db
|
|
59
|
+
.from("page_versions")
|
|
60
|
+
.insert({ page_id: pageId, tree })
|
|
61
|
+
.select("*")
|
|
62
|
+
.single();
|
|
63
|
+
if (res.error)
|
|
64
|
+
throw new Error(`smoodly: ${res.error.message}`);
|
|
65
|
+
return res.data;
|
|
66
|
+
}
|
|
67
|
+
async setPointers(id, patch) {
|
|
68
|
+
const res = await this.db
|
|
69
|
+
.from("pages")
|
|
70
|
+
.update({ ...patch, updated_at: new Date().toISOString() })
|
|
71
|
+
.eq("id", id)
|
|
72
|
+
.select("*")
|
|
73
|
+
.single();
|
|
74
|
+
if (res.error)
|
|
75
|
+
throw new Error(`smoodly: ${res.error.message}`);
|
|
76
|
+
return res.data;
|
|
77
|
+
}
|
|
78
|
+
async createPage({ slug, template }) {
|
|
79
|
+
const inserted = await this.db.from("pages").insert({ slug, template }).select("*").single();
|
|
80
|
+
if (inserted.error) {
|
|
81
|
+
if (inserted.error.code === UNIQUE_VIOLATION)
|
|
82
|
+
throw new Error(`smoodly: a page with slug "${slug}" already exists.`);
|
|
83
|
+
throw new Error(`smoodly: ${inserted.error.message}`);
|
|
84
|
+
}
|
|
85
|
+
const page = inserted.data;
|
|
86
|
+
const version = await this.insertVersion(page.id, { zones: {} });
|
|
87
|
+
return toRecord(await this.setPointers(page.id, { draft_version_id: version.id }));
|
|
88
|
+
}
|
|
89
|
+
async getPage(slug) {
|
|
90
|
+
const row = await this.pageRow(slug);
|
|
91
|
+
return row ? toRecord(row) : null;
|
|
92
|
+
}
|
|
93
|
+
async getPageById(id) {
|
|
94
|
+
const res = await this.db.from("pages").select("*").eq("id", id).maybeSingle();
|
|
95
|
+
if (res.error)
|
|
96
|
+
throw new Error(`smoodly: ${res.error.message}`);
|
|
97
|
+
return res.data ? toRecord(res.data) : null;
|
|
98
|
+
}
|
|
99
|
+
async saveDraft(slug, tree) {
|
|
100
|
+
const page = await this.pageRow(slug);
|
|
101
|
+
if (!page)
|
|
102
|
+
throw new Error(`smoodly: no page with slug "${slug}".`);
|
|
103
|
+
const version = await this.insertVersion(page.id, tree);
|
|
104
|
+
await this.setPointers(page.id, { draft_version_id: version.id });
|
|
105
|
+
if (this.options.sections) {
|
|
106
|
+
const published = await this.versionTree(page.published_version_id);
|
|
107
|
+
await this.writeRefs(page.id, pageRefEdges(tree, published, this.options.sections));
|
|
108
|
+
}
|
|
109
|
+
return toVersion(version);
|
|
110
|
+
}
|
|
111
|
+
async publish(slug) {
|
|
112
|
+
const page = await this.pageRow(slug);
|
|
113
|
+
if (!page)
|
|
114
|
+
throw new Error(`smoodly: no page with slug "${slug}".`);
|
|
115
|
+
const { sections, entries } = this.options;
|
|
116
|
+
const draft = (await this.versionTree(page.draft_version_id)) ?? { zones: {} };
|
|
117
|
+
if (sections && entries)
|
|
118
|
+
await assertPublishable(slug, draft, sections, entries);
|
|
119
|
+
const record = toRecord(await this.setPointers(page.id, {
|
|
120
|
+
published_version_id: page.draft_version_id,
|
|
121
|
+
status: "published",
|
|
122
|
+
}));
|
|
123
|
+
// draft IS published now — one tree
|
|
124
|
+
if (sections)
|
|
125
|
+
await this.writeRefs(page.id, pageRefEdges(draft, null, sections));
|
|
126
|
+
return record;
|
|
127
|
+
}
|
|
128
|
+
async versionTree(versionId) {
|
|
129
|
+
if (!versionId)
|
|
130
|
+
return null;
|
|
131
|
+
const res = await this.db.from("page_versions").select("tree").eq("id", versionId).maybeSingle();
|
|
132
|
+
if (res.error)
|
|
133
|
+
throw new Error(`smoodly: ${res.error.message}`);
|
|
134
|
+
return res.data?.tree ?? null;
|
|
135
|
+
}
|
|
136
|
+
async getDraftTree(slug) {
|
|
137
|
+
const page = await this.pageRow(slug);
|
|
138
|
+
return page ? this.versionTree(page.draft_version_id) : null;
|
|
139
|
+
}
|
|
140
|
+
async getPublishedTree(slug) {
|
|
141
|
+
const page = await this.pageRow(slug);
|
|
142
|
+
return page ? this.versionTree(page.published_version_id) : null;
|
|
143
|
+
}
|
|
144
|
+
async listVersions(slug) {
|
|
145
|
+
const page = await this.pageRow(slug);
|
|
146
|
+
if (!page)
|
|
147
|
+
return [];
|
|
148
|
+
const res = await this.db
|
|
149
|
+
.from("page_versions")
|
|
150
|
+
.select("*")
|
|
151
|
+
.eq("page_id", page.id)
|
|
152
|
+
.order("created_at", { ascending: false })
|
|
153
|
+
.order("id", { ascending: false });
|
|
154
|
+
if (res.error)
|
|
155
|
+
throw new Error(`smoodly: ${res.error.message}`);
|
|
156
|
+
return res.data.map(toVersion);
|
|
157
|
+
}
|
|
158
|
+
async listPages() {
|
|
159
|
+
const res = await this.db.from("pages").select("*").order("slug", { ascending: true });
|
|
160
|
+
if (res.error)
|
|
161
|
+
throw new Error(`smoodly: ${res.error.message}`);
|
|
162
|
+
return res.data.map(toRecord);
|
|
163
|
+
}
|
|
164
|
+
async deletePage(slug) {
|
|
165
|
+
const page = await this.pageRow(slug);
|
|
166
|
+
if (!page)
|
|
167
|
+
return;
|
|
168
|
+
for (const [table, col, val] of [
|
|
169
|
+
["refs", "source_id", page.id],
|
|
170
|
+
["page_versions", "page_id", page.id],
|
|
171
|
+
["pages", "id", page.id],
|
|
172
|
+
]) {
|
|
173
|
+
const res = table === "refs"
|
|
174
|
+
? await this.db.from(table).delete().eq("source_kind", "page").eq(col, val)
|
|
175
|
+
: await this.db.from(table).delete().eq(col, val);
|
|
176
|
+
if (res.error)
|
|
177
|
+
throw new Error(`smoodly: ${res.error.message}`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
package/dist/zones.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
type WithSchema = {
|
|
2
|
+
schema: {
|
|
3
|
+
name: string;
|
|
4
|
+
};
|
|
5
|
+
};
|
|
6
|
+
export type ZonePolicy = {
|
|
7
|
+
kind: "sections";
|
|
8
|
+
allow: string[];
|
|
9
|
+
max?: number;
|
|
10
|
+
} | {
|
|
11
|
+
kind: "freeform";
|
|
12
|
+
allow: string[] | "*";
|
|
13
|
+
rows: boolean;
|
|
14
|
+
} | {
|
|
15
|
+
kind: "locked";
|
|
16
|
+
component: string;
|
|
17
|
+
};
|
|
18
|
+
export declare const z: {
|
|
19
|
+
sections(components: WithSchema[]): {
|
|
20
|
+
kind: "sections";
|
|
21
|
+
allow: string[];
|
|
22
|
+
} & {
|
|
23
|
+
max(n: number): ZonePolicy;
|
|
24
|
+
};
|
|
25
|
+
freeform(opts: {
|
|
26
|
+
sections: WithSchema[] | "*";
|
|
27
|
+
rows?: boolean;
|
|
28
|
+
}): ZonePolicy;
|
|
29
|
+
locked(component: WithSchema): ZonePolicy;
|
|
30
|
+
};
|
|
31
|
+
export {};
|