@autumnsgrove/groveengine 0.9.90 → 0.9.91
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/curios/timeline/Timeline.svelte +21 -2
- package/dist/durable-objects/TenantDO.d.ts +7 -106
- package/dist/durable-objects/TenantDO.js +7 -423
- package/dist/durable-objects/index.d.ts +0 -1
- package/dist/durable-objects/index.js +0 -2
- package/dist/ui/vineyard/AuthButton.svelte +127 -0
- package/dist/ui/vineyard/AuthButton.svelte.d.ts +6 -0
- package/dist/ui/vineyard/CodeExample.svelte +186 -0
- package/dist/ui/vineyard/CodeExample.svelte.d.ts +8 -0
- package/dist/ui/vineyard/DemoContainer.svelte +139 -0
- package/dist/ui/vineyard/DemoContainer.svelte.d.ts +8 -0
- package/dist/ui/vineyard/FeatureCard.svelte +127 -0
- package/dist/ui/vineyard/FeatureCard.svelte.d.ts +8 -0
- package/dist/ui/vineyard/RoadmapSection.svelte +238 -0
- package/dist/ui/vineyard/RoadmapSection.svelte.d.ts +4 -0
- package/dist/ui/vineyard/StatusBadge.svelte +88 -0
- package/dist/ui/vineyard/StatusBadge.svelte.d.ts +4 -0
- package/dist/ui/vineyard/TierGate.svelte +172 -0
- package/dist/ui/vineyard/TierGate.svelte.d.ts +10 -0
- package/dist/ui/vineyard/UserMenu.svelte +289 -0
- package/dist/ui/vineyard/UserMenu.svelte.d.ts +6 -0
- package/dist/ui/vineyard/VineyardLayout.svelte +254 -0
- package/dist/ui/vineyard/VineyardLayout.svelte.d.ts +8 -0
- package/dist/ui/vineyard/auth.d.ts +81 -0
- package/dist/ui/vineyard/auth.js +134 -0
- package/dist/ui/vineyard/index.d.ts +14 -3
- package/dist/ui/vineyard/index.js +20 -3
- package/dist/ui/vineyard/types.d.ts +147 -0
- package/dist/ui/vineyard/types.js +5 -0
- package/package.json +1 -2
- package/static/favicon.svg +27 -16
|
@@ -441,11 +441,17 @@
|
|
|
441
441
|
font-weight: 600;
|
|
442
442
|
color: var(--color-foreground, #333);
|
|
443
443
|
}
|
|
444
|
+
:global(.dark) .date-full {
|
|
445
|
+
color: var(--bark, #f5f2ea);
|
|
446
|
+
}
|
|
444
447
|
.date-short {
|
|
445
448
|
display: none;
|
|
446
449
|
font-weight: 600;
|
|
447
450
|
color: var(--color-foreground, #333);
|
|
448
451
|
}
|
|
452
|
+
:global(.dark) .date-short {
|
|
453
|
+
color: var(--bark, #f5f2ea);
|
|
454
|
+
}
|
|
449
455
|
:global(.today-badge) {
|
|
450
456
|
background: var(--grove-500, #4ade80);
|
|
451
457
|
color: white;
|
|
@@ -518,13 +524,16 @@
|
|
|
518
524
|
font-size: 0.95rem;
|
|
519
525
|
}
|
|
520
526
|
:global(.dark) .rest-message {
|
|
521
|
-
color: #
|
|
527
|
+
color: var(--bark-600, #b69575);
|
|
522
528
|
}
|
|
523
529
|
.brief-summary {
|
|
524
530
|
margin: 0 0 0.75rem;
|
|
525
531
|
color: var(--color-foreground, #333);
|
|
526
532
|
line-height: 1.5;
|
|
527
533
|
}
|
|
534
|
+
:global(.dark) .brief-summary {
|
|
535
|
+
color: var(--bark, #f5f2ea);
|
|
536
|
+
}
|
|
528
537
|
.meta-info {
|
|
529
538
|
display: flex;
|
|
530
539
|
flex-wrap: wrap;
|
|
@@ -533,6 +542,9 @@
|
|
|
533
542
|
color: var(--color-foreground, #333);
|
|
534
543
|
margin-bottom: 0.75rem;
|
|
535
544
|
}
|
|
545
|
+
:global(.dark) .meta-info {
|
|
546
|
+
color: var(--bark-700, #ccb59c);
|
|
547
|
+
}
|
|
536
548
|
.repos, .changes {
|
|
537
549
|
display: flex;
|
|
538
550
|
align-items: center;
|
|
@@ -606,6 +618,7 @@
|
|
|
606
618
|
}
|
|
607
619
|
:global(.dark) .detailed-timeline {
|
|
608
620
|
background: var(--cream-300, #2a2a2a);
|
|
621
|
+
color: var(--bark, #f5f2ea);
|
|
609
622
|
}
|
|
610
623
|
.markdown-content :global(h2) {
|
|
611
624
|
font-size: 1.1rem;
|
|
@@ -624,6 +637,9 @@
|
|
|
624
637
|
margin: 1rem 0 0.5rem;
|
|
625
638
|
font-weight: 600;
|
|
626
639
|
}
|
|
640
|
+
:global(.dark) .markdown-content :global(h3) {
|
|
641
|
+
color: var(--bark, #f5f2ea);
|
|
642
|
+
}
|
|
627
643
|
.markdown-content :global(h3 a) {
|
|
628
644
|
color: #2c5f2d;
|
|
629
645
|
text-decoration: none;
|
|
@@ -646,6 +662,9 @@
|
|
|
646
662
|
margin-bottom: 0.25rem;
|
|
647
663
|
color: var(--color-foreground, #333);
|
|
648
664
|
}
|
|
665
|
+
:global(.dark) .markdown-content :global(li) {
|
|
666
|
+
color: var(--bark, #f5f2ea);
|
|
667
|
+
}
|
|
649
668
|
.markdown-content :global(code) {
|
|
650
669
|
background: var(--color-border-strong, #e0e0e0);
|
|
651
670
|
padding: 0.15rem 0.35rem;
|
|
@@ -680,7 +699,7 @@
|
|
|
680
699
|
}
|
|
681
700
|
:global(.dark) .timeline-footer {
|
|
682
701
|
border-top-color: var(--color-border-strong, #444);
|
|
683
|
-
color: #
|
|
702
|
+
color: var(--bark-700, #ccb59c);
|
|
684
703
|
}
|
|
685
704
|
|
|
686
705
|
/* Mobile Responsiveness */
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* TenantDO
|
|
2
|
+
* TenantDO Types
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* - Analytics event buffering
|
|
4
|
+
* Type definitions for the TenantDO Durable Object.
|
|
5
|
+
* The actual class implementation lives in packages/durable-objects/src/TenantDO.ts
|
|
6
|
+
* and is deployed as a separate Cloudflare Worker (grove-durable-objects).
|
|
8
7
|
*
|
|
9
|
-
*
|
|
8
|
+
* The engine references this DO via service binding (script_name in wrangler.toml),
|
|
9
|
+
* so only types are needed here.
|
|
10
10
|
*
|
|
11
11
|
* Part of the Loom pattern - Grove's coordination layer.
|
|
12
12
|
*/
|
|
13
|
-
import {
|
|
13
|
+
import type { PaidTierKey } from "../config/tiers.js";
|
|
14
14
|
export interface TenantConfig {
|
|
15
15
|
id: string;
|
|
16
16
|
subdomain: string;
|
|
@@ -42,102 +42,3 @@ export interface AnalyticsEvent {
|
|
|
42
42
|
data?: Record<string, unknown>;
|
|
43
43
|
timestamp: number;
|
|
44
44
|
}
|
|
45
|
-
export declare class TenantDO implements DurableObject {
|
|
46
|
-
private state;
|
|
47
|
-
private env;
|
|
48
|
-
private config;
|
|
49
|
-
private configLoadedAt;
|
|
50
|
-
private analyticsBuffer;
|
|
51
|
-
private initialized;
|
|
52
|
-
private refreshPromise;
|
|
53
|
-
private subdomain;
|
|
54
|
-
constructor(state: DurableObjectState, env: Env);
|
|
55
|
-
/**
|
|
56
|
-
* Initialize SQLite tables in DO storage
|
|
57
|
-
*/
|
|
58
|
-
private initializeStorage;
|
|
59
|
-
/**
|
|
60
|
-
* Main request handler - routes to appropriate method
|
|
61
|
-
*/
|
|
62
|
-
fetch(request: Request): Promise<Response>;
|
|
63
|
-
/**
|
|
64
|
-
* Get tenant config (cached in memory, refreshed from D1 if stale)
|
|
65
|
-
*
|
|
66
|
-
* Uses a promise lock pattern to prevent race conditions where multiple
|
|
67
|
-
* concurrent requests all trigger D1 queries simultaneously.
|
|
68
|
-
*/
|
|
69
|
-
private handleGetConfig;
|
|
70
|
-
/**
|
|
71
|
-
* Refresh config from DO storage or D1
|
|
72
|
-
*
|
|
73
|
-
* This method caches the tenant ID in the config to avoid repeated D1 lookups
|
|
74
|
-
* in hooks.server.ts. The ID is stored alongside other config data.
|
|
75
|
-
*/
|
|
76
|
-
private refreshConfig;
|
|
77
|
-
/**
|
|
78
|
-
* Update tenant config
|
|
79
|
-
*
|
|
80
|
-
* Uses the same promise lock pattern as handleGetConfig to prevent
|
|
81
|
-
* race conditions where concurrent updates could clobber each other.
|
|
82
|
-
*/
|
|
83
|
-
private handleUpdateConfig;
|
|
84
|
-
/**
|
|
85
|
-
* Get tier limits from centralized tiers.ts config
|
|
86
|
-
*
|
|
87
|
-
* This ensures TenantDO limits match the single source of truth,
|
|
88
|
-
* preventing drift between DO cached limits and actual tier configuration.
|
|
89
|
-
*/
|
|
90
|
-
private getTierLimits;
|
|
91
|
-
/**
|
|
92
|
-
* List all drafts for this tenant
|
|
93
|
-
*/
|
|
94
|
-
private handleListDrafts;
|
|
95
|
-
/**
|
|
96
|
-
* Get a specific draft
|
|
97
|
-
*/
|
|
98
|
-
private handleGetDraft;
|
|
99
|
-
/**
|
|
100
|
-
* Save or update a draft
|
|
101
|
-
*/
|
|
102
|
-
private handleSaveDraft;
|
|
103
|
-
/**
|
|
104
|
-
* Delete a draft
|
|
105
|
-
*/
|
|
106
|
-
private handleDeleteDraft;
|
|
107
|
-
/**
|
|
108
|
-
* Record an analytics event (buffered)
|
|
109
|
-
*/
|
|
110
|
-
private handleRecordEvent;
|
|
111
|
-
/**
|
|
112
|
-
* Alarm handler - flush analytics buffer
|
|
113
|
-
*/
|
|
114
|
-
alarm(): Promise<void>;
|
|
115
|
-
/**
|
|
116
|
-
* Flush analytics buffer to D1
|
|
117
|
-
*/
|
|
118
|
-
private flushAnalytics;
|
|
119
|
-
/**
|
|
120
|
-
* Get the subdomain for this tenant DO
|
|
121
|
-
*
|
|
122
|
-
* Priority:
|
|
123
|
-
* 1. Subdomain passed via X-Tenant-Subdomain header (set on every request)
|
|
124
|
-
* 2. Cached subdomain from previous request
|
|
125
|
-
* 3. Subdomain from cached config
|
|
126
|
-
*
|
|
127
|
-
* Returns null if subdomain is not yet known (shouldn't happen in practice
|
|
128
|
-
* since hooks.server.ts always sends the header).
|
|
129
|
-
*/
|
|
130
|
-
private getSubdomain;
|
|
131
|
-
/**
|
|
132
|
-
* Get the tenant ID (UUID) for this tenant
|
|
133
|
-
*
|
|
134
|
-
* Returns the cached ID from config, or null if not yet loaded.
|
|
135
|
-
* The ID is fetched from D1 on first config load and cached.
|
|
136
|
-
*/
|
|
137
|
-
private getTenantId;
|
|
138
|
-
}
|
|
139
|
-
interface Env {
|
|
140
|
-
DB: D1Database;
|
|
141
|
-
CACHE_KV: KVNamespace;
|
|
142
|
-
}
|
|
143
|
-
export {};
|
|
@@ -1,429 +1,13 @@
|
|
|
1
|
-
/// <reference types="@cloudflare/workers-types" />
|
|
2
1
|
/**
|
|
3
|
-
* TenantDO
|
|
2
|
+
* TenantDO Types
|
|
4
3
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* - Analytics event buffering
|
|
4
|
+
* Type definitions for the TenantDO Durable Object.
|
|
5
|
+
* The actual class implementation lives in packages/durable-objects/src/TenantDO.ts
|
|
6
|
+
* and is deployed as a separate Cloudflare Worker (grove-durable-objects).
|
|
9
7
|
*
|
|
10
|
-
*
|
|
8
|
+
* The engine references this DO via service binding (script_name in wrangler.toml),
|
|
9
|
+
* so only types are needed here.
|
|
11
10
|
*
|
|
12
11
|
* Part of the Loom pattern - Grove's coordination layer.
|
|
13
12
|
*/
|
|
14
|
-
|
|
15
|
-
// ============================================================================
|
|
16
|
-
// Constants
|
|
17
|
-
// ============================================================================
|
|
18
|
-
/**
|
|
19
|
-
* Maximum analytics events to buffer before forcing a flush.
|
|
20
|
-
* Prevents memory leak if alarm mechanism fails.
|
|
21
|
-
*/
|
|
22
|
-
const MAX_ANALYTICS_BUFFER = 1000;
|
|
23
|
-
// ============================================================================
|
|
24
|
-
// TenantDO Class
|
|
25
|
-
// ============================================================================
|
|
26
|
-
export class TenantDO {
|
|
27
|
-
state;
|
|
28
|
-
env;
|
|
29
|
-
// In-memory caches (faster than storage for hot data)
|
|
30
|
-
config = null;
|
|
31
|
-
configLoadedAt = 0;
|
|
32
|
-
analyticsBuffer = [];
|
|
33
|
-
initialized = false;
|
|
34
|
-
// Race condition prevention: only one refresh at a time
|
|
35
|
-
refreshPromise = null;
|
|
36
|
-
// Subdomain extracted from DO name (set on first request)
|
|
37
|
-
subdomain = null;
|
|
38
|
-
constructor(state, env) {
|
|
39
|
-
this.state = state;
|
|
40
|
-
this.env = env;
|
|
41
|
-
// Block concurrent requests while initializing storage
|
|
42
|
-
this.state.blockConcurrencyWhile(async () => {
|
|
43
|
-
await this.initializeStorage();
|
|
44
|
-
});
|
|
45
|
-
}
|
|
46
|
-
/**
|
|
47
|
-
* Initialize SQLite tables in DO storage
|
|
48
|
-
*/
|
|
49
|
-
async initializeStorage() {
|
|
50
|
-
if (this.initialized)
|
|
51
|
-
return;
|
|
52
|
-
await this.state.storage.sql.exec(`
|
|
53
|
-
CREATE TABLE IF NOT EXISTS config (
|
|
54
|
-
key TEXT PRIMARY KEY,
|
|
55
|
-
value TEXT NOT NULL,
|
|
56
|
-
updated_at INTEGER NOT NULL
|
|
57
|
-
);
|
|
58
|
-
|
|
59
|
-
CREATE TABLE IF NOT EXISTS drafts (
|
|
60
|
-
slug TEXT PRIMARY KEY,
|
|
61
|
-
content TEXT NOT NULL,
|
|
62
|
-
metadata TEXT NOT NULL,
|
|
63
|
-
last_saved INTEGER NOT NULL,
|
|
64
|
-
device_id TEXT NOT NULL
|
|
65
|
-
);
|
|
66
|
-
|
|
67
|
-
CREATE TABLE IF NOT EXISTS analytics_buffer (
|
|
68
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
69
|
-
event_type TEXT NOT NULL,
|
|
70
|
-
event_data TEXT,
|
|
71
|
-
timestamp INTEGER NOT NULL
|
|
72
|
-
);
|
|
73
|
-
`);
|
|
74
|
-
this.initialized = true;
|
|
75
|
-
}
|
|
76
|
-
/**
|
|
77
|
-
* Main request handler - routes to appropriate method
|
|
78
|
-
*/
|
|
79
|
-
async fetch(request) {
|
|
80
|
-
const url = new URL(request.url);
|
|
81
|
-
const path = url.pathname;
|
|
82
|
-
// Extract subdomain from request header (set by hooks.server.ts)
|
|
83
|
-
// This is passed on every request to ensure we always know our identity
|
|
84
|
-
const subdomainHeader = request.headers.get("X-Tenant-Subdomain");
|
|
85
|
-
if (subdomainHeader && !this.subdomain) {
|
|
86
|
-
this.subdomain = subdomainHeader;
|
|
87
|
-
}
|
|
88
|
-
try {
|
|
89
|
-
// Config endpoints
|
|
90
|
-
if (path === "/config" && request.method === "GET") {
|
|
91
|
-
return this.handleGetConfig();
|
|
92
|
-
}
|
|
93
|
-
if (path === "/config" && request.method === "PUT") {
|
|
94
|
-
return this.handleUpdateConfig(request);
|
|
95
|
-
}
|
|
96
|
-
// Draft endpoints
|
|
97
|
-
if (path === "/drafts" && request.method === "GET") {
|
|
98
|
-
return this.handleListDrafts();
|
|
99
|
-
}
|
|
100
|
-
if (path.startsWith("/drafts/") && request.method === "GET") {
|
|
101
|
-
const slug = path.split("/").pop();
|
|
102
|
-
return this.handleGetDraft(slug);
|
|
103
|
-
}
|
|
104
|
-
if (path.startsWith("/drafts/") && request.method === "PUT") {
|
|
105
|
-
const slug = path.split("/").pop();
|
|
106
|
-
return this.handleSaveDraft(slug, request);
|
|
107
|
-
}
|
|
108
|
-
if (path.startsWith("/drafts/") && request.method === "DELETE") {
|
|
109
|
-
const slug = path.split("/").pop();
|
|
110
|
-
return this.handleDeleteDraft(slug);
|
|
111
|
-
}
|
|
112
|
-
// Analytics endpoint
|
|
113
|
-
if (path === "/analytics" && request.method === "POST") {
|
|
114
|
-
return this.handleRecordEvent(request);
|
|
115
|
-
}
|
|
116
|
-
return new Response("Not found", { status: 404 });
|
|
117
|
-
}
|
|
118
|
-
catch (err) {
|
|
119
|
-
console.error("[TenantDO] Error:", err);
|
|
120
|
-
return new Response(JSON.stringify({
|
|
121
|
-
error: err instanceof Error ? err.message : "Internal error",
|
|
122
|
-
}), { status: 500, headers: { "Content-Type": "application/json" } });
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
// ============================================================================
|
|
126
|
-
// Config Methods
|
|
127
|
-
// ============================================================================
|
|
128
|
-
/**
|
|
129
|
-
* Get tenant config (cached in memory, refreshed from D1 if stale)
|
|
130
|
-
*
|
|
131
|
-
* Uses a promise lock pattern to prevent race conditions where multiple
|
|
132
|
-
* concurrent requests all trigger D1 queries simultaneously.
|
|
133
|
-
*/
|
|
134
|
-
async handleGetConfig() {
|
|
135
|
-
const STALE_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes
|
|
136
|
-
// Refresh if stale or not loaded (with race condition prevention)
|
|
137
|
-
if (!this.config || Date.now() - this.configLoadedAt > STALE_THRESHOLD_MS) {
|
|
138
|
-
// Only start one refresh at a time - other requests wait for it
|
|
139
|
-
if (!this.refreshPromise) {
|
|
140
|
-
this.refreshPromise = this.refreshConfig().finally(() => {
|
|
141
|
-
this.refreshPromise = null;
|
|
142
|
-
});
|
|
143
|
-
}
|
|
144
|
-
await this.refreshPromise;
|
|
145
|
-
}
|
|
146
|
-
if (!this.config) {
|
|
147
|
-
return new Response("Tenant not found", { status: 404 });
|
|
148
|
-
}
|
|
149
|
-
return Response.json(this.config);
|
|
150
|
-
}
|
|
151
|
-
/**
|
|
152
|
-
* Refresh config from DO storage or D1
|
|
153
|
-
*
|
|
154
|
-
* This method caches the tenant ID in the config to avoid repeated D1 lookups
|
|
155
|
-
* in hooks.server.ts. The ID is stored alongside other config data.
|
|
156
|
-
*/
|
|
157
|
-
async refreshConfig() {
|
|
158
|
-
// Try DO storage first (fastest)
|
|
159
|
-
const stored = this.state.storage.sql
|
|
160
|
-
.exec("SELECT value FROM config WHERE key = 'tenant_config'")
|
|
161
|
-
.one();
|
|
162
|
-
if (stored?.value) {
|
|
163
|
-
try {
|
|
164
|
-
this.config = JSON.parse(stored.value);
|
|
165
|
-
// Also set subdomain from cached config if we don't have it
|
|
166
|
-
if (this.config?.subdomain && !this.subdomain) {
|
|
167
|
-
this.subdomain = this.config.subdomain;
|
|
168
|
-
}
|
|
169
|
-
this.configLoadedAt = Date.now();
|
|
170
|
-
return;
|
|
171
|
-
}
|
|
172
|
-
catch (err) {
|
|
173
|
-
// Corrupted cache - clear it and fall through to D1
|
|
174
|
-
console.warn("[TenantDO] Failed to parse cached config, clearing:", err instanceof Error ? err.message : err);
|
|
175
|
-
await this.state.storage.sql.exec("DELETE FROM config WHERE key = 'tenant_config'");
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
// Fall back to D1 - need subdomain to query
|
|
179
|
-
const subdomain = this.getSubdomain();
|
|
180
|
-
if (!subdomain) {
|
|
181
|
-
console.error("[TenantDO] Cannot refresh config: no subdomain available");
|
|
182
|
-
return;
|
|
183
|
-
}
|
|
184
|
-
// Query D1 for full tenant data including ID
|
|
185
|
-
const row = await this.env.DB.prepare(`
|
|
186
|
-
SELECT id, subdomain, name as displayName, theme, plan as tier, owner_id as ownerId
|
|
187
|
-
FROM tenants
|
|
188
|
-
WHERE subdomain = ? AND active = 1
|
|
189
|
-
`)
|
|
190
|
-
.bind(subdomain)
|
|
191
|
-
.first();
|
|
192
|
-
if (row) {
|
|
193
|
-
// Build config with tier limits from centralized tiers.ts
|
|
194
|
-
const tier = row.tier || "seedling";
|
|
195
|
-
// Safely parse theme JSON (corrupted data shouldn't crash the DO)
|
|
196
|
-
let theme = null;
|
|
197
|
-
if (row.theme) {
|
|
198
|
-
try {
|
|
199
|
-
theme = JSON.parse(row.theme);
|
|
200
|
-
}
|
|
201
|
-
catch (err) {
|
|
202
|
-
console.warn(`[TenantDO] Failed to parse theme JSON for ${this.subdomain}:`, err instanceof Error ? err.message : err);
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
this.config = {
|
|
206
|
-
id: row.id, // Include tenant ID to eliminate hooks.server.ts D1 query
|
|
207
|
-
subdomain: row.subdomain,
|
|
208
|
-
displayName: row.displayName,
|
|
209
|
-
theme,
|
|
210
|
-
tier,
|
|
211
|
-
ownerId: row.ownerId,
|
|
212
|
-
limits: this.getTierLimits(tier),
|
|
213
|
-
};
|
|
214
|
-
// Cache in DO storage for next time
|
|
215
|
-
await this.state.storage.sql.exec("INSERT OR REPLACE INTO config (key, value, updated_at) VALUES (?, ?, ?)", "tenant_config", JSON.stringify(this.config), Date.now());
|
|
216
|
-
this.configLoadedAt = Date.now();
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
/**
|
|
220
|
-
* Update tenant config
|
|
221
|
-
*
|
|
222
|
-
* Uses the same promise lock pattern as handleGetConfig to prevent
|
|
223
|
-
* race conditions where concurrent updates could clobber each other.
|
|
224
|
-
*/
|
|
225
|
-
async handleUpdateConfig(request) {
|
|
226
|
-
const updates = (await request.json());
|
|
227
|
-
// Ensure config is loaded with race condition prevention
|
|
228
|
-
if (!this.config) {
|
|
229
|
-
if (!this.refreshPromise) {
|
|
230
|
-
this.refreshPromise = this.refreshConfig().finally(() => {
|
|
231
|
-
this.refreshPromise = null;
|
|
232
|
-
});
|
|
233
|
-
}
|
|
234
|
-
await this.refreshPromise;
|
|
235
|
-
}
|
|
236
|
-
if (!this.config) {
|
|
237
|
-
return new Response("Tenant not found", { status: 404 });
|
|
238
|
-
}
|
|
239
|
-
this.config = { ...this.config, ...updates };
|
|
240
|
-
// Update DO storage
|
|
241
|
-
await this.state.storage.sql.exec("INSERT OR REPLACE INTO config (key, value, updated_at) VALUES (?, ?, ?)", "tenant_config", JSON.stringify(this.config), Date.now());
|
|
242
|
-
// Update D1 (source of truth) using the subdomain
|
|
243
|
-
const subdomain = this.getSubdomain();
|
|
244
|
-
if (subdomain) {
|
|
245
|
-
await this.env.DB.prepare(`
|
|
246
|
-
UPDATE tenants
|
|
247
|
-
SET name = ?, theme = ?, updated_at = datetime('now')
|
|
248
|
-
WHERE subdomain = ?
|
|
249
|
-
`)
|
|
250
|
-
.bind(this.config.displayName, this.config.theme ? JSON.stringify(this.config.theme) : null, subdomain)
|
|
251
|
-
.run();
|
|
252
|
-
}
|
|
253
|
-
this.configLoadedAt = Date.now();
|
|
254
|
-
return Response.json({ success: true });
|
|
255
|
-
}
|
|
256
|
-
/**
|
|
257
|
-
* Get tier limits from centralized tiers.ts config
|
|
258
|
-
*
|
|
259
|
-
* This ensures TenantDO limits match the single source of truth,
|
|
260
|
-
* preventing drift between DO cached limits and actual tier configuration.
|
|
261
|
-
*/
|
|
262
|
-
getTierLimits(tier) {
|
|
263
|
-
// Map TenantConfig tier to TierKey (they're compatible)
|
|
264
|
-
const tierConfig = TIERS[tier] ?? TIERS.seedling;
|
|
265
|
-
return {
|
|
266
|
-
// Convert Infinity to -1 for JSON serialization (Infinity isn't valid JSON)
|
|
267
|
-
postsPerMonth: tierConfig.limits.posts === Infinity ? -1 : tierConfig.limits.posts,
|
|
268
|
-
storageBytes: tierConfig.limits.storage,
|
|
269
|
-
// Custom domains based on tier features
|
|
270
|
-
customDomains: tierConfig.features.customDomain
|
|
271
|
-
? tier === "evergreen"
|
|
272
|
-
? 10
|
|
273
|
-
: tier === "oak"
|
|
274
|
-
? 3
|
|
275
|
-
: 1
|
|
276
|
-
: 0,
|
|
277
|
-
};
|
|
278
|
-
}
|
|
279
|
-
// ============================================================================
|
|
280
|
-
// Draft Methods
|
|
281
|
-
// ============================================================================
|
|
282
|
-
/**
|
|
283
|
-
* List all drafts for this tenant
|
|
284
|
-
*/
|
|
285
|
-
async handleListDrafts() {
|
|
286
|
-
const rows = this.state.storage.sql
|
|
287
|
-
.exec("SELECT slug, metadata, last_saved, device_id FROM drafts ORDER BY last_saved DESC")
|
|
288
|
-
.toArray();
|
|
289
|
-
const drafts = rows.map((row) => {
|
|
290
|
-
let metadata = { title: "Untitled" };
|
|
291
|
-
try {
|
|
292
|
-
metadata = JSON.parse(row.metadata);
|
|
293
|
-
}
|
|
294
|
-
catch (err) {
|
|
295
|
-
console.warn(`[TenantDO] Failed to parse draft metadata for ${row.slug}:`, err instanceof Error ? err.message : err);
|
|
296
|
-
}
|
|
297
|
-
return {
|
|
298
|
-
slug: row.slug,
|
|
299
|
-
metadata,
|
|
300
|
-
lastSaved: row.last_saved,
|
|
301
|
-
deviceId: row.device_id,
|
|
302
|
-
};
|
|
303
|
-
});
|
|
304
|
-
return Response.json(drafts);
|
|
305
|
-
}
|
|
306
|
-
/**
|
|
307
|
-
* Get a specific draft
|
|
308
|
-
*/
|
|
309
|
-
async handleGetDraft(slug) {
|
|
310
|
-
const row = this.state.storage.sql
|
|
311
|
-
.exec("SELECT * FROM drafts WHERE slug = ?", slug)
|
|
312
|
-
.one();
|
|
313
|
-
if (!row) {
|
|
314
|
-
return new Response("Draft not found", { status: 404 });
|
|
315
|
-
}
|
|
316
|
-
let metadata = { title: "Untitled" };
|
|
317
|
-
try {
|
|
318
|
-
metadata = JSON.parse(row.metadata);
|
|
319
|
-
}
|
|
320
|
-
catch (err) {
|
|
321
|
-
console.warn(`[TenantDO] Failed to parse draft metadata for ${slug}:`, err instanceof Error ? err.message : err);
|
|
322
|
-
}
|
|
323
|
-
return Response.json({
|
|
324
|
-
slug: row.slug,
|
|
325
|
-
content: row.content,
|
|
326
|
-
metadata,
|
|
327
|
-
lastSaved: row.last_saved,
|
|
328
|
-
deviceId: row.device_id,
|
|
329
|
-
});
|
|
330
|
-
}
|
|
331
|
-
/**
|
|
332
|
-
* Save or update a draft
|
|
333
|
-
*/
|
|
334
|
-
async handleSaveDraft(slug, request) {
|
|
335
|
-
const draft = (await request.json());
|
|
336
|
-
const now = Date.now();
|
|
337
|
-
await this.state.storage.sql.exec(`
|
|
338
|
-
INSERT OR REPLACE INTO drafts (slug, content, metadata, last_saved, device_id)
|
|
339
|
-
VALUES (?, ?, ?, ?, ?)
|
|
340
|
-
`, slug, draft.content, JSON.stringify(draft.metadata), now, draft.deviceId);
|
|
341
|
-
return Response.json({ success: true, lastSaved: now });
|
|
342
|
-
}
|
|
343
|
-
/**
|
|
344
|
-
* Delete a draft
|
|
345
|
-
*/
|
|
346
|
-
async handleDeleteDraft(slug) {
|
|
347
|
-
await this.state.storage.sql.exec("DELETE FROM drafts WHERE slug = ?", slug);
|
|
348
|
-
return Response.json({ success: true });
|
|
349
|
-
}
|
|
350
|
-
// ============================================================================
|
|
351
|
-
// Analytics Methods
|
|
352
|
-
// ============================================================================
|
|
353
|
-
/**
|
|
354
|
-
* Record an analytics event (buffered)
|
|
355
|
-
*/
|
|
356
|
-
async handleRecordEvent(request) {
|
|
357
|
-
const event = (await request.json());
|
|
358
|
-
// Add to memory buffer
|
|
359
|
-
this.analyticsBuffer.push({
|
|
360
|
-
...event,
|
|
361
|
-
timestamp: event.timestamp || Date.now(),
|
|
362
|
-
});
|
|
363
|
-
// Flush conditions:
|
|
364
|
-
// 1. Normal threshold (100): flush and let alarm reschedule
|
|
365
|
-
// 2. MAX_ANALYTICS_BUFFER: safety net if alarm mechanism fails
|
|
366
|
-
if (this.analyticsBuffer.length >= MAX_ANALYTICS_BUFFER) {
|
|
367
|
-
// Force flush - buffer is dangerously large
|
|
368
|
-
console.warn(`[TenantDO] Analytics buffer hit max (${MAX_ANALYTICS_BUFFER}), forcing flush`);
|
|
369
|
-
await this.flushAnalytics();
|
|
370
|
-
}
|
|
371
|
-
else if (this.analyticsBuffer.length >= 100) {
|
|
372
|
-
// Normal flush threshold
|
|
373
|
-
await this.flushAnalytics();
|
|
374
|
-
}
|
|
375
|
-
else {
|
|
376
|
-
// Schedule flush via alarm if not already set
|
|
377
|
-
const currentAlarm = await this.state.storage.getAlarm();
|
|
378
|
-
if (!currentAlarm) {
|
|
379
|
-
await this.state.storage.setAlarm(Date.now() + 60_000); // 1 minute
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
return Response.json({ success: true });
|
|
383
|
-
}
|
|
384
|
-
/**
|
|
385
|
-
* Alarm handler - flush analytics buffer
|
|
386
|
-
*/
|
|
387
|
-
async alarm() {
|
|
388
|
-
await this.flushAnalytics();
|
|
389
|
-
}
|
|
390
|
-
/**
|
|
391
|
-
* Flush analytics buffer to D1
|
|
392
|
-
*/
|
|
393
|
-
async flushAnalytics() {
|
|
394
|
-
if (this.analyticsBuffer.length === 0)
|
|
395
|
-
return;
|
|
396
|
-
const events = this.analyticsBuffer.splice(0, this.analyticsBuffer.length);
|
|
397
|
-
const subdomain = this.getSubdomain() || "unknown";
|
|
398
|
-
// For now, just log - analytics table implementation deferred to Rings
|
|
399
|
-
console.log(`[TenantDO] Flushing ${events.length} analytics events for ${subdomain}`);
|
|
400
|
-
// TODO: When Rings is implemented, batch insert to analytics table
|
|
401
|
-
// This will use the AnalyticsDO pattern from the Rings spec
|
|
402
|
-
}
|
|
403
|
-
// ============================================================================
|
|
404
|
-
// Utility Methods
|
|
405
|
-
// ============================================================================
|
|
406
|
-
/**
|
|
407
|
-
* Get the subdomain for this tenant DO
|
|
408
|
-
*
|
|
409
|
-
* Priority:
|
|
410
|
-
* 1. Subdomain passed via X-Tenant-Subdomain header (set on every request)
|
|
411
|
-
* 2. Cached subdomain from previous request
|
|
412
|
-
* 3. Subdomain from cached config
|
|
413
|
-
*
|
|
414
|
-
* Returns null if subdomain is not yet known (shouldn't happen in practice
|
|
415
|
-
* since hooks.server.ts always sends the header).
|
|
416
|
-
*/
|
|
417
|
-
getSubdomain() {
|
|
418
|
-
return this.subdomain || this.config?.subdomain || null;
|
|
419
|
-
}
|
|
420
|
-
/**
|
|
421
|
-
* Get the tenant ID (UUID) for this tenant
|
|
422
|
-
*
|
|
423
|
-
* Returns the cached ID from config, or null if not yet loaded.
|
|
424
|
-
* The ID is fetched from D1 on first config load and cached.
|
|
425
|
-
*/
|
|
426
|
-
getTenantId() {
|
|
427
|
-
return this.config?.id || null;
|
|
428
|
-
}
|
|
429
|
-
}
|
|
13
|
+
export {};
|
|
@@ -7,7 +7,6 @@
|
|
|
7
7
|
*
|
|
8
8
|
* Part of the Loom pattern - Grove's coordination layer.
|
|
9
9
|
*/
|
|
10
|
-
export { TenantDO } from "./TenantDO.js";
|
|
11
10
|
export type { TenantConfig, TierLimits, Draft, DraftMetadata, AnalyticsEvent, } from "./TenantDO.js";
|
|
12
11
|
export { PostMetaDO } from "./PostMetaDO.js";
|
|
13
12
|
export type { PostMeta, ReactionCounts, ReactionEvent, PresenceInfo, } from "./PostMetaDO.js";
|
|
@@ -7,8 +7,6 @@
|
|
|
7
7
|
*
|
|
8
8
|
* Part of the Loom pattern - Grove's coordination layer.
|
|
9
9
|
*/
|
|
10
|
-
// TenantDO - Per-tenant config, drafts, analytics
|
|
11
|
-
export { TenantDO } from "./TenantDO.js";
|
|
12
10
|
// PostMetaDO - Per-post reactions, views, presence (hot data)
|
|
13
11
|
export { PostMetaDO } from "./PostMetaDO.js";
|
|
14
12
|
// PostContentDO - Per-post content caching (warm data, hibernates)
|