emdash-multisite 0.0.0-stage → 0.7.0
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 +681 -2
- package/SETUP.md +205 -0
- package/bin/setup.mjs +83 -0
- package/package.json +91 -4
- package/src/abuse.ts +65 -0
- package/src/auth-links.ts +21 -0
- package/src/auth-ui.ts +390 -0
- package/src/backup.ts +137 -0
- package/src/bootstrap.ts +51 -0
- package/src/clone-run.ts +57 -0
- package/src/clone.ts +77 -0
- package/src/config.ts +17 -0
- package/src/console-page.ts +318 -0
- package/src/console.ts +141 -0
- package/src/db/d1.ts +9 -0
- package/src/db/sqlite.ts +6 -0
- package/src/demo.ts +23 -0
- package/src/dialect.ts +109 -0
- package/src/edge-cache.ts +249 -0
- package/src/fleet-actions.ts +44 -0
- package/src/fleet-blocks.ts +115 -0
- package/src/fleet.ts +151 -0
- package/src/handoff.ts +58 -0
- package/src/index.ts +35 -0
- package/src/log.ts +6 -0
- package/src/media-bucket.ts +44 -0
- package/src/middleware.ts +607 -0
- package/src/orgs.ts +343 -0
- package/src/passwords.ts +60 -0
- package/src/platform-env.ts +10 -0
- package/src/plugin-entry.ts +145 -0
- package/src/plugin.ts +26 -0
- package/src/provision.ts +17 -0
- package/src/purge.ts +105 -0
- package/src/registry.ts +421 -0
- package/src/rewrite.ts +199 -0
- package/src/rules.ts +521 -0
- package/src/runtime.ts +69 -0
- package/src/scheduled.ts +107 -0
- package/src/scope.ts +178 -0
- package/src/sites.ts +97 -0
- package/src/slots.ts +38 -0
- package/src/storage.ts +57 -0
- package/src/vite.ts +256 -0
- package/templates/astro.config.snippet.mjs +42 -0
- package/templates/worker.ts +61 -0
- package/templates/wrangler.snippet.jsonc +26 -0
package/src/auth-ui.ts
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Sites" tab for Better Auth UI: the signed-in user's sites inside the account page (`/account/sites`).
|
|
3
|
+
* Built with HeroUI v3 (the library Better Auth UI itself uses, so it looks like the sign-in and account pages) in plain words for
|
|
4
|
+
* people who are not technical: a card per site with a picture, Edit and View as the two main buttons, a four-step-feel create
|
|
5
|
+
* flow (pick what you want to make, name it, wait, "what next"), and a Change look view. See emdash-plan/product/multisite-onboarding-ux.md.
|
|
6
|
+
*
|
|
7
|
+
* Needs `emdash-better-auth` >= 0.10.0 (extension point for Better Auth UI plugins). In the host:
|
|
8
|
+
*
|
|
9
|
+
* // astro.config.mjs
|
|
10
|
+
* import { betterAuthUi } from "emdash-better-auth/vite";
|
|
11
|
+
* vite: { plugins: [betterAuthUi({ plugins: "emdash-multisite/auth-ui" })] }
|
|
12
|
+
*
|
|
13
|
+
* It only calls this package's own JSON endpoints and Better Auth's organization/create. `@heroui/react` is a peer dependency of
|
|
14
|
+
* the host (the same copy Better Auth UI uses).
|
|
15
|
+
*/
|
|
16
|
+
// @ts-ignore - provided by the host (peer dependency)
|
|
17
|
+
import { createAuthPlugin } from "@better-auth-ui/core";
|
|
18
|
+
// @ts-ignore - provided by the host (peer dependency)
|
|
19
|
+
import { Button, Card, Chip, Input, ProgressBar, Skeleton, buttonVariants } from "@heroui/react";
|
|
20
|
+
// @ts-ignore - provided by the host (peer dependency)
|
|
21
|
+
import * as React from "react";
|
|
22
|
+
|
|
23
|
+
interface MySite {
|
|
24
|
+
slug: string;
|
|
25
|
+
name: string;
|
|
26
|
+
role: string;
|
|
27
|
+
theme: string | null;
|
|
28
|
+
themeId: string | null;
|
|
29
|
+
status: string;
|
|
30
|
+
state: "active" | "suspended";
|
|
31
|
+
url: string | null;
|
|
32
|
+
adminUrl: string | null;
|
|
33
|
+
canRetry: boolean;
|
|
34
|
+
error?: string;
|
|
35
|
+
}
|
|
36
|
+
interface Me {
|
|
37
|
+
sites: MySite[];
|
|
38
|
+
emailVerified: boolean;
|
|
39
|
+
themed: boolean;
|
|
40
|
+
staff: boolean;
|
|
41
|
+
owned: number;
|
|
42
|
+
max: number | null;
|
|
43
|
+
/** Active plan name (lowercased), or null for the free tier. */
|
|
44
|
+
plan: string | null;
|
|
45
|
+
/** Billing is on, so the Upgrade link resolves (otherwise /account/billing 404s). */
|
|
46
|
+
canUpgrade: boolean;
|
|
47
|
+
}
|
|
48
|
+
interface Look {
|
|
49
|
+
id: string;
|
|
50
|
+
name: string;
|
|
51
|
+
description: string;
|
|
52
|
+
pitch?: string;
|
|
53
|
+
preview?: string;
|
|
54
|
+
demoUrl?: string;
|
|
55
|
+
checklist?: Array<{ label: string; path: string }>;
|
|
56
|
+
current?: boolean;
|
|
57
|
+
adds?: string[];
|
|
58
|
+
hides?: string[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const h = React.createElement;
|
|
62
|
+
const grid = (min: number) => ({ display: "grid", gridTemplateColumns: `repeat(auto-fill, minmax(${min}px, 1fr))`, gap: 16 }) as const;
|
|
63
|
+
const muted = { opacity: 0.65, fontSize: 14, margin: 0 } as const;
|
|
64
|
+
/** Display label for a plan name; null (no subscription) reads as "Free". */
|
|
65
|
+
const planLabel = (plan: string | null): string => (plan ? plan.charAt(0).toUpperCase() + plan.slice(1) : "Free");
|
|
66
|
+
const linkBtn = (variant: string) => buttonVariants({ variant });
|
|
67
|
+
|
|
68
|
+
async function api(url: string, init: RequestInit = {}) {
|
|
69
|
+
const res = await fetch(url, { credentials: "same-origin", ...init, headers: { accept: "application/json", ...(init.headers as Record<string, string>) } });
|
|
70
|
+
let body: any = null;
|
|
71
|
+
try {
|
|
72
|
+
body = await res.json();
|
|
73
|
+
} catch {}
|
|
74
|
+
return { ok: res.ok, status: res.status, body };
|
|
75
|
+
}
|
|
76
|
+
const post = (url: string, data: unknown) =>
|
|
77
|
+
api(url, { method: "POST", headers: { "content-type": "application/json", "x-emdash-request": "1" }, body: JSON.stringify(data) });
|
|
78
|
+
const slugify = (s: string) => s.toLowerCase().normalize("NFKD").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40);
|
|
79
|
+
const qs = (key: string) => (typeof location !== "undefined" ? new URLSearchParams(location.search).get(key) : null);
|
|
80
|
+
const clean = () => typeof history !== "undefined" && history.replaceState(null, "", location.pathname);
|
|
81
|
+
/** Deep link into a site's own admin, through the one-click sign-in. */
|
|
82
|
+
const adminLink = (slug: string, path: string) => `/sites/${slug}/open-admin?next=${encodeURIComponent(path)}`;
|
|
83
|
+
|
|
84
|
+
/** A picture of a look (a screenshot of its demo). */
|
|
85
|
+
function Shot(props: { look?: Look; height?: number }) {
|
|
86
|
+
const src = props.look?.preview;
|
|
87
|
+
return h("div", { style: { ...(props.height ? { height: props.height } : { aspectRatio: "16 / 10" }), overflow: "hidden", borderRadius: 10, background: "rgba(127,127,127,.12)" } },
|
|
88
|
+
src ? h("img", { src, alt: "", loading: "lazy", style: { width: "100%", height: "100%", objectFit: "cover", objectPosition: "top", display: "block" } }) : null);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Shimmer while data loads: same footprint as the real cards. */
|
|
92
|
+
const bone = (cls: string) => h(Skeleton, { className: cls });
|
|
93
|
+
function SitesSkeleton() {
|
|
94
|
+
return h("div", { "aria-hidden": true, style: { display: "grid", gap: 16 } },
|
|
95
|
+
h("div", { style: { display: "flex", justifyContent: "space-between" } }, bone("h-7 w-32 rounded-lg"), bone("h-9 w-24 rounded-full")),
|
|
96
|
+
h("div", { style: grid(300) }, ...[0, 1].map((i) => h(Card, { key: i }, h(Card.Content, { style: { display: "grid", gap: 10 } },
|
|
97
|
+
bone("h-[170px] w-full rounded-xl"), bone("h-5 w-40 rounded-lg"), bone("h-4 w-56 rounded-lg"), bone("h-9 w-32 rounded-full"))))));
|
|
98
|
+
}
|
|
99
|
+
function LookSkeleton() {
|
|
100
|
+
return h("div", { "aria-hidden": true, style: grid(220) }, ...[0, 1, 2, 3].map((i) => h("div", { key: i, style: { display: "grid", gap: 8 } }, bone("h-[130px] w-full rounded-xl"), bone("h-4 w-28 rounded-lg"), bone("h-3 w-40 rounded-lg"))));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Pick a look: big cards with a picture. Used by the create flow and by Change look. */
|
|
104
|
+
function LookPicker(props: { looks: Look[]; value: string; onChange: (id: string) => void }) {
|
|
105
|
+
return h("div", { role: "radiogroup", "aria-label": "Look", style: grid(220) },
|
|
106
|
+
...props.looks.map((t) => {
|
|
107
|
+
const on = props.value === t.id;
|
|
108
|
+
return h("div", { key: t.id, style: { display: "grid", gap: 6 } },
|
|
109
|
+
h("button", { type: "button", role: "radio", "aria-checked": on, onClick: () => props.onChange(t.id),
|
|
110
|
+
style: { all: "unset", cursor: "pointer", display: "block", borderRadius: 14, outline: on ? "3px solid var(--accent, #2563eb)" : "1px solid rgba(127,127,127,.3)", outlineOffset: 2, padding: 8 } },
|
|
111
|
+
h(Shot, { look: t }),
|
|
112
|
+
h("div", { style: { padding: "8px 4px 2px" } },
|
|
113
|
+
h("strong", null, t.pitch ? t.pitch.split(":")[0] : t.name), t.current ? h(Chip, { size: "sm", color: "accent", variant: "soft", style: { marginLeft: 8 } }, "Current") : null,
|
|
114
|
+
h("p", { style: muted }, t.pitch?.includes(":") ? t.pitch.split(":").slice(1).join(":").trim() : t.description),
|
|
115
|
+
!t.current && (t.adds?.length || t.hides?.length)
|
|
116
|
+
? h("p", { style: { ...muted, fontSize: 12 } }, [t.adds?.length ? `Adds ${t.adds.join(", ")}` : "", t.hides?.length ? `hides ${t.hides.join(", ")}` : ""].filter(Boolean).join("; "))
|
|
117
|
+
: null)),
|
|
118
|
+
t.demoUrl ? h("a", { href: t.demoUrl, target: "_blank", rel: "noopener", style: { fontSize: 14, paddingLeft: 12 } }, "Preview \u2197") : null);
|
|
119
|
+
}));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const STEP_PERCENT: Record<string, number> = { queued: 8, tables: 35, content: 65, identity: 90, ready: 100 };
|
|
123
|
+
const STEP_TEXT: Record<string, string> = {
|
|
124
|
+
queued: "Lots of people are building right now. You are next\u2026",
|
|
125
|
+
tables: "Creating your site\u2026",
|
|
126
|
+
content: "Adding your starter pages\u2026",
|
|
127
|
+
identity: "Setting up your account\u2026",
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
/** Create a site: pick what to make, name it, wait, then "what next". */
|
|
131
|
+
function Wizard(props: { me: Me; looks: Look[]; onDone: () => void }) {
|
|
132
|
+
const { looks } = props;
|
|
133
|
+
const wanted = qs("theme");
|
|
134
|
+
const [look, setLook] = React.useState(looks.some((t) => t.id === wanted) ? (wanted as string) : looks[0]?.id ?? "");
|
|
135
|
+
const [step, setStep] = React.useState(looks.length > 1 && !wanted ? 1 : 2);
|
|
136
|
+
const [name, setName] = React.useState("");
|
|
137
|
+
const [slug, setSlug] = React.useState("");
|
|
138
|
+
const [touched, setTouched] = React.useState(false);
|
|
139
|
+
const [check, setCheck] = React.useState({ ok: false, text: "", url: "" });
|
|
140
|
+
const [error, setError] = React.useState("");
|
|
141
|
+
const [busy, setBusy] = React.useState(false);
|
|
142
|
+
const [progress, setProgress] = React.useState(null as null | { slug: string; name: string; step: string; status: string; error?: string });
|
|
143
|
+
const seq = React.useRef(0);
|
|
144
|
+
const chosen = looks.find((t) => t.id === look);
|
|
145
|
+
React.useEffect(() => {
|
|
146
|
+
if (!slug) return setCheck({ ok: false, text: "", url: "" });
|
|
147
|
+
setCheck({ ok: false, text: "Checking\u2026", url: "" });
|
|
148
|
+
const mine = ++seq.current;
|
|
149
|
+
const t = setTimeout(async () => {
|
|
150
|
+
const r = await api(`/api/multisite/slug-check?slug=${encodeURIComponent(slug)}`);
|
|
151
|
+
if (mine !== seq.current) return;
|
|
152
|
+
setCheck(r.body?.available ? { ok: true, text: `${(r.body.url ?? slug).replace(/^https?:\/\//, "")} is free`, url: r.body.url ?? "" } : { ok: false, text: r.body?.message ?? "That address is taken. Try another.", url: "" });
|
|
153
|
+
}, 300);
|
|
154
|
+
return () => clearTimeout(t);
|
|
155
|
+
}, [slug]);
|
|
156
|
+
React.useEffect(() => {
|
|
157
|
+
if (!progress || progress.status === "ready" || progress.status === "failed") return;
|
|
158
|
+
const t = setTimeout(async () => {
|
|
159
|
+
const r = await api(`/api/multisite/sites/${progress.slug}`);
|
|
160
|
+
if (r.ok && r.body) setProgress({ ...progress, status: r.body.status, step: r.body.step ?? progress.step, error: r.body.error });
|
|
161
|
+
else setProgress({ ...progress });
|
|
162
|
+
}, 1500);
|
|
163
|
+
return () => clearTimeout(t);
|
|
164
|
+
}, [progress]);
|
|
165
|
+
|
|
166
|
+
if (!props.me.emailVerified) return h("p", { role: "alert" }, "Please confirm your email address first. Then you can create your site.");
|
|
167
|
+
if (props.me.max !== null && props.me.owned >= props.me.max && !progress)
|
|
168
|
+
return h("p", { role: "alert" }, `Your plan includes ${props.me.max} site${props.me.max === 1 ? "" : "s"}, and you have ${props.me.owned}.`);
|
|
169
|
+
|
|
170
|
+
if (progress?.status === "failed")
|
|
171
|
+
return h(Card, null, h(Card.Content, { style: { display: "grid", gap: 10 } },
|
|
172
|
+
h("h2", { style: { margin: 0 } }, "That did not finish"),
|
|
173
|
+
h("p", { style: muted }, "Nothing is lost. Go to your sites and press Try again."),
|
|
174
|
+
h("div", null, h(Button, { variant: "primary", onPress: props.onDone }, "Go to my sites"))));
|
|
175
|
+
if (progress?.status === "ready") {
|
|
176
|
+
const base = chosen?.checklist ?? [];
|
|
177
|
+
return h(Card, null, h(Card.Content, { style: { display: "grid", gap: 14 } },
|
|
178
|
+
h("div", null, h("h2", { style: { margin: 0 } }, "Your site is live"), h("p", { style: muted }, progress.name)),
|
|
179
|
+
h("div", { style: { display: "flex", gap: 8, flexWrap: "wrap" } },
|
|
180
|
+
h("a", { href: adminLink(progress.slug, "/_emdash/admin"), target: "_blank", rel: "noopener", className: linkBtn("primary") }, "Edit my site"),
|
|
181
|
+
check.url ? h("a", { href: check.url, target: "_blank", rel: "noopener", className: linkBtn("secondary") }, "View my site") : null),
|
|
182
|
+
base.length
|
|
183
|
+
? h("div", { style: { display: "grid", gap: 8 } }, h("strong", null, "Get started"),
|
|
184
|
+
...base.map((c) => h("a", { key: c.path + c.label, href: adminLink(progress.slug, c.path), target: "_blank", rel: "noopener",
|
|
185
|
+
style: { border: "1px solid rgba(127,127,127,.3)", borderRadius: 12, padding: "10px 12px", textDecoration: "none", color: "inherit" } }, `${c.label} \u2192`)))
|
|
186
|
+
: null,
|
|
187
|
+
h("div", null, h(Button, { variant: "ghost", onPress: props.onDone }, "Back to my sites"))));
|
|
188
|
+
}
|
|
189
|
+
if (progress)
|
|
190
|
+
return h(Card, null, h(Card.Content, { style: { display: "grid", gap: 12 } },
|
|
191
|
+
h("h2", { style: { margin: 0 } }, `Building ${progress.name}\u2026`),
|
|
192
|
+
h(ProgressBar, { "aria-label": "Building your site", value: STEP_PERCENT[progress.step] ?? 20 }, h(ProgressBar.Track, null, h(ProgressBar.Fill, null))),
|
|
193
|
+
h("p", { role: "status", "aria-live": "polite", style: muted }, STEP_TEXT[progress.step] ?? "Almost there\u2026")));
|
|
194
|
+
|
|
195
|
+
if (step === 1)
|
|
196
|
+
return h("div", { style: { display: "grid", gap: 16 } },
|
|
197
|
+
h("div", null, h("p", { style: { ...muted, fontSize: 12, textTransform: "uppercase", letterSpacing: ".04em" } }, "Step 1 of 2"),
|
|
198
|
+
h("h2", { style: { margin: 0 } }, "What do you want to make?"), h("p", { style: muted }, "Pick a look. You can change it later and your content stays.")),
|
|
199
|
+
h(LookPicker, { looks, value: look, onChange: setLook }),
|
|
200
|
+
h("div", { style: { display: "flex", gap: 8 } }, h(Button, { variant: "primary", onPress: () => setStep(2) }, "Continue"), h(Button, { variant: "ghost", onPress: props.onDone }, "Cancel")));
|
|
201
|
+
|
|
202
|
+
const submit = async (e: Event) => {
|
|
203
|
+
e.preventDefault();
|
|
204
|
+
if (!check.ok || !name.trim()) return;
|
|
205
|
+
setBusy(true);
|
|
206
|
+
setError("");
|
|
207
|
+
const r = await post("/api/auth/organization/create", { name: name.trim(), slug, ...(looks.length > 1 ? { metadata: { theme: look } } : {}) });
|
|
208
|
+
setBusy(false);
|
|
209
|
+
if (!r.ok) return setError(r.body?.message || r.body?.code || `Could not create the site (error ${r.status}).`);
|
|
210
|
+
clean();
|
|
211
|
+
setProgress({ slug, name: name.trim(), step: "tables", status: "provisioning" });
|
|
212
|
+
};
|
|
213
|
+
return h("form", { onSubmit: submit, style: { display: "grid", gap: 14, maxWidth: 520 } },
|
|
214
|
+
h("div", null, h("p", { style: { ...muted, fontSize: 12, textTransform: "uppercase", letterSpacing: ".04em" } }, looks.length > 1 ? "Step 2 of 2" : "New site"),
|
|
215
|
+
h("h2", { style: { margin: 0 } }, "Name your site"), h("p", { style: muted }, chosen ? `Look: ${chosen.pitch?.split(":")[0] ?? chosen.name}` : "This is what visitors see. You can change it later.")),
|
|
216
|
+
h("label", { style: { display: "grid", gap: 4 } }, h("strong", null, "Site name"),
|
|
217
|
+
h(Input, { value: name, "aria-label": "Site name", placeholder: "My garden notes", maxLength: 80, autoComplete: "off", fullWidth: true,
|
|
218
|
+
onChange: (e: any) => { setName(e.target.value); if (!touched) setSlug(slugify(e.target.value)); } })),
|
|
219
|
+
h("label", { style: { display: "grid", gap: 4 } }, h("strong", null, "Site address"),
|
|
220
|
+
h(Input, { value: slug, "aria-label": "Site address", maxLength: 40, autoComplete: "off", fullWidth: true, "aria-describedby": "addr-msg",
|
|
221
|
+
onChange: (e: any) => { setTouched(true); setSlug(slugify(e.target.value)); } }),
|
|
222
|
+
h("span", { id: "addr-msg", "aria-live": "polite", style: { fontSize: 14, color: check.ok ? "#059669" : slug && check.text !== "Checking\u2026" ? "#dc2626" : undefined, opacity: check.ok || slug ? 1 : 0.65 } }, check.ok ? `\u2713 ${check.text}` : check.text || "Letters, numbers and dashes.")),
|
|
223
|
+
error ? h("p", { role: "alert", style: { color: "#dc2626", margin: 0 } }, error) : null,
|
|
224
|
+
h("div", { style: { display: "flex", gap: 8 } },
|
|
225
|
+
h(Button, { type: "submit", variant: "primary", isDisabled: busy || !check.ok || !name.trim() }, "Create my site"),
|
|
226
|
+
looks.length > 1 && !wanted ? h(Button, { variant: "outline", onPress: () => setStep(1) }, "Back") : null,
|
|
227
|
+
h(Button, { variant: "ghost", onPress: props.onDone }, "Cancel")));
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Change the look of one site (nothing is deleted; the old look's content is hidden and comes back). */
|
|
231
|
+
function ChangeLook(props: { slug: string; onDone: () => void }) {
|
|
232
|
+
const [state, setState] = React.useState(null as any);
|
|
233
|
+
const [demos, setDemos] = React.useState({} as Record<string, string>);
|
|
234
|
+
const [choice, setChoice] = React.useState("");
|
|
235
|
+
const [sample, setSample] = React.useState(false);
|
|
236
|
+
const [busy, setBusy] = React.useState(false);
|
|
237
|
+
const [msg, setMsg] = React.useState("");
|
|
238
|
+
const [failed, setFailed] = React.useState(false);
|
|
239
|
+
React.useEffect(() => {
|
|
240
|
+
api(`/api/multisite/sites/${props.slug}/appearance`).then((r) => {
|
|
241
|
+
if (!r.ok) return setFailed(true);
|
|
242
|
+
setState(r.body);
|
|
243
|
+
setChoice(r.body.current);
|
|
244
|
+
});
|
|
245
|
+
api("/api/multisite/themes").then((r) => r.ok && setDemos(Object.fromEntries((r.body.themes ?? []).filter((t: Look) => t.demoUrl).map((t: Look) => [t.id, t.demoUrl]))));
|
|
246
|
+
}, [props.slug]);
|
|
247
|
+
if (failed) return h("div", null, h("p", { role: "alert" }, "You cannot change this site\u2019s look."), h(Button, { variant: "outline", onPress: props.onDone }, "Back"));
|
|
248
|
+
if (!state) return h(LookSkeleton);
|
|
249
|
+
const looks: Look[] = state.themes.map((t: Look) => ({ ...t, demoUrl: demos[t.id] }));
|
|
250
|
+
const submit = async (e: Event) => {
|
|
251
|
+
e.preventDefault();
|
|
252
|
+
setBusy(true);
|
|
253
|
+
setMsg("Changing the look\u2026 this takes a few seconds.");
|
|
254
|
+
const r = await post(`/api/multisite/sites/${props.slug}/theme`, { theme: choice, includeContent: sample });
|
|
255
|
+
if (!r.ok) {
|
|
256
|
+
setBusy(false);
|
|
257
|
+
return setMsg(r.body?.message || "Could not change the look. Try again.");
|
|
258
|
+
}
|
|
259
|
+
// The switch runs in the background (202): wait until the site really shows the new look.
|
|
260
|
+
for (let i = 0; i < 60; i++) {
|
|
261
|
+
await new Promise((res) => setTimeout(res, 1000));
|
|
262
|
+
const a = await api(`/api/multisite/sites/${props.slug}/appearance`);
|
|
263
|
+
if (a.ok && a.body.current === choice) {
|
|
264
|
+
setBusy(false);
|
|
265
|
+
setState({ ...state, current: choice, themes: state.themes.map((t: any) => ({ ...t, current: t.id === choice })) });
|
|
266
|
+
return setMsg("Done. Your site has its new look. Your content is safe.");
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
setBusy(false);
|
|
270
|
+
setMsg("This is taking longer than usual. Wait a minute, then check your sites. If the look did not change, try again.");
|
|
271
|
+
};
|
|
272
|
+
return h("form", { onSubmit: submit, style: { display: "grid", gap: 16 } },
|
|
273
|
+
h("div", null, h("h2", { style: { margin: 0 } }, `Change the look of ${state.name}`),
|
|
274
|
+
h("p", { style: muted }, "Your content stays. Anything the new look does not show is hidden, and comes back if you switch back.")),
|
|
275
|
+
h(LookPicker, { looks, value: choice, onChange: setChoice }),
|
|
276
|
+
h("label", { style: { display: "flex", gap: 8, alignItems: "center" } }, h("input", { type: "checkbox", checked: sample, onChange: (e: any) => setSample(e.target.checked) }), "Add example content for any new sections"),
|
|
277
|
+
h("p", { role: "status", "aria-live": "polite", style: muted }, msg),
|
|
278
|
+
h("div", { style: { display: "flex", gap: 8 } },
|
|
279
|
+
h(Button, { type: "submit", variant: "primary", isDisabled: busy || choice === state.current }, "Use this look"),
|
|
280
|
+
h(Button, { variant: "outline", isDisabled: busy, onPress: props.onDone }, "Back")));
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function SiteCard(props: { s: MySite; me: Me; look?: Look; reload: () => void; onLook: (slug: string) => void }) {
|
|
284
|
+
const { s, me, look } = props;
|
|
285
|
+
const [busy, setBusy] = React.useState(false);
|
|
286
|
+
const suspended = s.state === "suspended";
|
|
287
|
+
const live = s.status === "ready" && !suspended;
|
|
288
|
+
const run = async (fn: () => Promise<{ ok: boolean; body: any }>, fail: string) => {
|
|
289
|
+
setBusy(true);
|
|
290
|
+
const r = await fn();
|
|
291
|
+
setBusy(false);
|
|
292
|
+
if (!r.ok) alert(r.body?.message || r.body?.code || fail);
|
|
293
|
+
props.reload();
|
|
294
|
+
};
|
|
295
|
+
const chip = suspended ? ["Paused", "warning"] : s.status === "provisioning" ? ["Setting up", "default"] : s.status === "failed" ? ["Needs attention", "danger"] : ["Live", "success"];
|
|
296
|
+
return h(Card, null,
|
|
297
|
+
h(Card.Content, { style: { display: "grid", gap: 10 } },
|
|
298
|
+
h(Shot, { look, height: 170 }),
|
|
299
|
+
h("div", { style: { display: "flex", justifyContent: "space-between", gap: 8, alignItems: "start" } },
|
|
300
|
+
h("div", { style: { minWidth: 0 } }, h("strong", { style: { fontSize: 17 } }, s.name),
|
|
301
|
+
h("p", { style: { ...muted, overflowWrap: "anywhere" } }, (s.url ?? s.slug).replace(/^https?:\/\//, ""))),
|
|
302
|
+
h(Chip, { size: "sm", color: chip[1], variant: "soft" }, chip[0])),
|
|
303
|
+
suspended ? h("p", { style: { color: "#dc2626", margin: 0, fontSize: 14 } }, "This site is paused. Contact support to turn it back on.") : null,
|
|
304
|
+
s.error ? h("p", { style: { color: "#dc2626", margin: 0, fontSize: 14 } }, s.error) : null,
|
|
305
|
+
h("div", { style: { display: "flex", gap: 8, flexWrap: "wrap" } },
|
|
306
|
+
live ? h("a", { href: adminLink(s.slug, "/_emdash/admin"), target: "_blank", rel: "noopener", className: linkBtn("primary") }, "Edit") : null,
|
|
307
|
+
live && s.url ? h("a", { href: s.url, target: "_blank", rel: "noopener", className: linkBtn("secondary") }, "View") : null,
|
|
308
|
+
s.canRetry ? h(Button, { variant: "primary", isDisabled: busy, onPress: () => run(() => post(`/api/multisite/sites/${s.slug}/retry`, {}), "Could not try again.") }, "Try again") : null),
|
|
309
|
+
h("div", { style: { display: "flex", gap: 4, flexWrap: "wrap" } },
|
|
310
|
+
live && me.themed && (s.role === "owner" || me.staff) ? h(Button, { size: "sm", variant: "ghost", onPress: () => props.onLook(s.slug) }, "Change look") : null,
|
|
311
|
+
s.role === "owner"
|
|
312
|
+
? h(Button, { size: "sm", variant: "ghost", isDisabled: busy, onPress: () => {
|
|
313
|
+
if (confirm(`Delete "${s.name}"? It goes offline now. Support can bring it back for a limited time.`))
|
|
314
|
+
run(() => post(`/api/multisite/sites/${s.slug}/state`, { action: "delete" }), "Could not delete the site.");
|
|
315
|
+
} }, "Delete") : null)));
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** The control-plane (app) origin, for a tenant that must send the user "home" to manage sites. */
|
|
319
|
+
function appOrigin(): string {
|
|
320
|
+
// The host injects it on the auth pages (Astro.locals.multisite.account is app-relative);
|
|
321
|
+
// fall back to the registrable apex of the current host (drop the left-most label).
|
|
322
|
+
try {
|
|
323
|
+
const inj = (window as unknown as { __MULTISITE_APP_URL__?: string }).__MULTISITE_APP_URL__;
|
|
324
|
+
if (inj) return inj;
|
|
325
|
+
const h = window.location.hostname.split(".");
|
|
326
|
+
return `${window.location.protocol}//${h.length > 2 ? h.slice(1).join(".") : h.join(".")}`;
|
|
327
|
+
} catch {
|
|
328
|
+
return "/";
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function SitesTab() {
|
|
333
|
+
const [me, setMe] = React.useState(null as Me | null);
|
|
334
|
+
const [looks, setLooks] = React.useState([] as Look[]);
|
|
335
|
+
const [failed, setFailed] = React.useState(false);
|
|
336
|
+
// A tenant site has no "my sites" feature — the `/api/multisite/me` route only exists on the
|
|
337
|
+
// control plane (404 here). Show a link back to the app instead of a broken/empty list.
|
|
338
|
+
const [tenant, setTenant] = React.useState(false);
|
|
339
|
+
const [view, setView] = React.useState((qs("new") !== null ? { kind: "new" } : qs("appearance") ? { kind: "look", slug: qs("appearance") } : { kind: "list" }) as { kind: string; slug?: string });
|
|
340
|
+
const timer = React.useRef(0 as any);
|
|
341
|
+
const load = React.useCallback(async () => {
|
|
342
|
+
clearTimeout(timer.current);
|
|
343
|
+
const r = await api("/api/multisite/me");
|
|
344
|
+
if (r.status === 404) return setTenant(true);
|
|
345
|
+
if (!r.ok) return setFailed(true);
|
|
346
|
+
setMe(r.body);
|
|
347
|
+
if (r.body.sites.some((s: MySite) => s.status === "provisioning")) timer.current = setTimeout(load, 3000);
|
|
348
|
+
}, []);
|
|
349
|
+
React.useEffect(() => {
|
|
350
|
+
load();
|
|
351
|
+
api("/api/multisite/themes").then((r) => r.ok && setLooks(r.body.themes ?? []));
|
|
352
|
+
return () => clearTimeout(timer.current);
|
|
353
|
+
}, [load]);
|
|
354
|
+
if (tenant)
|
|
355
|
+
return h(Card, null, h(Card.Content, { style: { display: "grid", gap: 8, justifyItems: "start" } },
|
|
356
|
+
h("h3", { style: { margin: 0 } }, "Manage your sites from your dashboard"),
|
|
357
|
+
h("p", { style: muted }, "This is one of your sites. Your full list of sites, billing and new-site creation live in your hosting dashboard."),
|
|
358
|
+
h("a", { href: `${appOrigin()}/account/sites`, className: linkBtn("primary") }, "Go to my dashboard")));
|
|
359
|
+
if (failed) return h("p", { role: "alert" }, "Could not load your sites. Please reload the page.");
|
|
360
|
+
if (!me) return h(SitesSkeleton);
|
|
361
|
+
const back = () => { clean(); setView({ kind: "list" }); load(); };
|
|
362
|
+
if (view.kind === "new") return h(Wizard, { me, looks, onDone: back });
|
|
363
|
+
if (view.kind === "look" && view.slug) return h(ChangeLook, { slug: view.slug, onDone: back });
|
|
364
|
+
const full = me.max !== null && me.owned >= me.max;
|
|
365
|
+
return h("div", { style: { display: "grid", gap: 16 } },
|
|
366
|
+
h("div", { style: { display: "flex", justifyContent: "space-between", alignItems: "center", gap: 12, flexWrap: "wrap" } },
|
|
367
|
+
h("div", null, h("h2", { style: { margin: 0 } }, "My sites"),
|
|
368
|
+
me.max !== null
|
|
369
|
+
? h("p", { style: muted },
|
|
370
|
+
`${planLabel(me.plan)} plan — ${me.owned} of ${me.max} site${me.max === 1 ? "" : "s"} used`,
|
|
371
|
+
full && me.canUpgrade ? h("a", { href: "/account/billing", style: { marginLeft: 8 } }, "Upgrade") : null)
|
|
372
|
+
: null),
|
|
373
|
+
full ? null : h(Button, { variant: "primary", isDisabled: !me.emailVerified, onPress: () => setView({ kind: "new" }) }, "New site")),
|
|
374
|
+
!me.emailVerified ? h("p", { style: muted }, "Confirm your email address to create a site.") : null,
|
|
375
|
+
me.sites.length === 0
|
|
376
|
+
? h(Card, null, h(Card.Content, { style: { display: "grid", gap: 8, justifyItems: "start" } },
|
|
377
|
+
h("h3", { style: { margin: 0 } }, "You have no sites yet"), h("p", { style: muted }, "Make one in a minute: pick a look, give it a name, done."),
|
|
378
|
+
me.emailVerified ? h(Button, { variant: "primary", onPress: () => setView({ kind: "new" }) }, "Create my first site") : null))
|
|
379
|
+
: h("div", { style: grid(300) },
|
|
380
|
+
...me.sites.map((s: MySite) => h(SiteCard, { key: s.slug, s, me, look: looks.find((t: Look) => t.id === s.themeId), reload: load, onLook: (slug: string) => setView({ kind: "look", slug }) }))));
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
export const sitesPlugin = createAuthPlugin("multisite-sites", () => ({
|
|
384
|
+
viewPaths: { settings: { sites: "sites" } },
|
|
385
|
+
settingsTabs: [{ view: "sites", label: "My sites", component: SitesTab }],
|
|
386
|
+
}));
|
|
387
|
+
/** What `betterAuthUi({ plugins: "emdash-multisite/auth-ui" })` registers. */
|
|
388
|
+
// oxlint-disable-next-line typescript/no-explicit-any
|
|
389
|
+
const plugins: any[] = [sitesPlugin()];
|
|
390
|
+
export default plugins;
|
package/src/backup.ts
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-site backup and restore. D1 Time Travel only restores the whole database, so a
|
|
3
|
+
* single site that was damaged (or deleted by mistake) needs its own copy.
|
|
4
|
+
*
|
|
5
|
+
* A backup is one JSON file in the media bucket, outside every site's key prefix:
|
|
6
|
+
* _backups/<site>/<iso time>[-pre-restore].json
|
|
7
|
+
* { version, slug, createdAt, schema: [...], data: [...], triggers: [...] }
|
|
8
|
+
* `schema` and `triggers` are the site's own CREATE statements; `data` is multi-row INSERTs whose
|
|
9
|
+
* values come from SQLite's `quote()`, so text, numbers, NULL and blobs round-trip exactly.
|
|
10
|
+
* Restore replays them in ONE D1 batch (atomic). The control-plane records (organization, state,
|
|
11
|
+
* theme, status) are not part of a backup: they stay where they are.
|
|
12
|
+
*
|
|
13
|
+
* ponytail: each table is read in one query, so a table too big for one D1 response needs paging;
|
|
14
|
+
* the control plane (primary site) is not backed up here (Time Travel covers it).
|
|
15
|
+
*/
|
|
16
|
+
import { sql } from "kysely";
|
|
17
|
+
import { clonePlan, insertStatements, retarget, type MasterRow } from "./clone.ts";
|
|
18
|
+
import { d1, type D1 } from "./clone-run.ts";
|
|
19
|
+
import { log } from "./log.ts";
|
|
20
|
+
import { runAsSite } from "./dialect.ts";
|
|
21
|
+
import { mediaBucket, type Bucket } from "./media-bucket.ts";
|
|
22
|
+
import { dropSiteTables } from "./purge.ts";
|
|
23
|
+
import { controlDb, forgetSiteTheme, siteHasTables } from "./registry.ts";
|
|
24
|
+
import { BACKUP_KEEP, backupKey, backupPrefix } from "./rules.ts";
|
|
25
|
+
import { scopeState } from "./scope.ts";
|
|
26
|
+
|
|
27
|
+
export interface Backup {
|
|
28
|
+
version: 1;
|
|
29
|
+
slug: string;
|
|
30
|
+
createdAt: string;
|
|
31
|
+
schema: string[];
|
|
32
|
+
data: string[];
|
|
33
|
+
triggers: string[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const quote = (name: string) => `"${name.replaceAll('"', '""')}"`;
|
|
37
|
+
const lastKey = (slug: string) => `multisite:backup:${slug}`;
|
|
38
|
+
|
|
39
|
+
async function needs(): Promise<{ db: D1; bucket: Bucket }> {
|
|
40
|
+
const [db, bucket] = [await d1(), await mediaBucket()];
|
|
41
|
+
if (!db || !bucket) throw new Error("[multisite] backups need the D1 and R2 (media) bindings");
|
|
42
|
+
return { db, bucket };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Read a site's schema and rows. Two D1 round trips for the rows, however many tables. */
|
|
46
|
+
export async function exportSite(slug: string): Promise<Backup> {
|
|
47
|
+
const { db } = await needs();
|
|
48
|
+
const { results } = await db.prepare("SELECT type, name, sql FROM sqlite_master WHERE instr(name, ?) > 0").bind(`$${slug}`).all<MasterRow>();
|
|
49
|
+
const plan = clonePlan(results, slug, slug);
|
|
50
|
+
if (!plan.tables.length) throw new Error(`[multisite] "${slug}" has no tables to back up`);
|
|
51
|
+
const info = await db.batch(plan.tables.map((t) => db.prepare(`PRAGMA table_info(${quote(t)})`)));
|
|
52
|
+
const columns = info.map((r) => (r.results as Array<{ name: string }>).map((c) => c.name));
|
|
53
|
+
const rows = await db.batch(
|
|
54
|
+
plan.tables.map((t, i) => db.prepare(`SELECT '(' || ${columns[i]!.map((c) => `quote(${quote(c)})`).join(" || ',' || ")} || ')' AS v FROM ${quote(t)}`)),
|
|
55
|
+
);
|
|
56
|
+
return {
|
|
57
|
+
version: 1,
|
|
58
|
+
slug,
|
|
59
|
+
createdAt: new Date().toISOString(),
|
|
60
|
+
schema: plan.schema,
|
|
61
|
+
data: plan.tables.flatMap((t, i) => insertStatements(t, columns[i]!, (rows[i]!.results as Array<{ v: string }>).map((r) => r.v))),
|
|
62
|
+
triggers: plan.triggers,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Store a backup, drop the oldest beyond `BACKUP_KEEP`, and remember when it happened. */
|
|
67
|
+
export async function saveBackup(backup: Backup, tag?: string): Promise<{ key: string; bytes: number }> {
|
|
68
|
+
const { bucket } = await needs();
|
|
69
|
+
const key = backupKey(backup.slug, backup.createdAt, tag);
|
|
70
|
+
const body = JSON.stringify(backup);
|
|
71
|
+
await bucket.put(key, body);
|
|
72
|
+
const keys = await listBackups(backup.slug);
|
|
73
|
+
const old = keys.slice(0, Math.max(0, keys.length - BACKUP_KEEP));
|
|
74
|
+
if (old.length) await bucket.delete(old);
|
|
75
|
+
await runAsSite(scopeState().primary, () =>
|
|
76
|
+
sql`INSERT INTO options (name, value) VALUES (${lastKey(backup.slug)}, ${JSON.stringify(backup.createdAt)})
|
|
77
|
+
ON CONFLICT(name) DO UPDATE SET value = excluded.value`.execute(controlDb()),
|
|
78
|
+
);
|
|
79
|
+
log("backup.saved", { site: backup.slug, key, bytes: body.length });
|
|
80
|
+
return { key, bytes: body.length };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export const backupSite = async (slug: string, tag?: string) => saveBackup(await exportSite(slug), tag);
|
|
84
|
+
|
|
85
|
+
/** Backup keys of a site, oldest first. */
|
|
86
|
+
export async function listBackups(slug: string): Promise<string[]> {
|
|
87
|
+
const { bucket } = await needs();
|
|
88
|
+
const keys: string[] = [];
|
|
89
|
+
let cursor: string | undefined;
|
|
90
|
+
do {
|
|
91
|
+
const page = await bucket.list({ prefix: backupPrefix(slug), cursor, limit: 1000 });
|
|
92
|
+
keys.push(...page.objects.map((o) => o.key));
|
|
93
|
+
cursor = page.truncated ? page.cursor : undefined;
|
|
94
|
+
} while (cursor);
|
|
95
|
+
return keys.sort();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** When each site was last backed up (ISO), by slug. */
|
|
99
|
+
export async function readLastBackups(): Promise<Map<string, string>> {
|
|
100
|
+
const r = await runAsSite(scopeState().primary, () =>
|
|
101
|
+
sql<{ name: string; value: string }>`SELECT name, value FROM options WHERE name LIKE 'multisite:backup:%'`.execute(controlDb()),
|
|
102
|
+
);
|
|
103
|
+
return new Map(r.rows.map((x) => [x.name.slice("multisite:backup:".length), JSON.parse(x.value) as string]));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export type RestoreResult = "restored" | "not-found" | "site-has-tables" | "bad-backup";
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Put a site back from a backup. Without `replace` the site must have no tables. With it the
|
|
110
|
+
* current state is backed up first (`-pre-restore`), then the tables are dropped and rebuilt, so a
|
|
111
|
+
* bad restore can itself be undone.
|
|
112
|
+
*/
|
|
113
|
+
export async function restoreSite(slug: string, key: string, opts: { replace: boolean }): Promise<RestoreResult> {
|
|
114
|
+
const { db, bucket } = await needs();
|
|
115
|
+
// Only this site's own backup files: the key comes from a request.
|
|
116
|
+
if (!key.startsWith(backupPrefix(slug)) || key.includes("..")) return "not-found";
|
|
117
|
+
const object = await bucket.get(key);
|
|
118
|
+
if (!object) return "not-found";
|
|
119
|
+
let backup: Backup;
|
|
120
|
+
try {
|
|
121
|
+
backup = JSON.parse(await object.text()) as Backup;
|
|
122
|
+
} catch {
|
|
123
|
+
return "bad-backup";
|
|
124
|
+
}
|
|
125
|
+
if (backup.version !== 1 || backup.slug !== slug || ![backup.schema, backup.data, backup.triggers].every(Array.isArray)) return "bad-backup";
|
|
126
|
+
if (await siteHasTables(slug)) {
|
|
127
|
+
if (!opts.replace) return "site-has-tables";
|
|
128
|
+
await backupSite(slug, "pre-restore");
|
|
129
|
+
const dropped = await runAsSite(scopeState().primary, () => dropSiteTables(slug));
|
|
130
|
+
if (dropped.remaining) throw new Error(`[multisite] could not clear ${dropped.remaining} tables of "${slug}"; nothing restored`);
|
|
131
|
+
}
|
|
132
|
+
const statements = ["PRAGMA defer_foreign_keys = ON", ...backup.schema, ...backup.data, ...backup.triggers].map((s) => retarget(s, backup.slug, slug));
|
|
133
|
+
await db.batch(statements.map((s) => db.prepare(s)));
|
|
134
|
+
forgetSiteTheme(slug);
|
|
135
|
+
log("backup.restored", { site: slug, key, replace: opts.replace });
|
|
136
|
+
return "restored";
|
|
137
|
+
}
|
package/src/bootstrap.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { Kysely, sql } from "kysely";
|
|
2
|
+
import { siteDialect, runAsSite } from "./dialect.ts";
|
|
3
|
+
import { scopeState } from "./scope.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Make the control-plane (primary) site usable without the first-run wizard.
|
|
7
|
+
*
|
|
8
|
+
* The primary site's first human arrives through Better Auth signup as a
|
|
9
|
+
* subscriber (role 10), not through EmDash's setup wizard. So the wizard would
|
|
10
|
+
* appear and then refuse ("admin user already exists"). This promotes a named
|
|
11
|
+
* user to admin and marks setup complete — run it ONCE, for your own staff
|
|
12
|
+
* account, from a trusted script. It never runs automatically.
|
|
13
|
+
*
|
|
14
|
+
* Tenant sites don't need this: provisioning already creates their admin and
|
|
15
|
+
* sets the flag (see createSiteForOrg).
|
|
16
|
+
*
|
|
17
|
+
* ```ts
|
|
18
|
+
* import { Kysely } from "kysely";
|
|
19
|
+
* import { createDialect } from "emdash-multisite/db/d1"; // or /db/sqlite
|
|
20
|
+
* import { bootstrapControlPlane } from "emdash-multisite/bootstrap";
|
|
21
|
+
* const db = new Kysely({ dialect: createDialect({ inner, sites }) });
|
|
22
|
+
* await bootstrapControlPlane(db, "you@example.com");
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
export async function bootstrapControlPlane(
|
|
26
|
+
db: Kysely<never>,
|
|
27
|
+
adminEmail: string,
|
|
28
|
+
): Promise<{ promoted: boolean }> {
|
|
29
|
+
const primary = scopeState().primary;
|
|
30
|
+
return runAsSite(primary, async () => {
|
|
31
|
+
const upd = await sql`
|
|
32
|
+
UPDATE users SET role = 50, email_verified = 1
|
|
33
|
+
WHERE lower(email) = ${adminEmail.toLowerCase()}`.execute(db);
|
|
34
|
+
const promoted = Number(upd.numAffectedRows ?? 0n) > 0;
|
|
35
|
+
if (!promoted) {
|
|
36
|
+
throw new Error(
|
|
37
|
+
`[multisite] no user "${adminEmail}" on the control-plane site. Sign up first, then run bootstrap.`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
await sql`INSERT INTO options (name, value) VALUES ('emdash:setup_complete', 'true')
|
|
41
|
+
ON CONFLICT(name) DO UPDATE SET value = 'true'`.execute(db);
|
|
42
|
+
return { promoted };
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Build the control-plane connection the bootstrap/scripts use. */
|
|
47
|
+
export function controlPlaneDb(): Kysely<never> {
|
|
48
|
+
const st = scopeState();
|
|
49
|
+
if (!st.inner) throw new Error("[multisite] database not initialised yet");
|
|
50
|
+
return new Kysely<never>({ dialect: siteDialect(st.inner, st.activeSite, st.primary) });
|
|
51
|
+
}
|
package/src/clone-run.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run a clone on D1: read a template site's schema, then rebuild it under the new
|
|
3
|
+
* site id in ONE batch (one transaction, one round trip). Goes straight to the D1
|
|
4
|
+
* binding, not through the per-site dialect: the statements already carry the
|
|
5
|
+
* physical `$<site>` names.
|
|
6
|
+
*
|
|
7
|
+
* ponytail: D1 only (the Node/SQLite host falls back to migrate-and-seed), and one
|
|
8
|
+
* batch, so a template whose clone outgrows D1's batch limits needs chunking.
|
|
9
|
+
*/
|
|
10
|
+
import { clonePlan, type MasterRow } from "./clone.ts";
|
|
11
|
+
import { d1Binding } from "./config.ts";
|
|
12
|
+
import { platformEnv } from "./platform-env.ts";
|
|
13
|
+
import { templateId } from "./rules.ts";
|
|
14
|
+
|
|
15
|
+
export interface D1Statement {
|
|
16
|
+
bind(...v: unknown[]): D1Statement;
|
|
17
|
+
all<T = unknown>(): Promise<{ results: T[] }>;
|
|
18
|
+
}
|
|
19
|
+
export interface D1 {
|
|
20
|
+
prepare(sql: string): D1Statement;
|
|
21
|
+
batch(statements: D1Statement[]): Promise<Array<{ results: unknown[] }>>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const d1 = async (): Promise<D1 | undefined> => {
|
|
25
|
+
const binding = d1Binding();
|
|
26
|
+
return binding ? ((await platformEnv())?.[binding] as D1 | undefined) : undefined;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* True when the theme's template is complete. The `multisite:theme` option is the
|
|
31
|
+
* last thing `buildTemplate` writes, so a template killed half way does not count.
|
|
32
|
+
*/
|
|
33
|
+
export async function hasTemplate(theme: string): Promise<boolean> {
|
|
34
|
+
const db = await d1();
|
|
35
|
+
if (!db) return false;
|
|
36
|
+
const table = `options$${templateId(theme)}`;
|
|
37
|
+
const q = (sql: string, ...v: unknown[]) => db.prepare(sql).bind(...v).all();
|
|
38
|
+
if (!(await q("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?", table)).results.length) return false;
|
|
39
|
+
return (await q(`SELECT 1 FROM "${table}" WHERE name = 'multisite:theme'`)).results.length > 0;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Create `to` as a copy of the theme's template. Returns false when there is no
|
|
44
|
+
* template (caller falls back to migrating and seeding). Atomic: on failure
|
|
45
|
+
* nothing of `to` exists, so a retry starts clean.
|
|
46
|
+
*/
|
|
47
|
+
export async function cloneTemplate(theme: string, to: string): Promise<boolean> {
|
|
48
|
+
const db = await d1();
|
|
49
|
+
if (!db || !(await hasTemplate(theme))) return false;
|
|
50
|
+
const from = templateId(theme);
|
|
51
|
+
const { results } = await db.prepare("SELECT type, name, sql FROM sqlite_master WHERE instr(name, ?) > 0").bind(`$${from}`).all<MasterRow>();
|
|
52
|
+
const plan = clonePlan(results, from, to);
|
|
53
|
+
// Foreign keys are checked at commit, so copy order between tables does not matter.
|
|
54
|
+
const statements = ["PRAGMA defer_foreign_keys = ON", ...plan.schema, ...plan.copy, ...plan.triggers];
|
|
55
|
+
await db.batch(statements.map((s) => db.prepare(s)));
|
|
56
|
+
return true;
|
|
57
|
+
}
|