vibecarbon 0.1.2 → 0.1.4
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/carbon/docker-compose.yml +25 -2
- package/carbon/functions/main/index.ts +17 -0
- package/carbon/package.json +31 -30
- package/carbon/vite.config.ts +13 -1
- package/carbon/volumes/db/wal-archive.sh +43 -0
- package/carbon/volumes/loki/loki-config.yml +8 -0
- package/package.json +1 -1
- package/src/lib/providers/hetzner-s3.js +24 -4
|
@@ -129,9 +129,31 @@ services:
|
|
|
129
129
|
- -c
|
|
130
130
|
- archive_mode=on
|
|
131
131
|
- -c
|
|
132
|
-
-
|
|
132
|
+
# Fault-tolerant wrapper around `wal-g wal-push`. If wal-g fails (S3
|
|
133
|
+
# outage, bad creds, missing bucket), the wrapper retries with backoff
|
|
134
|
+
# then logs "WAL_ARCHIVE_FAILED" to stderr and exits 0 so PG can recycle
|
|
135
|
+
# the segment instead of pinning the entire pg_wal directory. Without
|
|
136
|
+
# this, a single archive failure pins WAL forever and fills the disk
|
|
137
|
+
# within days (~20 GiB/day of WAL on an idle Supabase DB given
|
|
138
|
+
# archive_timeout below, because supabase extensions like pg_cron,
|
|
139
|
+
# pg_stat_statements, realtime publications all generate background
|
|
140
|
+
# writes every minute). RCA: prod-1 2026-05-26, 58 GiB of WAL retained
|
|
141
|
+
# in 3 days because wal-g couldn't reach the configured S3 bucket;
|
|
142
|
+
# disk filled, db went into Error state, /api/v1/notifications 500'd,
|
|
143
|
+
# whole stack down. Trade-off: a persistent archive outage leaves a
|
|
144
|
+
# PITR gap, but the DB stays up — strictly better than the previous
|
|
145
|
+
# behavior for a SaaS template. Grep `docker logs db` for
|
|
146
|
+
# WAL_ARCHIVE_FAILED to detect silent backup regressions.
|
|
147
|
+
- archive_command=/etc/postgresql/wal-archive.sh %p
|
|
133
148
|
- -c
|
|
134
|
-
|
|
149
|
+
# 60s was too aggressive — on an idle Supabase DB, that forces a
|
|
150
|
+
# 16 MiB segment switch every minute regardless of write volume (PG14+
|
|
151
|
+
# is supposed to skip empty switches but supabase's bundled extensions
|
|
152
|
+
# write enough every minute to trip it). 23 GiB/day of WAL baseline.
|
|
153
|
+
# 900s (15 min) drops baseline to ~1.5 GiB/day with an RPO cap that's
|
|
154
|
+
# plenty for SaaS workloads. Override via postgresql.conf or compose
|
|
155
|
+
# if tighter RPO is needed.
|
|
156
|
+
- archive_timeout=900
|
|
135
157
|
- -c
|
|
136
158
|
# hot_standby=on lets a server in recovery mode (i.e. our standby)
|
|
137
159
|
# accept read-only psql connections. Required so failover's
|
|
@@ -167,6 +189,7 @@ services:
|
|
|
167
189
|
WALG_COMPRESSION_METHOD: lz4
|
|
168
190
|
volumes:
|
|
169
191
|
- db_data:/var/lib/postgresql/data
|
|
192
|
+
- ./volumes/db/wal-archive.sh:/etc/postgresql/wal-archive.sh:ro,Z
|
|
170
193
|
- ./volumes/db/roles.sql:/docker-entrypoint-initdb.d/init-scripts/98-roles.sql:Z
|
|
171
194
|
- ./volumes/db/jwt.sql:/docker-entrypoint-initdb.d/init-scripts/99-jwt.sql:Z
|
|
172
195
|
- ./volumes/db/realtime.sql:/docker-entrypoint-initdb.d/migrations/99-realtime.sql:Z
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Edge Functions main entrypoint.
|
|
2
|
+
//
|
|
3
|
+
// docker-compose.prod.yml starts the supabase/edge-runtime container with
|
|
4
|
+
// `--main-service /home/deno/functions/main`, which requires this file to
|
|
5
|
+
// exist. Without it the container crash-loops with
|
|
6
|
+
// "could not find an appropriate entrypoint".
|
|
7
|
+
//
|
|
8
|
+
// This stub returns 404 for any path. Add real edge functions by routing in
|
|
9
|
+
// the request handler below, or by mounting per-function directories under
|
|
10
|
+
// functions/<name>/index.ts and updating this dispatcher.
|
|
11
|
+
|
|
12
|
+
Deno.serve((_req: Request) => {
|
|
13
|
+
return new Response(
|
|
14
|
+
JSON.stringify({ error: 'No edge function configured for this path' }),
|
|
15
|
+
{ status: 404, headers: { 'content-type': 'application/json' } }
|
|
16
|
+
);
|
|
17
|
+
});
|
package/carbon/package.json
CHANGED
|
@@ -50,41 +50,41 @@
|
|
|
50
50
|
"stripe:listen": "stripe listen --forward-to localhost:3000/api/webhooks/stripe"
|
|
51
51
|
},
|
|
52
52
|
"dependencies": {
|
|
53
|
-
"@base-ui/react": "^1.
|
|
53
|
+
"@base-ui/react": "^1.5.0",
|
|
54
54
|
"@fontsource-variable/noto-sans": "^5.2.10",
|
|
55
55
|
"@fontsource/jetbrains-mono": "^5.2.8",
|
|
56
56
|
"@hono/node-server": "^1.19.13",
|
|
57
|
-
"@hono/zod-openapi": "^1.
|
|
58
|
-
"@hookform/resolvers": "^5.
|
|
57
|
+
"@hono/zod-openapi": "^1.4.0",
|
|
58
|
+
"@hookform/resolvers": "^5.4.0",
|
|
59
59
|
"@mdx-js/react": "^3.1.1",
|
|
60
60
|
"@mdx-js/rollup": "^3.1.1",
|
|
61
|
-
"@scalar/api-reference-react": "^0.9.
|
|
61
|
+
"@scalar/api-reference-react": "^0.9.41",
|
|
62
62
|
"@supabase/ssr": "^0.9.0",
|
|
63
|
-
"@supabase/supabase-js": "^2.
|
|
63
|
+
"@supabase/supabase-js": "^2.106.2",
|
|
64
64
|
"@tanstack/react-query": "^5.95.2",
|
|
65
65
|
"class-variance-authority": "^0.7.1",
|
|
66
66
|
"clsx": "^2.1.1",
|
|
67
67
|
"cmdk": "^1.1.1",
|
|
68
68
|
"date-fns": "^4.1.0",
|
|
69
69
|
"embla-carousel-react": "^8.6.0",
|
|
70
|
-
"framer-motion": "^12.
|
|
71
|
-
"hono": "^4.12.
|
|
70
|
+
"framer-motion": "^12.40.0",
|
|
71
|
+
"hono": "^4.12.23",
|
|
72
72
|
"i18next": "^25.10.9",
|
|
73
73
|
"i18next-browser-languagedetector": "^8.2.1",
|
|
74
74
|
"input-otp": "^1.4.2",
|
|
75
75
|
"ioredis": "^5.10.1",
|
|
76
|
-
"lenis": "^1.3.
|
|
76
|
+
"lenis": "^1.3.23",
|
|
77
77
|
"lucide-react": "^1.7.0",
|
|
78
78
|
"next-themes": "^0.4.6",
|
|
79
|
-
"nodemailer": "^8.0.
|
|
79
|
+
"nodemailer": "^8.0.8",
|
|
80
80
|
"pino": "^10.3.1",
|
|
81
|
-
"react": "^19.2.
|
|
81
|
+
"react": "^19.2.6",
|
|
82
82
|
"react-day-picker": "^9.14.0",
|
|
83
|
-
"react-dom": "^19.2.
|
|
83
|
+
"react-dom": "^19.2.6",
|
|
84
84
|
"react-hook-form": "^7.72.0",
|
|
85
85
|
"react-i18next": "^16.6.6",
|
|
86
|
-
"react-resizable-panels": "^4.
|
|
87
|
-
"react-router-dom": "^7.
|
|
86
|
+
"react-resizable-panels": "^4.11.2",
|
|
87
|
+
"react-router-dom": "^7.15.1",
|
|
88
88
|
"recharts": "^3.8.1",
|
|
89
89
|
"rehype-autolink-headings": "^7.1.0",
|
|
90
90
|
"rehype-slug": "^6.0.0",
|
|
@@ -113,7 +113,8 @@
|
|
|
113
113
|
"ip-address@<10.1.1": ">=10.1.1",
|
|
114
114
|
"fast-uri@<3.1.2": ">=3.1.2",
|
|
115
115
|
"protobufjs@<7.5.6": "^7.5.6",
|
|
116
|
-
"@protobufjs/utf8@<1.1.1": "^1.1.1"
|
|
116
|
+
"@protobufjs/utf8@<1.1.1": "^1.1.1",
|
|
117
|
+
"ws@>=8.0.0 <8.20.1": ">=8.20.1"
|
|
117
118
|
},
|
|
118
119
|
"peerDependencyRules": {
|
|
119
120
|
"allowedVersions": {
|
|
@@ -122,33 +123,33 @@
|
|
|
122
123
|
}
|
|
123
124
|
},
|
|
124
125
|
"devDependencies": {
|
|
125
|
-
"@biomejs/biome": "^2.4.
|
|
126
|
+
"@biomejs/biome": "^2.4.15",
|
|
126
127
|
"@hono/swagger-ui": "^0.6.1",
|
|
127
|
-
"@scalar/hono-api-reference": "^0.10.
|
|
128
|
+
"@scalar/hono-api-reference": "^0.10.19",
|
|
128
129
|
"@tailwindcss/typography": "^0.5.19",
|
|
129
|
-
"@tailwindcss/vite": "^4.
|
|
130
|
+
"@tailwindcss/vite": "^4.3.0",
|
|
130
131
|
"@testing-library/jest-dom": "^6.6.3",
|
|
131
132
|
"@testing-library/react": "^16.3.0",
|
|
132
133
|
"@testing-library/user-event": "^14.6.1",
|
|
133
|
-
"@types/node": "^25.
|
|
134
|
-
"@types/nodemailer": "^
|
|
134
|
+
"@types/node": "^25.9.1",
|
|
135
|
+
"@types/nodemailer": "^8.0.0",
|
|
135
136
|
"@types/pg": "^8.20.0",
|
|
136
|
-
"@types/react": "^19.2.
|
|
137
|
+
"@types/react": "^19.2.15",
|
|
137
138
|
"@types/react-dom": "^19.2.3",
|
|
138
|
-
"@vitejs/plugin-react": "^6.0.
|
|
139
|
-
"@vitest/coverage-v8": "^4.1.
|
|
139
|
+
"@vitejs/plugin-react": "^6.0.2",
|
|
140
|
+
"@vitest/coverage-v8": "^4.1.7",
|
|
140
141
|
"concurrently": "^9.2.1",
|
|
141
|
-
"esbuild": "^0.
|
|
142
|
+
"esbuild": "^0.28.0",
|
|
142
143
|
"jsdom": "^26.1.0",
|
|
143
|
-
"pg": "^8.
|
|
144
|
+
"pg": "^8.21.0",
|
|
144
145
|
"pino-pretty": "^13.1.3",
|
|
145
146
|
"remark-gfm": "^4.0.1",
|
|
146
|
-
"shadcn": "^4.1
|
|
147
|
-
"tailwindcss": "^4.
|
|
148
|
-
"tsx": "^4.
|
|
147
|
+
"shadcn": "^4.8.1",
|
|
148
|
+
"tailwindcss": "^4.3.0",
|
|
149
|
+
"tsx": "^4.22.3",
|
|
149
150
|
"tw-animate-css": "^1.4.0",
|
|
150
|
-
"typescript": "^6.0.
|
|
151
|
-
"vite": "^8.0.
|
|
152
|
-
"vitest": "^4.1.
|
|
151
|
+
"typescript": "^6.0.3",
|
|
152
|
+
"vite": "^8.0.14",
|
|
153
|
+
"vitest": "^4.1.7"
|
|
153
154
|
}
|
|
154
155
|
}
|
package/carbon/vite.config.ts
CHANGED
|
@@ -68,7 +68,19 @@ export default defineConfig(({ mode }) => {
|
|
|
68
68
|
return 'vendor-react';
|
|
69
69
|
if (id.includes('node_modules/@supabase/')) return 'vendor-supabase';
|
|
70
70
|
if (id.includes('node_modules/@tanstack/react-query')) return 'vendor-query';
|
|
71
|
-
|
|
71
|
+
// DO NOT split recharts into its own chunk. With rolldown-vite (Vite 8 +
|
|
72
|
+
// rolldown 1.x), splitting a CJS-React consumer causes rolldown to bundle
|
|
73
|
+
// a *second* copy of React inside that chunk to satisfy the CJS interop
|
|
74
|
+
// for `import * as React from 'react'`. The main entry then imports its
|
|
75
|
+
// React namespace (X.lazy, X.useState) from the recharts chunk while
|
|
76
|
+
// page chunks still import from vendor-react — two Reacts in one app.
|
|
77
|
+
// React.lazy elements created by one and rendered by the other resolve
|
|
78
|
+
// to {} and React throws minified #306 ("Lazy element type must resolve
|
|
79
|
+
// to a class or function") on first render → ErrorBoundary catches →
|
|
80
|
+
// white screen. Verified 2026-05-26 against rolldown 1.0.1 by patching
|
|
81
|
+
// the deployed vendor-react and dumping the failing lazy's payload.
|
|
82
|
+
// Leave recharts in the default chunk so React stays single-copy.
|
|
83
|
+
// if (id.includes('node_modules/recharts')) return 'vendor-charts';
|
|
72
84
|
if (id.includes('node_modules/framer-motion')) return 'vendor-motion';
|
|
73
85
|
if (
|
|
74
86
|
id.includes('node_modules/cmdk') ||
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Fault-tolerant archive_command for Postgres + wal-g.
|
|
3
|
+
#
|
|
4
|
+
# Standard archive_command (`wal-g wal-push %p`) blocks WAL recycling until it
|
|
5
|
+
# succeeds. If wal-g fails for any reason (S3 outage, bad credentials, bucket
|
|
6
|
+
# missing, network), pg_wal grows unbounded — combined with archive_timeout
|
|
7
|
+
# this fills the disk in days even on an idle DB.
|
|
8
|
+
#
|
|
9
|
+
# This wrapper:
|
|
10
|
+
# 1. Tries `wal-g wal-push` with $RETRIES attempts and exponential backoff.
|
|
11
|
+
# 2. On final failure, logs loudly to stderr (visible in `docker logs db`)
|
|
12
|
+
# and exits 0 so PG can recycle the segment. PITR coverage degrades for
|
|
13
|
+
# the failed segment; the database stays up.
|
|
14
|
+
#
|
|
15
|
+
# Trade-off: a persistent archive outage means a gap in your PITR chain. The
|
|
16
|
+
# alternative — letting WAL fill the disk and taking the DB offline — is worse
|
|
17
|
+
# for any SaaS template. Monitor for the "WAL_ARCHIVE_FAILED" line to detect
|
|
18
|
+
# silent backup regressions.
|
|
19
|
+
#
|
|
20
|
+
# Invoked by postgres as: wal-archive.sh <wal-file-path>
|
|
21
|
+
# %p is passed by Postgres and is the absolute path to the WAL segment.
|
|
22
|
+
|
|
23
|
+
set -u
|
|
24
|
+
WAL_PATH="${1:?archive_command requires a WAL file path}"
|
|
25
|
+
RETRIES="${WAL_ARCHIVE_RETRIES:-3}"
|
|
26
|
+
SLEEP_BASE="${WAL_ARCHIVE_SLEEP:-2}"
|
|
27
|
+
|
|
28
|
+
attempt=1
|
|
29
|
+
while [ "$attempt" -le "$RETRIES" ]; do
|
|
30
|
+
if /usr/local/bin/wal-g wal-push "$WAL_PATH"; then
|
|
31
|
+
exit 0
|
|
32
|
+
fi
|
|
33
|
+
if [ "$attempt" -lt "$RETRIES" ]; then
|
|
34
|
+
sleep_for=$((SLEEP_BASE ** attempt))
|
|
35
|
+
sleep "$sleep_for"
|
|
36
|
+
fi
|
|
37
|
+
attempt=$((attempt + 1))
|
|
38
|
+
done
|
|
39
|
+
|
|
40
|
+
# All retries exhausted. Loud, greppable log line so monitoring/alerting can
|
|
41
|
+
# catch this — but exit 0 so PG recycles the segment instead of pinning it.
|
|
42
|
+
echo "WAL_ARCHIVE_FAILED: wal-g wal-push '$WAL_PATH' failed after $RETRIES attempts; allowing PG to recycle (PITR gap for this segment)" >&2
|
|
43
|
+
exit 0
|
|
@@ -56,3 +56,11 @@ compactor:
|
|
|
56
56
|
retention_enabled: true
|
|
57
57
|
retention_delete_delay: 2h
|
|
58
58
|
compaction_interval: 10m
|
|
59
|
+
# Loki 3.x requires delete_request_store when retention_enabled is true,
|
|
60
|
+
# otherwise the container fails to start with "CONFIG ERROR: invalid
|
|
61
|
+
# compactor config: compactor.delete-request-store should be configured
|
|
62
|
+
# when retention is enabled" and crash-loops. RCA: prod-1 2026-05-26,
|
|
63
|
+
# loki had been restart-looping since first deploy because of this.
|
|
64
|
+
# filesystem matches the storage_config above; switch to s3/gcs if the
|
|
65
|
+
# rest of loki storage moves off filesystem.
|
|
66
|
+
delete_request_store: filesystem
|
package/package.json
CHANGED
|
@@ -355,16 +355,28 @@ export class HetznerS3Provider {
|
|
|
355
355
|
* @param {string} bucketName - Bucket name
|
|
356
356
|
*/
|
|
357
357
|
async deleteBucket(bucketName) {
|
|
358
|
-
|
|
358
|
+
// Hetzner's S3 returns BucketNotEmpty for some time after the last object
|
|
359
|
+
// deletion finishes propagating, even when GET/LIST already report the
|
|
360
|
+
// bucket as empty. Observed in prod-1 destroy 2026-05-26: cleanup ran,
|
|
361
|
+
// all subsequent listings returned 0 items, but DeleteBucket still raised
|
|
362
|
+
// BucketNotEmpty for >30s. Manual `aws s3api delete-bucket` minutes later
|
|
363
|
+
// succeeded immediately. Backoff schedule below totals ~90s wall time
|
|
364
|
+
// across 7 attempts — covers the consistency lag we've seen without
|
|
365
|
+
// dragging out the destroy stage on the happy path.
|
|
366
|
+
const delays = [2000, 5000, 10000, 15000, 20000, 25000, 30000];
|
|
367
|
+
let lastErr;
|
|
368
|
+
for (let attempt = 0; attempt <= delays.length; attempt++) {
|
|
359
369
|
try {
|
|
360
370
|
await this._send(new DeleteBucketCommand({ Bucket: bucketName }));
|
|
361
371
|
return;
|
|
362
372
|
} catch (err) {
|
|
373
|
+
lastErr = err;
|
|
363
374
|
const isNotEmpty = err.Code === 'BucketNotEmpty' || err.message?.includes('not empty');
|
|
364
|
-
if (!isNotEmpty || attempt ===
|
|
365
|
-
await new Promise((r) => setTimeout(r,
|
|
375
|
+
if (!isNotEmpty || attempt === delays.length) throw err;
|
|
376
|
+
await new Promise((r) => setTimeout(r, delays[attempt]));
|
|
366
377
|
}
|
|
367
378
|
}
|
|
379
|
+
throw lastErr;
|
|
368
380
|
}
|
|
369
381
|
|
|
370
382
|
/**
|
|
@@ -546,7 +558,15 @@ export class HetznerS3Provider {
|
|
|
546
558
|
// multipart API absent — covered above
|
|
547
559
|
}
|
|
548
560
|
|
|
549
|
-
|
|
561
|
+
// If all three listings succeeded and returned zero items, the bucket
|
|
562
|
+
// *appears* empty but DeleteBucket still raised BucketNotEmpty. Almost
|
|
563
|
+
// always Hetzner consistency lag — the upstream caller has already
|
|
564
|
+
// exhausted its retry budget by this point, so tell the operator how to
|
|
565
|
+
// recover instead of the generic "unable to list" message which sounds
|
|
566
|
+
// like the diagnostic itself broke.
|
|
567
|
+
return parts.length > 0
|
|
568
|
+
? parts.join(', ')
|
|
569
|
+
: 'bucket appears empty in all listings — likely Hetzner S3 eventual-consistency lag. Wait ~60s, then retry the destroy or delete the bucket manually with `aws s3api delete-bucket --bucket <name> --endpoint-url <endpoint>`.';
|
|
550
570
|
}
|
|
551
571
|
|
|
552
572
|
/**
|