@autumnsgrove/groveengine 0.9.4 → 0.9.6
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/dist/components/custom/GutterItem.svelte +1 -1
- package/dist/config/index.d.ts +1 -0
- package/dist/config/index.js +1 -0
- package/dist/config/presets.d.ts +46 -0
- package/dist/config/presets.js +120 -0
- package/dist/config/tiers.d.ts +10 -0
- package/dist/config/tiers.js +13 -0
- package/dist/durable-objects/PostContentDO.d.ts +62 -0
- package/dist/durable-objects/PostContentDO.js +231 -0
- package/dist/durable-objects/PostMetaDO.d.ts +98 -0
- package/dist/durable-objects/PostMetaDO.js +427 -0
- package/dist/durable-objects/TenantDO.d.ts +143 -0
- package/dist/durable-objects/TenantDO.js +429 -0
- package/dist/durable-objects/index.d.ts +14 -0
- package/dist/durable-objects/index.js +14 -0
- package/dist/feature-flags/cache.d.ts +64 -0
- package/dist/feature-flags/cache.js +219 -0
- package/dist/feature-flags/evaluate.d.ts +33 -0
- package/dist/feature-flags/evaluate.js +205 -0
- package/dist/feature-flags/index.d.ts +127 -0
- package/dist/feature-flags/index.js +149 -0
- package/dist/feature-flags/percentage.d.ts +39 -0
- package/dist/feature-flags/percentage.js +87 -0
- package/dist/feature-flags/rules.d.ts +35 -0
- package/dist/feature-flags/rules.js +112 -0
- package/dist/feature-flags/test-utils.d.ts +30 -0
- package/dist/feature-flags/test-utils.js +140 -0
- package/dist/feature-flags/types.d.ts +173 -0
- package/dist/feature-flags/types.js +9 -0
- package/dist/index.d.ts +22 -20
- package/dist/index.js +20 -18
- package/dist/payments/index.d.ts +7 -5
- package/dist/payments/index.js +11 -8
- package/dist/payments/lemonsqueezy/client.d.ts +124 -0
- package/dist/payments/lemonsqueezy/client.js +306 -0
- package/dist/payments/lemonsqueezy/index.d.ts +19 -0
- package/dist/payments/lemonsqueezy/index.js +18 -0
- package/dist/payments/lemonsqueezy/provider.d.ts +63 -0
- package/dist/payments/lemonsqueezy/provider.js +322 -0
- package/dist/payments/lemonsqueezy/types.d.ts +293 -0
- package/dist/payments/lemonsqueezy/types.js +7 -0
- package/dist/server/services/storage.js +2 -0
- package/dist/ui/components/chrome/Header.svelte +5 -6
- package/dist/ui/components/chrome/ThemeToggle.svelte +19 -80
- package/dist/ui/components/chrome/defaults.js +14 -4
- package/dist/ui/components/ui/GlassLegend.svelte +194 -0
- package/dist/ui/components/ui/GlassLegend.svelte.d.ts +68 -0
- package/dist/ui/components/ui/index.d.ts +1 -0
- package/dist/ui/components/ui/index.js +1 -0
- package/dist/utils/markdown.d.ts +6 -0
- package/dist/utils/markdown.js +15 -7
- package/dist/utils/upload-validation.d.ts +56 -0
- package/dist/utils/upload-validation.js +177 -0
- package/dist/utils/validation.d.ts +4 -1
- package/dist/utils/validation.js +12 -19
- package/package.json +11 -4
- package/static/favicon.svg +18 -5
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
{#if item.images?.length > 0}
|
|
64
64
|
<div class="gutter-gallery">
|
|
65
65
|
<GlassCarousel
|
|
66
|
-
images={item.images.map(img => ({ url: img.url, alt: img.alt || 'Gallery image', caption: img.caption }))}
|
|
66
|
+
images={item.images.map((/** @type {{url?: string, alt?: string, caption?: string}} */ img) => ({ url: img.url || '', alt: img.alt || 'Gallery image', caption: img.caption || '' }))}
|
|
67
67
|
variant="frosted"
|
|
68
68
|
showArrows={false}
|
|
69
69
|
class="gutter-carousel"
|
package/dist/config/index.d.ts
CHANGED
package/dist/config/index.js
CHANGED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared UI Presets
|
|
3
|
+
*
|
|
4
|
+
* Unified color and design presets used across the Grove ecosystem.
|
|
5
|
+
* This ensures consistency between Plant signup and Arbor admin panel.
|
|
6
|
+
*/
|
|
7
|
+
export interface ColorPreset {
|
|
8
|
+
/** Display name */
|
|
9
|
+
name: string;
|
|
10
|
+
/** Hex color value (e.g., "#16a34a") */
|
|
11
|
+
hex: string;
|
|
12
|
+
/** HSL values as string (e.g., "142 76% 36%") - used for CSS custom properties */
|
|
13
|
+
hsl: string;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Grove Nature Color Palette
|
|
17
|
+
*
|
|
18
|
+
* A curated set of colors inspired by nature, designed to feel
|
|
19
|
+
* warm and organic. These appear in the signup flow and settings panel.
|
|
20
|
+
*/
|
|
21
|
+
export declare const COLOR_PRESETS: ColorPreset[];
|
|
22
|
+
/**
|
|
23
|
+
* Default accent color (Grove Green)
|
|
24
|
+
*/
|
|
25
|
+
export declare const DEFAULT_ACCENT_COLOR = "#16a34a";
|
|
26
|
+
/**
|
|
27
|
+
* Font presets with display info
|
|
28
|
+
*/
|
|
29
|
+
export interface FontPreset {
|
|
30
|
+
/** Internal ID used in database */
|
|
31
|
+
id: string;
|
|
32
|
+
/** Display name */
|
|
33
|
+
name: string;
|
|
34
|
+
/** CSS font-family stack */
|
|
35
|
+
family: string;
|
|
36
|
+
/** Description for settings UI */
|
|
37
|
+
description: string;
|
|
38
|
+
/** Category for grouping */
|
|
39
|
+
category: "accessibility" | "sans-serif" | "monospace" | "display";
|
|
40
|
+
}
|
|
41
|
+
export declare const FONT_PRESETS: FontPreset[];
|
|
42
|
+
export declare const DEFAULT_FONT = "lexend";
|
|
43
|
+
/**
|
|
44
|
+
* Get font family by ID
|
|
45
|
+
*/
|
|
46
|
+
export declare function getFontFamily(id: string): string;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared UI Presets
|
|
3
|
+
*
|
|
4
|
+
* Unified color and design presets used across the Grove ecosystem.
|
|
5
|
+
* This ensures consistency between Plant signup and Arbor admin panel.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Grove Nature Color Palette
|
|
9
|
+
*
|
|
10
|
+
* A curated set of colors inspired by nature, designed to feel
|
|
11
|
+
* warm and organic. These appear in the signup flow and settings panel.
|
|
12
|
+
*/
|
|
13
|
+
export const COLOR_PRESETS = [
|
|
14
|
+
// Greens - The Grove
|
|
15
|
+
{ name: "Grove Green", hex: "#16a34a", hsl: "142 76% 36%" },
|
|
16
|
+
{ name: "Meadow Green", hex: "#22c55e", hsl: "142 76% 45%" },
|
|
17
|
+
// Blues - Water
|
|
18
|
+
{ name: "Ocean Blue", hex: "#0284c7", hsl: "200 90% 40%" },
|
|
19
|
+
// Purples - Twilight
|
|
20
|
+
{ name: "Deep Plum", hex: "#581c87", hsl: "274 79% 32%" },
|
|
21
|
+
{ name: "Violet Purple", hex: "#8b5cf6", hsl: "271 76% 53%" },
|
|
22
|
+
{ name: "Lavender", hex: "#a78bfa", hsl: "271 50% 68%" },
|
|
23
|
+
// Pinks - Blossoms
|
|
24
|
+
{ name: "Cherry Blossom", hex: "#ec4899", hsl: "330 81% 60%" },
|
|
25
|
+
{ name: "Tulip Pink", hex: "#f9a8d4", hsl: "330 71% 79%" },
|
|
26
|
+
// Warm - Autumn & Ember
|
|
27
|
+
{ name: "Sunset Ember", hex: "#c2410c", hsl: "20 86% 42%" },
|
|
28
|
+
{ name: "Golden Amber", hex: "#d97706", hsl: "38 92% 50%" },
|
|
29
|
+
{ name: "Autumn Gold", hex: "#eab308", hsl: "43 96% 56%" },
|
|
30
|
+
// Red - Cardinal
|
|
31
|
+
{ name: "Cardinal Red", hex: "#dc2626", hsl: "0 75% 51%" },
|
|
32
|
+
];
|
|
33
|
+
/**
|
|
34
|
+
* Default accent color (Grove Green)
|
|
35
|
+
*/
|
|
36
|
+
export const DEFAULT_ACCENT_COLOR = "#16a34a";
|
|
37
|
+
export const FONT_PRESETS = [
|
|
38
|
+
// Accessibility fonts
|
|
39
|
+
{
|
|
40
|
+
id: "lexend",
|
|
41
|
+
name: "Lexend",
|
|
42
|
+
family: "'Lexend', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
|
|
43
|
+
description: "Modern accessibility font for reading fluency (default)",
|
|
44
|
+
category: "accessibility",
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
id: "atkinson",
|
|
48
|
+
name: "Atkinson Hyperlegible",
|
|
49
|
+
family: "'Atkinson Hyperlegible', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
|
|
50
|
+
description: "Accessibility font for low vision readers",
|
|
51
|
+
category: "accessibility",
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
id: "opendyslexic",
|
|
55
|
+
name: "OpenDyslexic",
|
|
56
|
+
family: "'OpenDyslexic', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
|
|
57
|
+
description: "Accessibility font for dyslexia",
|
|
58
|
+
category: "accessibility",
|
|
59
|
+
},
|
|
60
|
+
// Sans-serif
|
|
61
|
+
{
|
|
62
|
+
id: "quicksand",
|
|
63
|
+
name: "Quicksand",
|
|
64
|
+
family: "'Quicksand', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
|
|
65
|
+
description: "Rounded, friendly geometric sans-serif",
|
|
66
|
+
category: "sans-serif",
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
id: "plus-jakarta-sans",
|
|
70
|
+
name: "Plus Jakarta Sans",
|
|
71
|
+
family: "'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
|
|
72
|
+
description: "Contemporary geometric sans, balanced and versatile",
|
|
73
|
+
category: "sans-serif",
|
|
74
|
+
},
|
|
75
|
+
// Monospace
|
|
76
|
+
{
|
|
77
|
+
id: "ibm-plex-mono",
|
|
78
|
+
name: "IBM Plex Mono",
|
|
79
|
+
family: "'IBM Plex Mono', 'Courier New', Consolas, monospace",
|
|
80
|
+
description: "Clean, highly readable code font",
|
|
81
|
+
category: "monospace",
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
id: "cozette",
|
|
85
|
+
name: "Cozette",
|
|
86
|
+
family: "'Cozette', 'Courier New', Consolas, monospace",
|
|
87
|
+
description: "Bitmap-style programming font, retro aesthetic",
|
|
88
|
+
category: "monospace",
|
|
89
|
+
},
|
|
90
|
+
// Display/Special
|
|
91
|
+
{
|
|
92
|
+
id: "alagard",
|
|
93
|
+
name: "Alagard",
|
|
94
|
+
family: "'Alagard', fantasy, cursive",
|
|
95
|
+
description: "Medieval pixel font for fantasy vibes",
|
|
96
|
+
category: "display",
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
id: "calistoga",
|
|
100
|
+
name: "Calistoga",
|
|
101
|
+
family: "'Calistoga', Georgia, serif",
|
|
102
|
+
description: "Casual brush serif, warm and friendly",
|
|
103
|
+
category: "display",
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
id: "caveat",
|
|
107
|
+
name: "Caveat",
|
|
108
|
+
family: "'Caveat', cursive, sans-serif",
|
|
109
|
+
description: "Handwritten script, personal and informal",
|
|
110
|
+
category: "display",
|
|
111
|
+
},
|
|
112
|
+
];
|
|
113
|
+
export const DEFAULT_FONT = "lexend";
|
|
114
|
+
/**
|
|
115
|
+
* Get font family by ID
|
|
116
|
+
*/
|
|
117
|
+
export function getFontFamily(id) {
|
|
118
|
+
const font = FONT_PRESETS.find((f) => f.id === id);
|
|
119
|
+
return font?.family ?? FONT_PRESETS[0].family;
|
|
120
|
+
}
|
package/dist/config/tiers.d.ts
CHANGED
|
@@ -78,6 +78,16 @@ export interface TierConfig {
|
|
|
78
78
|
support: TierSupport;
|
|
79
79
|
}
|
|
80
80
|
export declare const TIERS: Record<TierKey, TierConfig>;
|
|
81
|
+
/**
|
|
82
|
+
* Default tier used when tier is unknown, invalid, or not yet determined.
|
|
83
|
+
* Used as a safe fallback throughout the codebase to ensure consistent behavior.
|
|
84
|
+
*
|
|
85
|
+
* Seedling is the default because:
|
|
86
|
+
* - It's the entry-level paid tier with reasonable limits
|
|
87
|
+
* - Free tier doesn't have blog access, so can't be a safe default
|
|
88
|
+
* - It provides a good baseline without being overly permissive
|
|
89
|
+
*/
|
|
90
|
+
export declare const DEFAULT_TIER: TierKey;
|
|
81
91
|
export declare const TIER_ORDER: TierKey[];
|
|
82
92
|
export declare const PAID_TIERS: PaidTierKey[];
|
|
83
93
|
/**
|
package/dist/config/tiers.js
CHANGED
|
@@ -292,6 +292,19 @@ export const TIERS = {
|
|
|
292
292
|
},
|
|
293
293
|
};
|
|
294
294
|
// =============================================================================
|
|
295
|
+
// CONSTANTS
|
|
296
|
+
// =============================================================================
|
|
297
|
+
/**
|
|
298
|
+
* Default tier used when tier is unknown, invalid, or not yet determined.
|
|
299
|
+
* Used as a safe fallback throughout the codebase to ensure consistent behavior.
|
|
300
|
+
*
|
|
301
|
+
* Seedling is the default because:
|
|
302
|
+
* - It's the entry-level paid tier with reasonable limits
|
|
303
|
+
* - Free tier doesn't have blog access, so can't be a safe default
|
|
304
|
+
* - It provides a good baseline without being overly permissive
|
|
305
|
+
*/
|
|
306
|
+
export const DEFAULT_TIER = "seedling";
|
|
307
|
+
// =============================================================================
|
|
295
308
|
// HELPER ARRAYS
|
|
296
309
|
// =============================================================================
|
|
297
310
|
export const TIER_ORDER = [
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PostContentDO - Per-Post Durable Object for Content Caching
|
|
3
|
+
*
|
|
4
|
+
* Handles static content that rarely changes:
|
|
5
|
+
* - Cached rendered HTML
|
|
6
|
+
* - Markdown source
|
|
7
|
+
* - Post metadata (title, description, tags)
|
|
8
|
+
*
|
|
9
|
+
* This DO hibernates quickly since content doesn't change often.
|
|
10
|
+
* Separated from PostMetaDO for cost efficiency.
|
|
11
|
+
*
|
|
12
|
+
* ID Pattern: content:{tenantId}:{slug}
|
|
13
|
+
*
|
|
14
|
+
* Part of the Loom pattern - Grove's coordination layer.
|
|
15
|
+
*/
|
|
16
|
+
export interface PostContent {
|
|
17
|
+
tenantId: string;
|
|
18
|
+
slug: string;
|
|
19
|
+
title: string;
|
|
20
|
+
description: string;
|
|
21
|
+
tags: string[];
|
|
22
|
+
markdownContent: string;
|
|
23
|
+
htmlContent: string;
|
|
24
|
+
gutterContent: string;
|
|
25
|
+
font: string;
|
|
26
|
+
publishedAt: number | null;
|
|
27
|
+
updatedAt: number;
|
|
28
|
+
storageLocation: "hot" | "warm" | "cold";
|
|
29
|
+
r2Key?: string;
|
|
30
|
+
}
|
|
31
|
+
export interface ContentUpdate {
|
|
32
|
+
title?: string;
|
|
33
|
+
description?: string;
|
|
34
|
+
tags?: string[];
|
|
35
|
+
markdownContent?: string;
|
|
36
|
+
htmlContent?: string;
|
|
37
|
+
gutterContent?: string;
|
|
38
|
+
font?: string;
|
|
39
|
+
}
|
|
40
|
+
export declare class PostContentDO implements DurableObject {
|
|
41
|
+
private state;
|
|
42
|
+
private env;
|
|
43
|
+
private content;
|
|
44
|
+
private initialized;
|
|
45
|
+
constructor(state: DurableObjectState, env: Env);
|
|
46
|
+
private initializeStorage;
|
|
47
|
+
fetch(request: Request): Promise<Response>;
|
|
48
|
+
private handleGetContent;
|
|
49
|
+
private handleSetContent;
|
|
50
|
+
private handleUpdateContent;
|
|
51
|
+
private handleGetHtml;
|
|
52
|
+
private handleInvalidate;
|
|
53
|
+
private handleMigrateToCold;
|
|
54
|
+
private fetchFromR2;
|
|
55
|
+
private persistContent;
|
|
56
|
+
}
|
|
57
|
+
interface Env {
|
|
58
|
+
DB: D1Database;
|
|
59
|
+
CACHE_KV: KVNamespace;
|
|
60
|
+
IMAGES: R2Bucket;
|
|
61
|
+
}
|
|
62
|
+
export {};
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/// <reference types="@cloudflare/workers-types" />
|
|
2
|
+
// ============================================================================
|
|
3
|
+
// PostContentDO Class
|
|
4
|
+
// ============================================================================
|
|
5
|
+
export class PostContentDO {
|
|
6
|
+
state;
|
|
7
|
+
env;
|
|
8
|
+
content = null;
|
|
9
|
+
initialized = false;
|
|
10
|
+
constructor(state, env) {
|
|
11
|
+
this.state = state;
|
|
12
|
+
this.env = env;
|
|
13
|
+
this.state.blockConcurrencyWhile(async () => {
|
|
14
|
+
await this.initializeStorage();
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
async initializeStorage() {
|
|
18
|
+
if (this.initialized)
|
|
19
|
+
return;
|
|
20
|
+
await this.state.storage.sql.exec(`
|
|
21
|
+
CREATE TABLE IF NOT EXISTS content (
|
|
22
|
+
key TEXT PRIMARY KEY,
|
|
23
|
+
value TEXT NOT NULL,
|
|
24
|
+
updated_at INTEGER NOT NULL
|
|
25
|
+
);
|
|
26
|
+
`);
|
|
27
|
+
const stored = this.state.storage.sql
|
|
28
|
+
.exec("SELECT value FROM content WHERE key = 'post_content'")
|
|
29
|
+
.one();
|
|
30
|
+
if (stored?.value) {
|
|
31
|
+
this.content = JSON.parse(stored.value);
|
|
32
|
+
}
|
|
33
|
+
this.initialized = true;
|
|
34
|
+
}
|
|
35
|
+
async fetch(request) {
|
|
36
|
+
const url = new URL(request.url);
|
|
37
|
+
const path = url.pathname;
|
|
38
|
+
try {
|
|
39
|
+
if (path === "/content" && request.method === "GET") {
|
|
40
|
+
return this.handleGetContent();
|
|
41
|
+
}
|
|
42
|
+
if (path === "/content" && request.method === "PUT") {
|
|
43
|
+
return this.handleSetContent(request);
|
|
44
|
+
}
|
|
45
|
+
if (path === "/content" && request.method === "PATCH") {
|
|
46
|
+
return this.handleUpdateContent(request);
|
|
47
|
+
}
|
|
48
|
+
if (path === "/content/html" && request.method === "GET") {
|
|
49
|
+
return this.handleGetHtml();
|
|
50
|
+
}
|
|
51
|
+
if (path === "/content/invalidate" && request.method === "POST") {
|
|
52
|
+
return this.handleInvalidate();
|
|
53
|
+
}
|
|
54
|
+
if (path === "/content/migrate" && request.method === "POST") {
|
|
55
|
+
return this.handleMigrateToCold(request);
|
|
56
|
+
}
|
|
57
|
+
return new Response("Not found", { status: 404 });
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
console.error("[PostContentDO] Error:", err);
|
|
61
|
+
return new Response(JSON.stringify({
|
|
62
|
+
error: err instanceof Error ? err.message : "Internal error",
|
|
63
|
+
}), { status: 500, headers: { "Content-Type": "application/json" } });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
async handleGetContent() {
|
|
67
|
+
if (!this.content) {
|
|
68
|
+
return new Response("Content not found", { status: 404 });
|
|
69
|
+
}
|
|
70
|
+
if (this.content.storageLocation === "cold" && this.content.r2Key) {
|
|
71
|
+
const r2Content = await this.fetchFromR2(this.content.r2Key);
|
|
72
|
+
if (r2Content) {
|
|
73
|
+
return Response.json({ ...this.content, ...r2Content });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return Response.json(this.content);
|
|
77
|
+
}
|
|
78
|
+
async handleSetContent(request) {
|
|
79
|
+
const data = (await request.json());
|
|
80
|
+
if (!data.tenantId || !data.slug || !data.title) {
|
|
81
|
+
return new Response("Missing required fields", { status: 400 });
|
|
82
|
+
}
|
|
83
|
+
this.content = {
|
|
84
|
+
tenantId: data.tenantId,
|
|
85
|
+
slug: data.slug,
|
|
86
|
+
title: data.title,
|
|
87
|
+
description: data.description || "",
|
|
88
|
+
tags: data.tags || [],
|
|
89
|
+
markdownContent: data.markdownContent || "",
|
|
90
|
+
htmlContent: data.htmlContent || "",
|
|
91
|
+
gutterContent: data.gutterContent || "[]",
|
|
92
|
+
font: data.font || "default",
|
|
93
|
+
publishedAt: data.publishedAt || null,
|
|
94
|
+
updatedAt: Date.now(),
|
|
95
|
+
storageLocation: "hot",
|
|
96
|
+
};
|
|
97
|
+
await this.persistContent();
|
|
98
|
+
return Response.json({ success: true, content: this.content });
|
|
99
|
+
}
|
|
100
|
+
async handleUpdateContent(request) {
|
|
101
|
+
if (!this.content) {
|
|
102
|
+
return new Response("Content not found", { status: 404 });
|
|
103
|
+
}
|
|
104
|
+
const updates = (await request.json());
|
|
105
|
+
if (updates.title !== undefined)
|
|
106
|
+
this.content.title = updates.title;
|
|
107
|
+
if (updates.description !== undefined)
|
|
108
|
+
this.content.description = updates.description;
|
|
109
|
+
if (updates.tags !== undefined)
|
|
110
|
+
this.content.tags = updates.tags;
|
|
111
|
+
if (updates.markdownContent !== undefined)
|
|
112
|
+
this.content.markdownContent = updates.markdownContent;
|
|
113
|
+
if (updates.htmlContent !== undefined)
|
|
114
|
+
this.content.htmlContent = updates.htmlContent;
|
|
115
|
+
if (updates.gutterContent !== undefined)
|
|
116
|
+
this.content.gutterContent = updates.gutterContent;
|
|
117
|
+
if (updates.font !== undefined)
|
|
118
|
+
this.content.font = updates.font;
|
|
119
|
+
this.content.updatedAt = Date.now();
|
|
120
|
+
await this.persistContent();
|
|
121
|
+
return Response.json({ success: true, content: this.content });
|
|
122
|
+
}
|
|
123
|
+
async handleGetHtml() {
|
|
124
|
+
if (!this.content) {
|
|
125
|
+
return new Response("Content not found", { status: 404 });
|
|
126
|
+
}
|
|
127
|
+
if (this.content.storageLocation === "cold" && this.content.r2Key) {
|
|
128
|
+
const r2Content = await this.fetchFromR2(this.content.r2Key);
|
|
129
|
+
if (r2Content?.htmlContent) {
|
|
130
|
+
return new Response(r2Content.htmlContent, {
|
|
131
|
+
headers: { "Content-Type": "text/html" },
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return new Response(this.content.htmlContent, {
|
|
136
|
+
headers: { "Content-Type": "text/html" },
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
async handleInvalidate() {
|
|
140
|
+
this.content = null;
|
|
141
|
+
await this.state.storage.sql.exec("DELETE FROM content WHERE key = 'post_content'");
|
|
142
|
+
return Response.json({ success: true, message: "Content invalidated" });
|
|
143
|
+
}
|
|
144
|
+
async handleMigrateToCold(request) {
|
|
145
|
+
if (!this.content) {
|
|
146
|
+
return new Response("Content not found", { status: 404 });
|
|
147
|
+
}
|
|
148
|
+
const data = (await request.json());
|
|
149
|
+
if (!data.r2Key) {
|
|
150
|
+
return new Response("R2 key required", { status: 400 });
|
|
151
|
+
}
|
|
152
|
+
const r2 = this.env.IMAGES;
|
|
153
|
+
if (!r2) {
|
|
154
|
+
return new Response("R2 not configured", { status: 500 });
|
|
155
|
+
}
|
|
156
|
+
// Prepare content payload before modifying state
|
|
157
|
+
const contentPayload = JSON.stringify({
|
|
158
|
+
markdownContent: this.content.markdownContent,
|
|
159
|
+
htmlContent: this.content.htmlContent,
|
|
160
|
+
gutterContent: this.content.gutterContent,
|
|
161
|
+
});
|
|
162
|
+
// Upload to R2 with retry logic
|
|
163
|
+
const maxRetries = 3;
|
|
164
|
+
let lastError = null;
|
|
165
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
166
|
+
try {
|
|
167
|
+
await r2.put(data.r2Key, contentPayload, {
|
|
168
|
+
httpMetadata: { contentType: "application/json" },
|
|
169
|
+
});
|
|
170
|
+
// Verify upload succeeded by checking object exists
|
|
171
|
+
const verification = await r2.head(data.r2Key);
|
|
172
|
+
if (!verification) {
|
|
173
|
+
throw new Error("R2 upload verification failed - object not found");
|
|
174
|
+
}
|
|
175
|
+
// Atomic state update: prepare new state, persist, then update memory
|
|
176
|
+
// This prevents partial state if persistContent() fails
|
|
177
|
+
const updatedContent = {
|
|
178
|
+
...this.content,
|
|
179
|
+
markdownContent: "", // Clear after moving to R2
|
|
180
|
+
htmlContent: "",
|
|
181
|
+
gutterContent: "[]",
|
|
182
|
+
storageLocation: "cold",
|
|
183
|
+
r2Key: data.r2Key,
|
|
184
|
+
};
|
|
185
|
+
// Persist BEFORE modifying in-memory state (atomic operation)
|
|
186
|
+
await this.state.storage.sql.exec("INSERT OR REPLACE INTO content (key, value, updated_at) VALUES (?, ?, ?)", "post_content", JSON.stringify(updatedContent), Date.now());
|
|
187
|
+
// Only after successful persistence, update in-memory state
|
|
188
|
+
this.content = updatedContent;
|
|
189
|
+
return Response.json({
|
|
190
|
+
success: true,
|
|
191
|
+
message: "Migrated to cold storage",
|
|
192
|
+
r2Key: data.r2Key,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
catch (err) {
|
|
196
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
197
|
+
console.error(`[PostContentDO] R2 upload attempt ${attempt}/${maxRetries} failed:`, lastError.message);
|
|
198
|
+
if (attempt < maxRetries) {
|
|
199
|
+
// Exponential backoff: 100ms, 200ms, 400ms
|
|
200
|
+
await new Promise((resolve) => setTimeout(resolve, 100 * Math.pow(2, attempt - 1)));
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
// All retries failed - return error without modifying local state
|
|
205
|
+
return new Response(JSON.stringify({
|
|
206
|
+
error: "R2 migration failed after retries",
|
|
207
|
+
details: lastError?.message,
|
|
208
|
+
}), { status: 500, headers: { "Content-Type": "application/json" } });
|
|
209
|
+
}
|
|
210
|
+
async fetchFromR2(key) {
|
|
211
|
+
const r2 = this.env.IMAGES;
|
|
212
|
+
if (!r2)
|
|
213
|
+
return null;
|
|
214
|
+
try {
|
|
215
|
+
const object = await r2.get(key);
|
|
216
|
+
if (!object)
|
|
217
|
+
return null;
|
|
218
|
+
const text = await object.text();
|
|
219
|
+
return JSON.parse(text);
|
|
220
|
+
}
|
|
221
|
+
catch (err) {
|
|
222
|
+
console.error("[PostContentDO] R2 fetch error:", err);
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
async persistContent() {
|
|
227
|
+
if (!this.content)
|
|
228
|
+
return;
|
|
229
|
+
await this.state.storage.sql.exec("INSERT OR REPLACE INTO content (key, value, updated_at) VALUES (?, ?, ?)", "post_content", JSON.stringify(this.content), Date.now());
|
|
230
|
+
}
|
|
231
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PostMetaDO - Per-Post Durable Object for Hot Data
|
|
3
|
+
*
|
|
4
|
+
* Handles data that changes frequently and benefits from staying awake:
|
|
5
|
+
* - Reaction counts (likes, bookmarks)
|
|
6
|
+
* - View counts and analytics
|
|
7
|
+
* - Real-time presence (who's reading)
|
|
8
|
+
* - WebSocket connections for live updates
|
|
9
|
+
*
|
|
10
|
+
* ID Pattern: post:{tenantId}:{slug}
|
|
11
|
+
*
|
|
12
|
+
* Part of the Loom pattern - Grove's coordination layer.
|
|
13
|
+
* Split from PostContentDO for optimal hibernation behavior.
|
|
14
|
+
*/
|
|
15
|
+
import { type TierKey } from "../config/tiers.js";
|
|
16
|
+
export interface PostMeta {
|
|
17
|
+
tenantId: string;
|
|
18
|
+
slug: string;
|
|
19
|
+
tier?: TierKey;
|
|
20
|
+
viewCount: number;
|
|
21
|
+
reactions: ReactionCounts;
|
|
22
|
+
lastViewed: number;
|
|
23
|
+
isPopular: boolean;
|
|
24
|
+
}
|
|
25
|
+
export interface ReactionCounts {
|
|
26
|
+
likes: number;
|
|
27
|
+
bookmarks: number;
|
|
28
|
+
}
|
|
29
|
+
export interface ReactionEvent {
|
|
30
|
+
type: "like" | "bookmark";
|
|
31
|
+
action: "add" | "remove";
|
|
32
|
+
userId?: string;
|
|
33
|
+
timestamp: number;
|
|
34
|
+
}
|
|
35
|
+
export interface PresenceInfo {
|
|
36
|
+
activeReaders: number;
|
|
37
|
+
lastActivity: number;
|
|
38
|
+
}
|
|
39
|
+
export declare class PostMetaDO implements DurableObject {
|
|
40
|
+
private state;
|
|
41
|
+
private env;
|
|
42
|
+
private meta;
|
|
43
|
+
private presence;
|
|
44
|
+
private connections;
|
|
45
|
+
private initialized;
|
|
46
|
+
private isDirty;
|
|
47
|
+
private lastPersist;
|
|
48
|
+
private viewInsertsSinceCheck;
|
|
49
|
+
constructor(state: DurableObjectState, env: Env);
|
|
50
|
+
private initializeStorage;
|
|
51
|
+
fetch(request: Request): Promise<Response>;
|
|
52
|
+
private handleGetMeta;
|
|
53
|
+
private handleInitMeta;
|
|
54
|
+
private handleRecordView;
|
|
55
|
+
private handleGetReactions;
|
|
56
|
+
private handleAddReaction;
|
|
57
|
+
private handleRemoveReaction;
|
|
58
|
+
private handleGetPresence;
|
|
59
|
+
/**
|
|
60
|
+
* Handle WebSocket connection for real-time presence updates.
|
|
61
|
+
*
|
|
62
|
+
* SECURITY NOTE: Anonymous WebSocket access is intentional.
|
|
63
|
+
* This endpoint provides read-only presence data ("N people reading")
|
|
64
|
+
* which is public information suitable for any blog visitor. The data
|
|
65
|
+
* exposed (reader count, reaction counts) is already visible on the
|
|
66
|
+
* public blog page. No authentication is required because:
|
|
67
|
+
* - No sensitive data is transmitted
|
|
68
|
+
* - No state-changing operations are available via WebSocket
|
|
69
|
+
* - Presence tracking uses anonymous session IDs, not user identities
|
|
70
|
+
*
|
|
71
|
+
* If private analytics are needed in the future, that would go through
|
|
72
|
+
* authenticated API endpoints, not this WebSocket.
|
|
73
|
+
*/
|
|
74
|
+
private handleWebSocket;
|
|
75
|
+
webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void>;
|
|
76
|
+
webSocketClose(ws: WebSocket): Promise<void>;
|
|
77
|
+
private broadcast;
|
|
78
|
+
private persistMeta;
|
|
79
|
+
private updatePopularStatus;
|
|
80
|
+
private calculateDailyViews;
|
|
81
|
+
/**
|
|
82
|
+
* Ensure an alarm is scheduled (with deduplication).
|
|
83
|
+
* Prevents redundant setAlarm() calls which waste resources.
|
|
84
|
+
*/
|
|
85
|
+
private ensureAlarmScheduled;
|
|
86
|
+
/**
|
|
87
|
+
* Trim view_log if it exceeds MAX_VIEW_LOG_ROWS.
|
|
88
|
+
* Called both by alarm() (hourly) and inline during view recording (every 100 inserts).
|
|
89
|
+
* This dual approach prevents unbounded growth on viral posts.
|
|
90
|
+
*/
|
|
91
|
+
private trimViewLogIfNeeded;
|
|
92
|
+
alarm(): Promise<void>;
|
|
93
|
+
}
|
|
94
|
+
interface Env {
|
|
95
|
+
DB: D1Database;
|
|
96
|
+
CACHE_KV: KVNamespace;
|
|
97
|
+
}
|
|
98
|
+
export {};
|