create-pulsekit 0.0.2 → 0.1.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/README.md +43 -0
- package/dist/index.js +135 -11
- package/package.json +9 -7
package/README.md
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="https://raw.githubusercontent.com/benoiteom/pulse-analytics/main/assets/logo.svg" alt="PulseKit" width="200" />
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
# create-pulsekit
|
|
6
|
+
|
|
7
|
+
CLI scaffolding tool for setting up [PulseKit](https://github.com/benoiteom/pulse-analytics) analytics in a Next.js project.
|
|
8
|
+
|
|
9
|
+
## Usage
|
|
10
|
+
|
|
11
|
+
Run in the root of an existing Next.js project:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npx create-pulsekit
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## What It Does
|
|
18
|
+
|
|
19
|
+
1. **Detects** your package manager (npm, yarn, pnpm, bun)
|
|
20
|
+
2. **Validates** that you're in a Next.js project
|
|
21
|
+
3. **Installs** `@pulsekit/core`, `@pulsekit/next`, and `@pulsekit/react`
|
|
22
|
+
4. **Scaffolds** the analytics dashboard page and API routes
|
|
23
|
+
5. **Injects** the `<PulseTracker />` component into your root layout
|
|
24
|
+
6. **Injects** the error instrumentation for server-side error tracking
|
|
25
|
+
7. **Writes** the Supabase SQL migration file
|
|
26
|
+
|
|
27
|
+
## After Setup
|
|
28
|
+
|
|
29
|
+
1. Add your Supabase credentials to `.env.local`:
|
|
30
|
+
```
|
|
31
|
+
NEXT_PUBLIC_SUPABASE_URL=<your-supabase-url>
|
|
32
|
+
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=<your-anon-key>
|
|
33
|
+
```
|
|
34
|
+
2. Link and push the database migration:
|
|
35
|
+
```bash
|
|
36
|
+
npx supabase link
|
|
37
|
+
npx supabase db push
|
|
38
|
+
```
|
|
39
|
+
3. Start your dev server and visit `/admin/analytics`
|
|
40
|
+
|
|
41
|
+
## License
|
|
42
|
+
|
|
43
|
+
MIT
|
package/dist/index.js
CHANGED
|
@@ -75,7 +75,7 @@ var PACKAGES = [
|
|
|
75
75
|
"@pulsekit/core",
|
|
76
76
|
"@pulsekit/next",
|
|
77
77
|
"@pulsekit/react",
|
|
78
|
-
"@supabase/supabase-js"
|
|
78
|
+
"@supabase/supabase-js@latest"
|
|
79
79
|
];
|
|
80
80
|
async function installPackages(pm) {
|
|
81
81
|
console.log(" Installing packages...\n");
|
|
@@ -130,16 +130,29 @@ const supabase = createClient(
|
|
|
130
130
|
|
|
131
131
|
export const POST = createRefreshHandler({ supabase });
|
|
132
132
|
`;
|
|
133
|
-
const
|
|
133
|
+
const consolidateRoute = `import { createConsolidateHandler } from "@pulsekit/next";
|
|
134
|
+
import { createClient } from "@supabase/supabase-js";
|
|
135
|
+
|
|
136
|
+
const supabase = createClient(
|
|
137
|
+
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
138
|
+
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
export const POST = createConsolidateHandler({ supabase });
|
|
142
|
+
`;
|
|
143
|
+
const dashboardPage = `import { Suspense } from "react";
|
|
144
|
+
import { createClient } from "@supabase/supabase-js";
|
|
134
145
|
import { PulseDashboard } from "@pulsekit/react";
|
|
135
146
|
import { getPulseTimezone } from "@pulsekit/next";
|
|
147
|
+
import { Spinner } from "@/components/ui/spinner";
|
|
148
|
+
import "@pulsekit/react/pulse.css";
|
|
136
149
|
|
|
137
150
|
const supabase = createClient(
|
|
138
151
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
139
152
|
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!
|
|
140
153
|
);
|
|
141
154
|
|
|
142
|
-
|
|
155
|
+
async function Dashboard() {
|
|
143
156
|
const timezone = await getPulseTimezone();
|
|
144
157
|
|
|
145
158
|
return (
|
|
@@ -151,10 +164,45 @@ export default async function AnalyticsPage() {
|
|
|
151
164
|
/>
|
|
152
165
|
);
|
|
153
166
|
}
|
|
167
|
+
|
|
168
|
+
export default function AnalyticsPage() {
|
|
169
|
+
return (
|
|
170
|
+
<Suspense fallback={<div className="flex items-center justify-center min-h-screen p-6"><Spinner className="size-6" /></div>}>
|
|
171
|
+
<Dashboard />
|
|
172
|
+
</Suspense>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
`;
|
|
176
|
+
const spinnerContent = `import { LoaderIcon } from "lucide-react"
|
|
177
|
+
import { cn } from "@/lib/utils"
|
|
178
|
+
|
|
179
|
+
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
|
|
180
|
+
return (
|
|
181
|
+
<LoaderIcon
|
|
182
|
+
role="status"
|
|
183
|
+
aria-label="Loading"
|
|
184
|
+
className={cn("size-4 animate-spin", className)}
|
|
185
|
+
{...props}
|
|
186
|
+
/>
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export { Spinner }
|
|
154
191
|
`;
|
|
192
|
+
const cwd = process.cwd();
|
|
193
|
+
const componentsBase = appDir.includes(import_node_path2.default.join("src", "app")) ? import_node_path2.default.join(cwd, "src", "components", "ui") : import_node_path2.default.join(cwd, "components", "ui");
|
|
194
|
+
const spinnerPath = import_node_path2.default.join(componentsBase, "spinner.tsx");
|
|
195
|
+
import_node_fs2.default.mkdirSync(componentsBase, { recursive: true });
|
|
196
|
+
if (import_node_fs2.default.existsSync(spinnerPath)) {
|
|
197
|
+
console.log(" Skipped (already exists): components/ui/spinner.tsx");
|
|
198
|
+
} else {
|
|
199
|
+
import_node_fs2.default.writeFileSync(spinnerPath, spinnerContent, "utf8");
|
|
200
|
+
console.log(" Created: components/ui/spinner.tsx");
|
|
201
|
+
}
|
|
155
202
|
const files = [
|
|
156
203
|
{ rel: "api/pulse/route.ts", content: pulseRoute },
|
|
157
204
|
{ rel: "api/pulse/refresh-aggregates/route.ts", content: refreshRoute },
|
|
205
|
+
{ rel: "api/pulse/consolidate/route.ts", content: consolidateRoute },
|
|
158
206
|
{ rel: "admin/analytics/page.tsx", content: dashboardPage }
|
|
159
207
|
];
|
|
160
208
|
for (const { rel, content } of files) {
|
|
@@ -218,7 +266,7 @@ async function injectPulseTracker() {
|
|
|
218
266
|
const indent = content.slice(lineStart, bodyCloseIndex).match(/^\s*/)?.[0] ?? " ";
|
|
219
267
|
const trackerJsx = `${indent} <PulseTracker excludePaths={["/admin/analytics"]} />
|
|
220
268
|
`;
|
|
221
|
-
content = content.slice(0,
|
|
269
|
+
content = content.slice(0, lineStart) + trackerJsx + content.slice(lineStart);
|
|
222
270
|
import_node_fs3.default.writeFileSync(foundPath, content, "utf8");
|
|
223
271
|
console.log(
|
|
224
272
|
` Modified: ${import_node_path3.default.relative(process.cwd(), foundPath)}
|
|
@@ -237,27 +285,102 @@ function printManualInstructions() {
|
|
|
237
285
|
);
|
|
238
286
|
}
|
|
239
287
|
|
|
240
|
-
// src/
|
|
288
|
+
// src/inject-instrumentation.ts
|
|
241
289
|
var import_node_fs4 = __toESM(require("fs"));
|
|
242
290
|
var import_node_path4 = __toESM(require("path"));
|
|
291
|
+
var FULL_CONTENT = `import { createClient } from "@supabase/supabase-js";
|
|
292
|
+
import { createPulseErrorReporter } from "@pulsekit/next";
|
|
293
|
+
|
|
294
|
+
const supabase = createClient(
|
|
295
|
+
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
296
|
+
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
export const onRequestError = createPulseErrorReporter({
|
|
300
|
+
supabase,
|
|
301
|
+
siteId: "default",
|
|
302
|
+
});
|
|
303
|
+
`;
|
|
304
|
+
var IMPORT_LINE = 'import { createPulseErrorReporter } from "@pulsekit/next";';
|
|
305
|
+
var EXPORT_BLOCK = `
|
|
306
|
+
export const onRequestError = createPulseErrorReporter({
|
|
307
|
+
supabase,
|
|
308
|
+
siteId: "default",
|
|
309
|
+
});
|
|
310
|
+
`;
|
|
311
|
+
async function injectInstrumentation() {
|
|
312
|
+
console.log(" Setting up error reporting instrumentation...\n");
|
|
313
|
+
const appDir = getAppDir();
|
|
314
|
+
const useSrc = appDir.includes(import_node_path4.default.join("src", "app"));
|
|
315
|
+
const baseDir = useSrc ? import_node_path4.default.join(process.cwd(), "src") : process.cwd();
|
|
316
|
+
const candidates = [
|
|
317
|
+
import_node_path4.default.join(baseDir, "instrumentation.ts"),
|
|
318
|
+
import_node_path4.default.join(baseDir, "instrumentation.js")
|
|
319
|
+
];
|
|
320
|
+
let foundPath = null;
|
|
321
|
+
for (const candidate of candidates) {
|
|
322
|
+
if (import_node_fs4.default.existsSync(candidate)) {
|
|
323
|
+
foundPath = candidate;
|
|
324
|
+
break;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
if (!foundPath) {
|
|
328
|
+
const targetPath = import_node_path4.default.join(baseDir, "instrumentation.ts");
|
|
329
|
+
import_node_fs4.default.writeFileSync(targetPath, FULL_CONTENT, "utf8");
|
|
330
|
+
console.log(` Created: ${import_node_path4.default.relative(process.cwd(), targetPath)}
|
|
331
|
+
`);
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
const content = import_node_fs4.default.readFileSync(foundPath, "utf8");
|
|
335
|
+
if (content.includes("onRequestError")) {
|
|
336
|
+
console.log(" onRequestError already present in instrumentation. Skipping.\n");
|
|
337
|
+
console.log(" To add PulseKit error reporting manually, add:\n");
|
|
338
|
+
console.log(` ${IMPORT_LINE}`);
|
|
339
|
+
console.log("");
|
|
340
|
+
console.log(" export const onRequestError = createPulseErrorReporter({");
|
|
341
|
+
console.log(" supabase,");
|
|
342
|
+
console.log(' siteId: "default",');
|
|
343
|
+
console.log(" });\n");
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
const importRegex = /^import\s.+$/gm;
|
|
347
|
+
let lastImportEnd = -1;
|
|
348
|
+
let match;
|
|
349
|
+
while ((match = importRegex.exec(content)) !== null) {
|
|
350
|
+
lastImportEnd = match.index + match[0].length;
|
|
351
|
+
}
|
|
352
|
+
let updated;
|
|
353
|
+
if (lastImportEnd === -1) {
|
|
354
|
+
updated = IMPORT_LINE + "\n" + content + EXPORT_BLOCK;
|
|
355
|
+
} else {
|
|
356
|
+
updated = content.slice(0, lastImportEnd) + "\n" + IMPORT_LINE + content.slice(lastImportEnd) + EXPORT_BLOCK;
|
|
357
|
+
}
|
|
358
|
+
import_node_fs4.default.writeFileSync(foundPath, updated, "utf8");
|
|
359
|
+
console.log(` Modified: ${import_node_path4.default.relative(process.cwd(), foundPath)}
|
|
360
|
+
`);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// src/migration.ts
|
|
364
|
+
var import_node_fs5 = __toESM(require("fs"));
|
|
365
|
+
var import_node_path5 = __toESM(require("path"));
|
|
243
366
|
var SQL_MAP = JSON.parse(
|
|
244
|
-
`{"001_init_pulse.sql":"create schema if not exists analytics;\\n\\n-- Add analytics to the schemas exposed by PostgREST\\nalter role authenticator set pgrst.db_schemas = 'public, graphql_public, analytics';\\n\\n-- Schema-level access\\ngrant usage on schema analytics to anon, authenticated, service_role;\\nalter default privileges in schema analytics grant all on tables to anon, authenticated, service_role;\\n\\ncreate table if not exists analytics.pulse_events (\\n id bigserial primary key,\\n site_id text not null,\\n session_id text,\\n path text not null,\\n event_type text not null,\\n meta jsonb,\\n created_at timestamptz not null default now()\\n);\\n\\ncreate index if not exists idx_pulse_events_site_created_at\\n on analytics.pulse_events (site_id, created_at);\\n\\ncreate index if not exists idx_pulse_events_site_path_created_at\\n on analytics.pulse_events (site_id, path, created_at);\\n\\nalter table analytics.pulse_events enable row level security; \\n \\n-- Allow the anon key (API route) to insert events\\ndrop policy if exists \\"Allow anon insert on pulse_events\\" on analytics.pulse_events;\\ncreate policy \\"Allow anon insert on pulse_events\\"\\n on analytics.pulse_events\\n for insert\\n to anon\\n with check (true);\\n\\n-- Only authenticated users (dashboard) can read events\\ndrop policy if exists \\"Allow authenticated select on pulse_events\\" on analytics.pulse_events;\\ncreate policy \\"Allow authenticated select on pulse_events\\"\\n on analytics.pulse_events\\n for select\\n to authenticated\\n using (true);\\n\\ncreate table if not exists analytics.pulse_aggregates (\\n date date not null,\\n site_id text not null,\\n path text not null,\\n total_views integer not null default 0,\\n unique_visitors integer not null default 0,\\n primary key (date, site_id, path)\\n);\\n\\n-- Grant table-level access (must be after table creation)\\ngrant all on all tables in schema analytics to anon, authenticated, service_role;\\ngrant all on all sequences in schema analytics to anon, authenticated, service_role;\\n\\nalter table analytics.pulse_aggregates enable row level security;\\n\\n-- Allow reading aggregates (dashboard)\\ndrop policy if exists \\"Allow authenticated select on pulse_aggregates\\" on analytics.pulse_aggregates;\\ncreate policy \\"Allow authenticated select on pulse_aggregates\\"\\n on analytics.pulse_aggregates\\n for select\\n to authenticated\\n using (true);\\n\\ndrop policy if exists \\"Allow anon select on pulse_aggregates\\" on analytics.pulse_aggregates;\\ncreate policy \\"Allow anon select on pulse_aggregates\\"\\n on analytics.pulse_aggregates\\n for select\\n to anon\\n using (true);\\n\\n-- Reload PostgREST config and schema cache (must be last)\\nnotify pgrst, 'reload config';\\nnotify pgrst, 'reload schema';\\n","002_aggregation_function.sql":"-- Aggregation function: rolls up raw events into daily aggregates\\ncreate or replace function analytics.pulse_refresh_aggregates(days_back integer default 7)\\nreturns void\\nlanguage sql\\nsecurity definer\\nas $$\\n insert into analytics.pulse_aggregates (date, site_id, path, total_views, unique_visitors)\\n select\\n date_trunc('day', created_at)::date as date,\\n site_id,\\n path,\\n count(*) as total_views,\\n count(distinct session_id) as unique_visitors\\n from analytics.pulse_events\\n where created_at >= now() - (days_back || ' days')::interval\\n group by 1, 2, 3\\n on conflict (date, site_id, path) do update\\n set\\n total_views = excluded.total_views,\\n unique_visitors = excluded.unique_visitors;\\n$$;\\n\\n-- Allow all roles to execute the aggregation function\\n-- security definer ensures it runs with the owner's privileges regardless of caller\\ngrant execute on function analytics.pulse_refresh_aggregates(integer) to anon, authenticated, service_role;\\n","003_geo_and_timezone.sql":"-- Add geo columns to pulse_events\\nalter table analytics.pulse_events\\n add column if not exists country text,\\n add column if not exists region text,\\n add column if not exists city text,\\n add column if not exists timezone text,\\n add column if not exists latitude double precision,\\n add column if not exists longitude double precision;\\n\\n-- Timezone-aware stats: queries raw events with AT TIME ZONE\\n-- so the dashboard can display data bucketed by the viewer's local day.\\ncreate or replace function analytics.pulse_stats_by_timezone(\\n p_site_id text,\\n p_timezone text default 'UTC',\\n p_days_back integer default 7\\n)\\nreturns table (\\n date date,\\n path text,\\n total_views bigint,\\n unique_visitors bigint\\n)\\nlanguage sql\\nsecurity definer\\nstable\\nas $$\\n select\\n date_trunc('day', created_at at time zone p_timezone)::date as date,\\n path,\\n count(*) as total_views,\\n count(distinct session_id) as unique_visitors\\n from analytics.pulse_events\\n where site_id = p_site_id\\n and created_at >= now() - make_interval(days => p_days_back + 1)\\n group by 1, 2;\\n$$;\\n\\ngrant execute on function analytics.pulse_stats_by_timezone(text, text, integer)\\n to anon, authenticated, service_role;\\n\\n-- Drop first so return type can change (CREATE OR REPLACE cannot alter return columns)\\ndrop function if exists analytics.pulse_location_stats(text, integer);\\n\\n-- Location stats: visitor counts grouped by country + city, with averaged coordinates\\ncreate or replace function analytics.pulse_location_stats(\\n p_site_id text,\\n p_days_back integer default 7\\n)\\nreturns table (\\n country text,\\n city text,\\n latitude double precision,\\n longitude double precision,\\n total_views bigint,\\n unique_visitors bigint\\n)\\nlanguage sql\\nsecurity definer\\nstable\\nas $$\\n select\\n country,\\n city,\\n avg(latitude) as latitude,\\n avg(longitude) as longitude,\\n count(*) as total_views,\\n count(distinct session_id) as unique_visitors\\n from analytics.pulse_events\\n where site_id = p_site_id\\n and created_at >= now() - make_interval(days => p_days_back)\\n and country is not null\\n group by 1, 2\\n order by total_views desc;\\n$$;\\n\\ngrant execute on function analytics.pulse_location_stats(text, integer)\\n to anon, authenticated, service_role;\\n","004_web_vitals.sql":"-- 004_web_vitals.sql\\n-- Partial index + RPC for Web Vitals p75 aggregation\\n\\n-- Partial index: only covers vitals events, stays small\\nCREATE INDEX IF NOT EXISTS idx_pulse_events_vitals\\n ON analytics.pulse_events (site_id, created_at)\\n WHERE event_type = 'vitals';\\n\\n-- RPC: returns per-metric p75 for each page + site-wide (__overall__)\\nCREATE OR REPLACE FUNCTION analytics.pulse_vitals_stats(\\n p_site_id TEXT,\\n p_days_back INT DEFAULT 7\\n)\\nRETURNS TABLE (\\n path TEXT,\\n metric TEXT,\\n p75 DOUBLE PRECISION,\\n sample_count BIGINT\\n)\\nLANGUAGE sql SECURITY DEFINER STABLE\\nAS $$\\n WITH vitals_raw AS (\\n SELECT\\n e.path,\\n kv.key AS metric,\\n kv.value::double precision AS val\\n FROM analytics.pulse_events e,\\n LATERAL jsonb_each_text(e.meta) AS kv(key, value)\\n WHERE e.site_id = p_site_id\\n AND e.event_type = 'vitals'\\n AND e.created_at >= NOW() - (p_days_back || ' days')::interval\\n AND kv.key IN ('lcp', 'inp', 'cls', 'fcp', 'ttfb')\\n )\\n -- Per-page stats\\n SELECT\\n vr.path,\\n vr.metric,\\n percentile_cont(0.75) WITHIN GROUP (ORDER BY vr.val) AS p75,\\n count(*)::bigint AS sample_count\\n FROM vitals_raw vr\\n GROUP BY vr.path, vr.metric\\n\\n UNION ALL\\n\\n -- Site-wide stats\\n SELECT\\n '__overall__'::text AS path,\\n vr.metric,\\n percentile_cont(0.75) WITHIN GROUP (ORDER BY vr.val) AS p75,\\n count(*)::bigint AS sample_count\\n FROM vitals_raw vr\\n GROUP BY vr.metric;\\n$$;\\n\\nGRANT EXECUTE ON FUNCTION analytics.pulse_vitals_stats(TEXT, INT)\\n TO anon, authenticated, service_role;\\n"}`
|
|
367
|
+
`{"001_init_pulse.sql":"create schema if not exists analytics;\\n\\n-- Add analytics to the schemas exposed by PostgREST\\nalter role authenticator set pgrst.db_schemas = 'public, graphql_public, analytics';\\n\\n-- Schema-level access\\ngrant usage on schema analytics to anon, authenticated, service_role;\\nalter default privileges in schema analytics grant all on tables to anon, authenticated, service_role;\\n\\ncreate table if not exists analytics.pulse_events (\\n id bigserial primary key,\\n site_id text not null,\\n session_id text,\\n path text not null,\\n event_type text not null,\\n meta jsonb,\\n created_at timestamptz not null default now()\\n);\\n\\ncreate index if not exists idx_pulse_events_site_created_at\\n on analytics.pulse_events (site_id, created_at);\\n\\ncreate index if not exists idx_pulse_events_site_path_created_at\\n on analytics.pulse_events (site_id, path, created_at);\\n\\nalter table analytics.pulse_events enable row level security; \\n \\n-- Allow the anon key (API route) to insert events\\ndrop policy if exists \\"Allow anon insert on pulse_events\\" on analytics.pulse_events;\\ncreate policy \\"Allow anon insert on pulse_events\\"\\n on analytics.pulse_events\\n for insert\\n to anon\\n with check (true);\\n\\n-- Only authenticated users (dashboard) can read events\\ndrop policy if exists \\"Allow authenticated select on pulse_events\\" on analytics.pulse_events;\\ncreate policy \\"Allow authenticated select on pulse_events\\"\\n on analytics.pulse_events\\n for select\\n to authenticated\\n using (true);\\n\\ncreate table if not exists analytics.pulse_aggregates (\\n date date not null,\\n site_id text not null,\\n path text not null,\\n total_views integer not null default 0,\\n unique_visitors integer not null default 0,\\n primary key (date, site_id, path)\\n);\\n\\n-- Grant table-level access (must be after table creation)\\ngrant all on all tables in schema analytics to anon, authenticated, service_role;\\ngrant all on all sequences in schema analytics to anon, authenticated, service_role;\\n\\nalter table analytics.pulse_aggregates enable row level security;\\n\\n-- Allow reading aggregates (dashboard)\\ndrop policy if exists \\"Allow authenticated select on pulse_aggregates\\" on analytics.pulse_aggregates;\\ncreate policy \\"Allow authenticated select on pulse_aggregates\\"\\n on analytics.pulse_aggregates\\n for select\\n to authenticated\\n using (true);\\n\\ndrop policy if exists \\"Allow anon select on pulse_aggregates\\" on analytics.pulse_aggregates;\\ncreate policy \\"Allow anon select on pulse_aggregates\\"\\n on analytics.pulse_aggregates\\n for select\\n to anon\\n using (true);\\n\\n-- Reload PostgREST config and schema cache (must be last)\\nnotify pgrst, 'reload config';\\nnotify pgrst, 'reload schema';\\n","002_aggregation_function.sql":"-- Aggregation function: rolls up raw events into daily aggregates\\ncreate or replace function analytics.pulse_refresh_aggregates(days_back integer default 7)\\nreturns void\\nlanguage sql\\nsecurity definer\\nas $$\\n insert into analytics.pulse_aggregates (date, site_id, path, total_views, unique_visitors)\\n select\\n date_trunc('day', created_at)::date as date,\\n site_id,\\n path,\\n count(*) as total_views,\\n count(distinct session_id) as unique_visitors\\n from analytics.pulse_events\\n where created_at >= now() - (days_back || ' days')::interval\\n group by 1, 2, 3\\n on conflict (date, site_id, path) do update\\n set\\n total_views = excluded.total_views,\\n unique_visitors = excluded.unique_visitors;\\n$$;\\n\\n-- Allow all roles to execute the aggregation function\\n-- security definer ensures it runs with the owner's privileges regardless of caller\\ngrant execute on function analytics.pulse_refresh_aggregates(integer) to anon, authenticated, service_role;\\n","003_geo_and_timezone.sql":"-- Add geo columns to pulse_events\\nalter table analytics.pulse_events\\n add column if not exists country text,\\n add column if not exists region text,\\n add column if not exists city text,\\n add column if not exists timezone text,\\n add column if not exists latitude double precision,\\n add column if not exists longitude double precision;\\n\\n-- Timezone-aware stats: queries raw events with AT TIME ZONE\\n-- so the dashboard can display data bucketed by the viewer's local day.\\ncreate or replace function analytics.pulse_stats_by_timezone(\\n p_site_id text,\\n p_timezone text default 'UTC',\\n p_days_back integer default 7\\n)\\nreturns table (\\n date date,\\n path text,\\n total_views bigint,\\n unique_visitors bigint\\n)\\nlanguage sql\\nsecurity definer\\nstable\\nas $$\\n select\\n date_trunc('day', created_at at time zone p_timezone)::date as date,\\n path,\\n count(*) as total_views,\\n count(distinct session_id) as unique_visitors\\n from analytics.pulse_events\\n where site_id = p_site_id\\n and created_at >= now() - make_interval(days => p_days_back + 1)\\n group by 1, 2;\\n$$;\\n\\ngrant execute on function analytics.pulse_stats_by_timezone(text, text, integer)\\n to anon, authenticated, service_role;\\n\\n-- Drop first so return type can change (CREATE OR REPLACE cannot alter return columns)\\ndrop function if exists analytics.pulse_location_stats(text, integer);\\n\\n-- Location stats: visitor counts grouped by country + city, with averaged coordinates\\ncreate or replace function analytics.pulse_location_stats(\\n p_site_id text,\\n p_days_back integer default 7\\n)\\nreturns table (\\n country text,\\n city text,\\n latitude double precision,\\n longitude double precision,\\n total_views bigint,\\n unique_visitors bigint\\n)\\nlanguage sql\\nsecurity definer\\nstable\\nas $$\\n select\\n country,\\n city,\\n avg(latitude) as latitude,\\n avg(longitude) as longitude,\\n count(*) as total_views,\\n count(distinct session_id) as unique_visitors\\n from analytics.pulse_events\\n where site_id = p_site_id\\n and created_at >= now() - make_interval(days => p_days_back)\\n and country is not null\\n group by 1, 2\\n order by total_views desc;\\n$$;\\n\\ngrant execute on function analytics.pulse_location_stats(text, integer)\\n to anon, authenticated, service_role;\\n","004_web_vitals.sql":"-- 004_web_vitals.sql\\n-- Partial index + RPC for Web Vitals p75 aggregation\\n\\n-- Partial index: only covers vitals events, stays small\\nCREATE INDEX IF NOT EXISTS idx_pulse_events_vitals\\n ON analytics.pulse_events (site_id, created_at)\\n WHERE event_type = 'vitals';\\n\\n-- RPC: returns per-metric p75 for each page + site-wide (__overall__)\\nCREATE OR REPLACE FUNCTION analytics.pulse_vitals_stats(\\n p_site_id TEXT,\\n p_days_back INT DEFAULT 7\\n)\\nRETURNS TABLE (\\n path TEXT,\\n metric TEXT,\\n p75 DOUBLE PRECISION,\\n sample_count BIGINT\\n)\\nLANGUAGE sql SECURITY DEFINER STABLE\\nAS $$\\n WITH vitals_raw AS (\\n SELECT\\n e.path,\\n kv.key AS metric,\\n kv.value::double precision AS val\\n FROM analytics.pulse_events e,\\n LATERAL jsonb_each_text(e.meta) AS kv(key, value)\\n WHERE e.site_id = p_site_id\\n AND e.event_type = 'vitals'\\n AND e.created_at >= NOW() - (p_days_back || ' days')::interval\\n AND kv.key IN ('lcp', 'inp', 'cls', 'fcp', 'ttfb')\\n )\\n -- Per-page stats\\n SELECT\\n vr.path,\\n vr.metric,\\n percentile_cont(0.75) WITHIN GROUP (ORDER BY vr.val) AS p75,\\n count(*)::bigint AS sample_count\\n FROM vitals_raw vr\\n GROUP BY vr.path, vr.metric\\n\\n UNION ALL\\n\\n -- Site-wide stats\\n SELECT\\n '__overall__'::text AS path,\\n vr.metric,\\n percentile_cont(0.75) WITHIN GROUP (ORDER BY vr.val) AS p75,\\n count(*)::bigint AS sample_count\\n FROM vitals_raw vr\\n GROUP BY vr.metric;\\n$$;\\n\\nGRANT EXECUTE ON FUNCTION analytics.pulse_vitals_stats(TEXT, INT)\\n TO anon, authenticated, service_role;\\n","005_error_tracking.sql":"-- 005_error_tracking.sql\\n-- Fix existing RPCs to filter by event_type = 'pageview', add error tracking\\n\\n-- \u2500\u2500 Fix pulse_refresh_aggregates: only aggregate pageview events \u2500\u2500\u2500\u2500\u2500\u2500\\nCREATE OR REPLACE FUNCTION analytics.pulse_refresh_aggregates(days_back integer default 7)\\nRETURNS void\\nLANGUAGE sql\\nSECURITY DEFINER\\nAS $$\\n INSERT INTO analytics.pulse_aggregates (date, site_id, path, total_views, unique_visitors)\\n SELECT\\n date_trunc('day', created_at)::date AS date,\\n site_id,\\n path,\\n count(*) AS total_views,\\n count(distinct session_id) AS unique_visitors\\n FROM analytics.pulse_events\\n WHERE created_at >= now() - (days_back || ' days')::interval\\n AND event_type = 'pageview'\\n GROUP BY 1, 2, 3\\n ON CONFLICT (date, site_id, path) DO UPDATE\\n SET\\n total_views = excluded.total_views,\\n unique_visitors = excluded.unique_visitors;\\n$$;\\n\\n-- \u2500\u2500 Fix pulse_stats_by_timezone: only count pageview events \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\\nCREATE OR REPLACE FUNCTION analytics.pulse_stats_by_timezone(\\n p_site_id text,\\n p_timezone text default 'UTC',\\n p_days_back integer default 7\\n)\\nRETURNS TABLE (\\n date date,\\n path text,\\n total_views bigint,\\n unique_visitors bigint\\n)\\nLANGUAGE sql\\nSECURITY DEFINER\\nSTABLE\\nAS $$\\n SELECT\\n date_trunc('day', created_at AT TIME ZONE p_timezone)::date AS date,\\n path,\\n count(*) AS total_views,\\n count(distinct session_id) AS unique_visitors\\n FROM analytics.pulse_events\\n WHERE site_id = p_site_id\\n AND created_at >= now() - make_interval(days => p_days_back + 1)\\n AND event_type = 'pageview'\\n GROUP BY 1, 2;\\n$$;\\n\\n-- \u2500\u2500 Fix pulse_location_stats: only count pageview events \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\\nCREATE OR REPLACE FUNCTION analytics.pulse_location_stats(\\n p_site_id text,\\n p_days_back integer default 7\\n)\\nRETURNS TABLE (\\n country text,\\n city text,\\n latitude double precision,\\n longitude double precision,\\n total_views bigint,\\n unique_visitors bigint\\n)\\nLANGUAGE sql\\nSECURITY DEFINER\\nSTABLE\\nAS $$\\n SELECT\\n country,\\n city,\\n avg(latitude) AS latitude,\\n avg(longitude) AS longitude,\\n count(*) AS total_views,\\n count(distinct session_id) AS unique_visitors\\n FROM analytics.pulse_events\\n WHERE site_id = p_site_id\\n AND created_at >= now() - make_interval(days => p_days_back)\\n AND country IS NOT NULL\\n AND event_type = 'pageview'\\n GROUP BY 1, 2\\n ORDER BY total_views DESC;\\n$$;\\n\\n-- \u2500\u2500 Partial index for error events \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\\nCREATE INDEX IF NOT EXISTS idx_pulse_events_errors\\n ON analytics.pulse_events (site_id, created_at)\\n WHERE event_type IN ('error', 'server_error');\\n\\n-- \u2500\u2500 Error stats RPC \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\\nCREATE OR REPLACE FUNCTION analytics.pulse_error_stats(\\n p_site_id TEXT,\\n p_days_back INT DEFAULT 7\\n)\\nRETURNS TABLE (\\n error_type TEXT,\\n message TEXT,\\n path TEXT,\\n total_count BIGINT,\\n session_count BIGINT,\\n last_seen TIMESTAMPTZ,\\n first_seen TIMESTAMPTZ,\\n sample_meta JSONB\\n)\\nLANGUAGE sql SECURITY DEFINER STABLE\\nAS $$\\n WITH ranked AS (\\n SELECT\\n e.event_type AS error_type,\\n e.meta->>'message' AS message,\\n e.path,\\n count(*) AS total_count,\\n count(DISTINCT e.session_id) AS session_count,\\n max(e.created_at) AS last_seen,\\n min(e.created_at) AS first_seen,\\n -- Get the full meta from the most recent occurrence\\n (ARRAY_AGG(e.meta ORDER BY e.created_at DESC))[1] AS sample_meta\\n FROM analytics.pulse_events e\\n WHERE e.site_id = p_site_id\\n AND e.event_type IN ('error', 'server_error')\\n AND e.created_at >= NOW() - (p_days_back || ' days')::interval\\n GROUP BY e.event_type, e.meta->>'message', e.path\\n )\\n SELECT * FROM ranked\\n ORDER BY last_seen DESC\\n LIMIT 50;\\n$$;\\n\\nGRANT EXECUTE ON FUNCTION analytics.pulse_error_stats(TEXT, INT)\\n TO anon, authenticated, service_role;\\n","006_date_range_support.sql":"-- 006_date_range_support.sql\\n-- Replace p_days_back with p_start_date / p_end_date date range params.\\n-- Both default to NULL \u2192 falls back to last 7 days when not provided.\\n\\n-- \u2500\u2500 pulse_stats_by_timezone \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\\nDROP FUNCTION IF EXISTS analytics.pulse_stats_by_timezone(text, text, integer);\\n\\nCREATE OR REPLACE FUNCTION analytics.pulse_stats_by_timezone(\\n p_site_id text,\\n p_timezone text DEFAULT 'UTC',\\n p_start_date date DEFAULT NULL,\\n p_end_date date DEFAULT NULL\\n)\\nRETURNS TABLE (\\n date date,\\n path text,\\n total_views bigint,\\n unique_visitors bigint\\n)\\nLANGUAGE sql\\nSECURITY DEFINER\\nSTABLE\\nAS $$\\n SELECT\\n date_trunc('day', created_at AT TIME ZONE p_timezone)::date AS date,\\n path,\\n count(*) AS total_views,\\n count(DISTINCT session_id) AS unique_visitors\\n FROM analytics.pulse_events\\n WHERE site_id = p_site_id\\n AND event_type = 'pageview'\\n AND created_at >= (COALESCE(p_start_date, current_date - 7)::timestamp AT TIME ZONE p_timezone)\\n AND created_at < ((COALESCE(p_end_date, current_date) + interval '1 day')::timestamp AT TIME ZONE p_timezone)\\n GROUP BY 1, 2;\\n$$;\\n\\nGRANT EXECUTE ON FUNCTION analytics.pulse_stats_by_timezone(text, text, date, date)\\n TO anon, authenticated, service_role;\\n\\n-- \u2500\u2500 pulse_location_stats \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\\nDROP FUNCTION IF EXISTS analytics.pulse_location_stats(text, integer);\\n\\nCREATE OR REPLACE FUNCTION analytics.pulse_location_stats(\\n p_site_id text,\\n p_start_date date DEFAULT NULL,\\n p_end_date date DEFAULT NULL\\n)\\nRETURNS TABLE (\\n country text,\\n city text,\\n latitude double precision,\\n longitude double precision,\\n total_views bigint,\\n unique_visitors bigint\\n)\\nLANGUAGE sql\\nSECURITY DEFINER\\nSTABLE\\nAS $$\\n SELECT\\n country,\\n city,\\n avg(latitude) AS latitude,\\n avg(longitude) AS longitude,\\n count(*) AS total_views,\\n count(DISTINCT session_id) AS unique_visitors\\n FROM analytics.pulse_events\\n WHERE site_id = p_site_id\\n AND event_type = 'pageview'\\n AND country IS NOT NULL\\n AND created_at >= COALESCE(p_start_date, current_date - 7)::timestamptz\\n AND created_at < (COALESCE(p_end_date, current_date) + interval '1 day')::timestamptz\\n GROUP BY 1, 2\\n ORDER BY total_views DESC;\\n$$;\\n\\nGRANT EXECUTE ON FUNCTION analytics.pulse_location_stats(text, date, date)\\n TO anon, authenticated, service_role;\\n\\n-- \u2500\u2500 pulse_vitals_stats \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\\nDROP FUNCTION IF EXISTS analytics.pulse_vitals_stats(text, int);\\n\\nCREATE OR REPLACE FUNCTION analytics.pulse_vitals_stats(\\n p_site_id text,\\n p_start_date date DEFAULT NULL,\\n p_end_date date DEFAULT NULL\\n)\\nRETURNS TABLE (\\n path text,\\n metric text,\\n p75 double precision,\\n sample_count bigint\\n)\\nLANGUAGE sql SECURITY DEFINER STABLE\\nAS $$\\n WITH vitals_raw AS (\\n SELECT\\n e.path,\\n kv.key AS metric,\\n kv.value::double precision AS val\\n FROM analytics.pulse_events e,\\n LATERAL jsonb_each_text(e.meta) AS kv(key, value)\\n WHERE e.site_id = p_site_id\\n AND e.event_type = 'vitals'\\n AND e.created_at >= COALESCE(p_start_date, current_date - 7)::timestamptz\\n AND e.created_at < (COALESCE(p_end_date, current_date) + interval '1 day')::timestamptz\\n AND kv.key IN ('lcp', 'inp', 'cls', 'fcp', 'ttfb')\\n )\\n -- Per-page stats\\n SELECT\\n vr.path,\\n vr.metric,\\n percentile_cont(0.75) WITHIN GROUP (ORDER BY vr.val) AS p75,\\n count(*)::bigint AS sample_count\\n FROM vitals_raw vr\\n GROUP BY vr.path, vr.metric\\n\\n UNION ALL\\n\\n -- Site-wide stats\\n SELECT\\n '__overall__'::text AS path,\\n vr.metric,\\n percentile_cont(0.75) WITHIN GROUP (ORDER BY vr.val) AS p75,\\n count(*)::bigint AS sample_count\\n FROM vitals_raw vr\\n GROUP BY vr.metric;\\n$$;\\n\\nGRANT EXECUTE ON FUNCTION analytics.pulse_vitals_stats(text, date, date)\\n TO anon, authenticated, service_role;\\n\\n-- \u2500\u2500 pulse_error_stats \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\\nDROP FUNCTION IF EXISTS analytics.pulse_error_stats(text, int);\\n\\nCREATE OR REPLACE FUNCTION analytics.pulse_error_stats(\\n p_site_id text,\\n p_start_date date DEFAULT NULL,\\n p_end_date date DEFAULT NULL\\n)\\nRETURNS TABLE (\\n error_type text,\\n message text,\\n path text,\\n total_count bigint,\\n session_count bigint,\\n last_seen timestamptz,\\n first_seen timestamptz,\\n sample_meta jsonb\\n)\\nLANGUAGE sql SECURITY DEFINER STABLE\\nAS $$\\n WITH ranked AS (\\n SELECT\\n e.event_type AS error_type,\\n e.meta->>'message' AS message,\\n e.path,\\n count(*) AS total_count,\\n count(DISTINCT e.session_id) AS session_count,\\n max(e.created_at) AS last_seen,\\n min(e.created_at) AS first_seen,\\n (ARRAY_AGG(e.meta ORDER BY e.created_at DESC))[1] AS sample_meta\\n FROM analytics.pulse_events e\\n WHERE e.site_id = p_site_id\\n AND e.event_type IN ('error', 'server_error')\\n AND e.created_at >= COALESCE(p_start_date, current_date - 7)::timestamptz\\n AND e.created_at < (COALESCE(p_end_date, current_date) + interval '1 day')::timestamptz\\n GROUP BY e.event_type, e.meta->>'message', e.path\\n )\\n SELECT * FROM ranked\\n ORDER BY last_seen DESC\\n LIMIT 50;\\n$$;\\n\\nGRANT EXECUTE ON FUNCTION analytics.pulse_error_stats(text, date, date)\\n TO anon, authenticated, service_role;\\n","007_data_lifecycle.sql":"-- 007_data_lifecycle.sql\\n-- Automatic data consolidation & cleanup.\\n-- Rolls pageview counts older than retention_days into pulse_aggregates,\\n-- then deletes all old raw events (all event types).\\n\\nCREATE OR REPLACE FUNCTION analytics.pulse_consolidate_and_cleanup(\\n retention_days int DEFAULT 30\\n)\\nRETURNS TABLE (rows_consolidated bigint, rows_deleted bigint)\\nLANGUAGE plpgsql\\nSECURITY DEFINER\\nAS $$\\nDECLARE\\n v_cutoff timestamptz;\\n v_consolidated bigint;\\n v_deleted bigint;\\nBEGIN\\n v_cutoff := now() - make_interval(days => retention_days);\\n\\n -- Step 1: Roll up old pageview events into daily aggregates\\n WITH inserted AS (\\n INSERT INTO analytics.pulse_aggregates (date, site_id, path, total_views, unique_visitors)\\n SELECT\\n date_trunc('day', created_at)::date AS date,\\n site_id,\\n path,\\n count(*)::int AS total_views,\\n count(DISTINCT session_id)::int AS unique_visitors\\n FROM analytics.pulse_events\\n WHERE created_at < v_cutoff\\n AND event_type = 'pageview'\\n GROUP BY 1, 2, 3\\n ON CONFLICT (date, site_id, path) DO UPDATE SET\\n total_views = GREATEST(analytics.pulse_aggregates.total_views, excluded.total_views),\\n unique_visitors = GREATEST(analytics.pulse_aggregates.unique_visitors, excluded.unique_visitors)\\n RETURNING 1\\n )\\n SELECT count(*) INTO v_consolidated FROM inserted;\\n\\n -- Step 2: Delete all old events (pageviews, vitals, errors, etc.)\\n WITH deleted AS (\\n DELETE FROM analytics.pulse_events\\n WHERE created_at < v_cutoff\\n RETURNING 1\\n )\\n SELECT count(*) INTO v_deleted FROM deleted;\\n\\n RETURN QUERY SELECT v_consolidated, v_deleted;\\nEND;\\n$$;\\n\\nGRANT EXECUTE ON FUNCTION analytics.pulse_consolidate_and_cleanup(int)\\n TO anon, authenticated, service_role;\\n\\n-- \u2500\u2500 Replace pulse_stats_by_timezone to union raw events + aggregates \u2500\u2500\\nDROP FUNCTION IF EXISTS analytics.pulse_stats_by_timezone(text, text, date, date);\\n\\nCREATE OR REPLACE FUNCTION analytics.pulse_stats_by_timezone(\\n p_site_id text,\\n p_timezone text DEFAULT 'UTC',\\n p_start_date date DEFAULT NULL,\\n p_end_date date DEFAULT NULL\\n)\\nRETURNS TABLE (\\n date date,\\n path text,\\n total_views bigint,\\n unique_visitors bigint\\n)\\nLANGUAGE sql\\nSECURITY DEFINER\\nSTABLE\\nAS $$\\n WITH\\n -- Find the earliest raw pageview date for this site\\n oldest_raw AS (\\n SELECT min(date_trunc('day', created_at AT TIME ZONE p_timezone)::date) AS min_date\\n FROM analytics.pulse_events\\n WHERE site_id = p_site_id\\n AND event_type = 'pageview'\\n ),\\n -- Aggregated data for dates before the oldest raw event\\n from_aggregates AS (\\n SELECT\\n a.date,\\n a.path,\\n a.total_views::bigint,\\n a.unique_visitors::bigint\\n FROM analytics.pulse_aggregates a, oldest_raw o\\n WHERE a.site_id = p_site_id\\n AND a.date >= COALESCE(p_start_date, current_date - 7)\\n AND a.date < COALESCE(p_end_date, current_date) + 1\\n AND (o.min_date IS NULL OR a.date < o.min_date)\\n ),\\n -- Raw events for recent data\\n from_raw AS (\\n SELECT\\n date_trunc('day', created_at AT TIME ZONE p_timezone)::date AS date,\\n path,\\n count(*) AS total_views,\\n count(DISTINCT session_id) AS unique_visitors\\n FROM analytics.pulse_events\\n WHERE site_id = p_site_id\\n AND event_type = 'pageview'\\n AND created_at >= (COALESCE(p_start_date, current_date - 7)::timestamp AT TIME ZONE p_timezone)\\n AND created_at < ((COALESCE(p_end_date, current_date) + interval '1 day')::timestamp AT TIME ZONE p_timezone)\\n GROUP BY 1, 2\\n )\\n SELECT * FROM from_aggregates\\n UNION ALL\\n SELECT * FROM from_raw;\\n$$;\\n\\nGRANT EXECUTE ON FUNCTION analytics.pulse_stats_by_timezone(text, text, date, date)\\n TO anon, authenticated, service_role;\\n"}`
|
|
245
368
|
);
|
|
246
369
|
function writeMigration() {
|
|
247
370
|
console.log(" Writing database migration...\n");
|
|
248
|
-
const supabaseDir =
|
|
249
|
-
|
|
371
|
+
const supabaseDir = import_node_path5.default.join(process.cwd(), "supabase", "migrations");
|
|
372
|
+
import_node_fs5.default.mkdirSync(supabaseDir, { recursive: true });
|
|
250
373
|
const files = Object.keys(SQL_MAP).sort();
|
|
251
374
|
const combined = files.map((file) => `-- ${file}
|
|
252
375
|
${SQL_MAP[file]}`).join("\n\n");
|
|
253
376
|
const filename = "20250101000000_pulse_analytics.sql";
|
|
254
|
-
const fullPath =
|
|
255
|
-
if (
|
|
377
|
+
const fullPath = import_node_path5.default.join(supabaseDir, filename);
|
|
378
|
+
if (import_node_fs5.default.existsSync(fullPath)) {
|
|
256
379
|
console.log(` Skipped (already exists): supabase/migrations/${filename}
|
|
257
380
|
`);
|
|
258
381
|
return;
|
|
259
382
|
}
|
|
260
|
-
|
|
383
|
+
import_node_fs5.default.writeFileSync(fullPath, combined, "utf8");
|
|
261
384
|
console.log(` Created: supabase/migrations/${filename}
|
|
262
385
|
`);
|
|
263
386
|
}
|
|
@@ -273,6 +396,7 @@ async function main() {
|
|
|
273
396
|
await installPackages(pm);
|
|
274
397
|
scaffoldFiles();
|
|
275
398
|
await injectPulseTracker();
|
|
399
|
+
await injectInstrumentation();
|
|
276
400
|
writeMigration();
|
|
277
401
|
console.log("\n Done! PulseKit has been added to your project.\n");
|
|
278
402
|
console.log(" To finish setup:");
|
package/package.json
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pulsekit",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.1.0",
|
|
4
4
|
"description": "Set up PulseKit analytics in your Next.js project",
|
|
5
5
|
"bin": "./dist/index.js",
|
|
6
|
-
"files": [
|
|
6
|
+
"files": [
|
|
7
|
+
"dist"
|
|
8
|
+
],
|
|
9
|
+
"devDependencies": {
|
|
10
|
+
"tsup": "^8.0.0",
|
|
11
|
+
"typescript": "^5.7.0"
|
|
12
|
+
},
|
|
7
13
|
"scripts": {
|
|
8
14
|
"prebuild": "cp ../core/sql/*.sql src/sql/",
|
|
9
15
|
"build": "tsup",
|
|
10
16
|
"clean": "rm -rf dist"
|
|
11
|
-
},
|
|
12
|
-
"devDependencies": {
|
|
13
|
-
"tsup": "^8.0.0",
|
|
14
|
-
"typescript": "^5.7.0"
|
|
15
17
|
}
|
|
16
|
-
}
|
|
18
|
+
}
|